diff options
Diffstat (limited to 'stacks')
152 files changed, 21692 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..458883d --- /dev/null +++ b/stacks/daemon/README.md @@ -0,0 +1,72 @@ +# max25d — Linux daemon + +**max25d** supervises TNC/modem hardware, runs per-device RX/TX backends, and exposes M25/1 to `max25-terminal`. + +Linux only. Config: `share/max25/max25d.ini.example`. + +## Components + +| File | Role | +|------|------| +| `max25d` | Python 3 daemon — M25/1 server, stack lifecycle | +| `device_backends.py` | Backend abstraction (TNC, BayCom, CRDOP) | +| `kiss_bridge.py` | Serial KISS for command-mode TNCs (TNC2C/PK-TNC2) | +| `banlist.py` | AX.25 source ban list (silent RX drop) | +| `test_multi_device.py` | Multi-device routing smoke | + +## Backends + +| Device id | Backend | Notes | +|-----------|---------|-------| +| `tnc2c`, `pktnc2` | `KissSerialBackend` | CI-tested | +| `max25e0` (`max25-bcpr:bc0`) | bcpr / KISS PTY | BayCom/based SER12 userspace — [../max25-bcpr/README.md](../max25-bcpr/README.md) | +| `baycom-kiss` | `KissRawSerialBackend` | USB/async KISS (not SER12 product face) | +| `soft-crdop` | `CrdopTcpBackend` | MAX25-SoftModem (CRDOP) — standard; KISS default; sound IN/OUT + radio like hardware modem | + +Untested backends log a startup warning; SEND/RX return `ERR link not ready` when the stack path is unavailable. + +## Quick start + +```bash +# Protocol only (no hardware): +./max25d --no-stack -c ../../share/max25/max25d.ini.example + +# With stack auto-start: +sudo ./max25d -c /etc/max25/max25d.ini +``` + +## One RF device per Linux host + +**Target:** one `[devices]` id per `max25d` (localhost-only, single-machine Lite model — see [../../README.md](../../README.md)). + +```ini +[devices] +default = tnc2c +tnc2c = /dev/ttyS4 +``` + +**Legacy multi-id** (deprecated for new sites): `share/max25/max25d.full-station.ini.example`, `max25d.dual-baycom.ini.example`. + +M25/1: `devices=` in `STATUS`, `SET DEVICE <id>`, `GET DEVICES`, `RX device=<id> …`. + +## Source ban list + +Block unwanted AX.25 callers (source address on incoming UI frames). Banned traffic is dropped silently — no terminal display, no daemon log line. + +```ini +[modem] +bans_file = /etc/max25/bans.txt +``` + +File format: one callsign per line (`#` comments allowed). Ban without SSID blocks all SSIDs of that call (`DG1ABC` blocks `DG1ABC-7`). Ban with SSID (`DK0WC-7`) matches only that SSID. + +M25/1: `BAN <callsign>`, `UNBAN <callsign>`, `BANS`. Changes persist to `bans_file` immediately. + +## Install + +```bash +./scripts/install-max25.sh --deps +# Installs: /usr/local/bin/max25d, kiss_bridge.py, device_backends.py, max25-terminal +``` + +Host setup guide: [../../README.md](../../README.md) (quick start). Protocol: [include/max25/protocol.md](../../include/max25/protocol.md). diff --git a/stacks/daemon/ax25_codec.py b/stacks/daemon/ax25_codec.py new file mode 100644 index 0000000..03ee671 --- /dev/null +++ b/stacks/daemon/ax25_codec.py @@ -0,0 +1,176 @@ +""" +AX.25 UI frame codec for max25d — address fields, CRC-CCITT FCS, UI parse/build. + +Derived from libax25 axutils.c (address encoding) and ax25ipd/crc.c (RFC 1171 FCS). +KISS DATA carries the AX.25 body without FCS; over-the-air and some paths include FCS. +""" +from __future__ import annotations + +import re +from typing import Optional + +# HDLC CRC-CCITT (polynomial 0x1021), RFC 1171 / ax25ipd table +_FCS_TABLE: tuple[int, ...] = ( + 0x0000, 0x1189, 0x2312, 0x329B, 0x4624, 0x57AD, 0x6536, 0x74BF, + 0x8C48, 0x9DC1, 0xAF5A, 0xBED3, 0xCA6C, 0xDBE5, 0xE97E, 0xF8F7, + 0x1081, 0x0108, 0x3393, 0x221A, 0x56A5, 0x472C, 0x75B7, 0x643E, + 0x9CC9, 0x8D40, 0xBFDB, 0xAE52, 0xDAED, 0xCB64, 0xF9FF, 0xE876, + 0x2102, 0x308B, 0x0210, 0x1399, 0x6726, 0x76AF, 0x4434, 0x55BD, + 0xAD4A, 0xBCC3, 0x8E58, 0x9FD1, 0xEB6E, 0xFAE7, 0xC87C, 0xD9F5, + 0x3183, 0x200A, 0x1291, 0x0318, 0x77A7, 0x662E, 0x54B5, 0x453C, + 0xBDCB, 0xAC42, 0x9ED9, 0x8F50, 0xFBEF, 0xEA66, 0xD8FD, 0xC974, + 0x4204, 0x538D, 0x6116, 0x709F, 0x0420, 0x15A9, 0x2732, 0x36BB, + 0xCE4C, 0xDFC5, 0xED5E, 0xFCD7, 0x8868, 0x99E1, 0xAB7A, 0xBAF3, + 0x5285, 0x430C, 0x7197, 0x601E, 0x14A1, 0x0528, 0x37B3, 0x263A, + 0xDECD, 0xCF44, 0xFDDF, 0xEC56, 0x98E9, 0x8960, 0xBBFB, 0xAA72, + 0x6306, 0x728F, 0x4014, 0x519D, 0x2522, 0x34AB, 0x0630, 0x17B9, + 0xEF4E, 0xFEC7, 0xCC5C, 0xDDD5, 0xA96A, 0xB8E3, 0x8A78, 0x9BF1, + 0x7387, 0x620E, 0x5095, 0x411C, 0x35A3, 0x242A, 0x16B1, 0x0738, + 0xFFCF, 0xEE46, 0xDCDD, 0xCD54, 0xB9EB, 0xA862, 0x9AF9, 0x8B70, + 0x8408, 0x9581, 0xA71A, 0xB693, 0xC22C, 0xD3A5, 0xE13E, 0xF0B7, + 0x0840, 0x19C9, 0x2B52, 0x3ADB, 0x4E64, 0x5FED, 0x6D76, 0x7CFF, + 0x9489, 0x8500, 0xB79B, 0xA612, 0xD2AD, 0xC324, 0xF1BF, 0xE036, + 0x18C1, 0x0948, 0x3BD3, 0x2A5A, 0x5EE5, 0x4F6C, 0x7DF7, 0x6C7E, + 0xA50A, 0xB483, 0x8618, 0x9791, 0xE32E, 0xF2A7, 0xC03C, 0xD1B5, + 0x2942, 0x38CB, 0x0A50, 0x1BD9, 0x6F66, 0x7EEF, 0x4C74, 0x5DFD, + 0xB58B, 0xA402, 0x9699, 0x8710, 0xF3AF, 0xE226, 0xD0BD, 0xC134, + 0x39C3, 0x284A, 0x1AD1, 0x0B58, 0x7FE7, 0x6E6E, 0x5CF5, 0x4D7C, + 0xC60C, 0xD785, 0xE51E, 0xF497, 0x8028, 0x91A1, 0xA33A, 0xB2B3, + 0x4A44, 0x5BCD, 0x6956, 0x78DF, 0x0C60, 0x1DE9, 0x2F72, 0x3EFB, + 0xD68D, 0xC704, 0xF59F, 0xE416, 0x90A9, 0x8120, 0xB3BB, 0xA232, + 0x5AC5, 0x4B4C, 0x79D7, 0x685E, 0x1CE1, 0x0D68, 0x3FF3, 0x2E7A, + 0xE70E, 0xF687, 0xC41C, 0xD595, 0xA12A, 0xB0A3, 0x8238, 0x93B1, + 0x6B46, 0x7ACF, 0x4854, 0x59DD, 0x2D62, 0x3CEB, 0x0E70, 0x1FF9, + 0xF78F, 0xE606, 0xD49D, 0xC514, 0xB1AB, 0xA022, 0x92B9, 0x8330, + 0x7BC7, 0x6A4E, 0x58D5, 0x495C, 0x3DE3, 0x2C6A, 0x1EF1, 0x0F78, +) + +_FCS_GOOD = 0xF0B8 # ax25ipd PPPGOODFCS — residual after valid frame + FCS + +AX25_UI_CONTROL = 0x03 +AX25_UI_PID = 0xF0 +MIN_UI_FRAME = 16 # dest(7) + src(7) + ctrl + pid + +_CALLSIGN_RE = re.compile(r"^([A-Z0-9]{1,6})(?:-([0-9]{1,2}))?$") + + +def parse_callsign(text: str) -> tuple[str, int]: + """Parse operator callsign text; invalid SSID clamps to 0 (legacy helper).""" + text = text.strip().upper() + if "-" in text: + call, ssid_s = text.split("-", 1) + try: + ssid = int(ssid_s) + except ValueError: + ssid = 0 + if ssid < 0 or ssid > 15: + ssid = 0 + return call[:6], ssid + return text[:6], 0 + + +def validate_callsign(text: str) -> tuple[str, int]: + """Strict callsign parse (libax25 ax25_aton_entry rules).""" + text = text.strip().upper() + match = _CALLSIGN_RE.match(text) + if not match: + raise ValueError(f"invalid AX.25 callsign: {text!r}") + call = match.group(1) + ssid = int(match.group(2)) if match.group(2) is not None else 0 + if ssid < 0 or ssid > 15: + raise ValueError(f"invalid AX.25 SSID in {text!r}") + return call, ssid + + +def format_callsign(call: str, ssid: int) -> str: + """Textual callsign; omit -0 suffix (libax25 ax25_ntoa convention).""" + call = call.strip().upper() + if ssid <= 0: + return call + if ssid >= 10: + return f"{call}-{ssid}" + return f"{call}-{ssid}" + + +def ax25_crc(data: bytes) -> int: + """Compute AX.25 FCS over body (addresses + control + PID + info).""" + fcs = 0xFFFF + for b in data: + fcs = (fcs >> 8) ^ _FCS_TABLE[(fcs ^ b) & 0xFF] + return fcs ^ 0xFFFF + + +def ax25_crc_valid(frame: bytes) -> bool: + """True when frame includes a valid little-endian FCS trailer.""" + fcs = 0xFFFF + for b in frame: + fcs = (fcs >> 8) ^ _FCS_TABLE[(fcs ^ b) & 0xFF] + return fcs == _FCS_GOOD + + +def ax25_encode_address(call: str, ssid: int, last: bool) -> bytes: + """Seven-byte AX.25 address field (libax25 ax25_aton_entry layout).""" + padded = call.upper().ljust(6)[:6] + raw = bytes((ord(c) << 1) for c in padded) + ssid_b = ((ssid & 0x0F) << 1) | (0x01 if last else 0x00) + return raw + bytes([ssid_b]) + + +def ax25_decode_address(raw: bytes) -> tuple[str, int, bool]: + call = "".join(chr((b >> 1) & 0x7F) for b in raw[:6]).strip() + ssid = (raw[6] >> 1) & 0x0F + last = bool(raw[6] & 0x01) + return call, ssid, last + + +def ax25_build_ui(src: str, dst: str, info: bytes) -> bytes: + src_call, src_ssid = validate_callsign(src) + dst_call, dst_ssid = validate_callsign(dst) + body = ( + ax25_encode_address(dst_call, dst_ssid, last=False) + + ax25_encode_address(src_call, src_ssid, last=True) + + bytes([AX25_UI_CONTROL, AX25_UI_PID]) + + info + ) + crc = ax25_crc(body) + return body + bytes((crc & 0xFF, crc >> 8)) + + +def ax25_parse_ui(frame: bytes) -> Optional[tuple[str, str, bytes]]: + """ + Parse a UI frame. Strips FCS only when the trailer validates (ax25ipd ok_crc). + KISS payloads are usually FCS-free; over-the-air captures may include FCS. + """ + if len(frame) < MIN_UI_FRAME: + return None + + body = frame + if len(frame) >= MIN_UI_FRAME + 2 and ax25_crc_valid(frame): + body = frame[:-2] + + pos = 0 + addresses: list[tuple[str, int, bool]] = [] + while pos + 7 <= len(body): + call, ssid, last = ax25_decode_address(body[pos : pos + 7]) + addresses.append((call, ssid, last)) + pos += 7 + if last: + break + else: + return None + + if len(addresses) < 2: + return None + if pos + 2 > len(body): + return None + if body[pos] != AX25_UI_CONTROL or body[pos + 1] != AX25_UI_PID: + return None + + payload = body[pos + 2 :] + dst_call, dst_ssid, _ = addresses[0] + src_call, src_ssid, _ = addresses[-1] + return ( + format_callsign(src_call, src_ssid), + format_callsign(dst_call, dst_ssid), + payload, + ) diff --git a/stacks/daemon/banlist.py b/stacks/daemon/banlist.py new file mode 100644 index 0000000..061ed1f --- /dev/null +++ b/stacks/daemon/banlist.py @@ -0,0 +1,89 @@ +"""Simple AX.25 source ban list for max25d — silent RX drop.""" +from __future__ import annotations + +import re +import sys +import threading +from pathlib import Path +from typing import Optional + +DEFAULT_BANS_FILE = Path("/etc/max25/bans.txt") + +_AX25_UI_SRC_RE = re.compile(r"\[AX25 UI ([^>]+)>") + + +def extract_ax25_source(line: str) -> Optional[str]: + """Return source callsign from a formatted AX.25 UI RX line, or None.""" + match = _AX25_UI_SRC_RE.search(line) + if not match: + return None + return match.group(1).strip().upper() + + +def callsign_banned(ban_entry: str, source: str) -> bool: + """Match ban entry against an incoming source callsign.""" + ban = ban_entry.strip().upper() + src = source.strip().upper() + if not ban or not src: + return False + if "-" in ban: + return ban == src + return ban == src.split("-", 1)[0] + + +class BanList: + """Persistent set of banned AX.25 source addresses.""" + + def __init__(self, path: Path | str = DEFAULT_BANS_FILE) -> None: + self.path = Path(path) + self._lock = threading.Lock() + self._entries: set[str] = set() + self.load() + + def load(self) -> None: + entries: set[str] = set() + try: + if self.path.is_file(): + text = self.path.read_text(encoding="utf-8") + for raw in text.splitlines(): + line = raw.split("#", 1)[0].strip().upper() + if line: + entries.add(line) + except OSError as exc: + print(f"max25d: banlist load failed ({self.path}): {exc}", file=sys.stderr) + with self._lock: + self._entries = entries + + def save(self) -> None: + lines = sorted(self._entries) + content = "\n".join(lines) + if content: + content += "\n" + self.path.parent.mkdir(parents=True, exist_ok=True) + self.path.write_text(content, encoding="utf-8") + + def list(self) -> list[str]: + with self._lock: + return sorted(self._entries) + + def add(self, callsign: str) -> None: + call = callsign.strip().upper() + with self._lock: + self._entries.add(call) + self.save() + + def remove(self, callsign: str) -> bool: + call = callsign.strip().upper() + with self._lock: + if call not in self._entries: + return False + self._entries.remove(call) + self.save() + return True + + def is_banned(self, source: str) -> bool: + src = source.strip().upper() + if not src: + return False + with self._lock: + return any(callsign_banned(entry, src) for entry in self._entries) diff --git a/stacks/daemon/daemon_log.py b/stacks/daemon/daemon_log.py new file mode 100644 index 0000000..f1696df --- /dev/null +++ b/stacks/daemon/daemon_log.py @@ -0,0 +1,237 @@ +""" +Structured stderr logging for max25d — human- and machine-readable lines. + +Format: + max25d [LEVEL] [area] message + max25d [LEVEL] [area] [device] message + +Levels: INFO, OK, WARN, ERROR, EVENT, RECOVERY +""" +from __future__ import annotations + +import sys +from dataclasses import dataclass +from typing import Callable, Optional + +PREFIX = "max25d" +DEVICE_TOKENS = ("tnc2c", "pktnc2", "tmodem", "max25e0", "max25e0:bc0", "max25e0:bc1", "baycom-kiss", "soft-crdop") + + +@dataclass(frozen=True) +class DeviceSummary: + device_id: str + backend: str + hardware: str + serial: str + enabled: bool + tested: bool + + +class DaemonLogger: + """Thread-safe enough for max25d (GIL); all lines go to stderr.""" + + def __init__(self, emit: Optional[Callable[[str], None]] = None) -> None: + self._emit = emit or self._default_emit + + @staticmethod + def _default_emit(line: str) -> None: + print(line, file=sys.stderr, flush=True) + + def _line( + self, + level: str, + msg: str, + *, + area: str = "", + device: str = "", + ) -> None: + parts = [PREFIX, f"[{level}]"] + if area: + parts.append(f"[{area}]") + if device: + parts.append(f"[{device}]") + parts.append(msg) + self._emit(" ".join(parts)) + + def info(self, msg: str, *, area: str = "", device: str = "") -> None: + self._line("INFO", msg, area=area, device=device) + + def ok(self, msg: str, *, area: str = "", device: str = "") -> None: + self._line("OK", msg, area=area, device=device) + + def warn(self, msg: str, *, area: str = "", device: str = "") -> None: + self._line("WARN", msg, area=area, device=device) + + def error(self, msg: str, *, area: str = "", device: str = "") -> None: + self._line("ERROR", msg, area=area, device=device) + + def event(self, msg: str, *, area: str = "", device: str = "") -> None: + self._line("EVENT", msg, area=area, device=device) + + def recovery(self, msg: str, *, device: str = "") -> None: + self._line("RECOVERY", msg, area="serial", device=device) + + def _parse_device_prefix(self, text: str) -> tuple[str, str]: + head, sep, tail = text.partition(":") + if sep and head in DEVICE_TOKENS: + return head, tail.strip() + device = "" + for token in DEVICE_TOKENS: + if f"({token})" in text or f" {token})" in text: + device = token + break + return device, text + + def emit_unstructured(self, msg: str) -> None: + """Map legacy free-form strings to structured levels.""" + device, text = self._parse_device_prefix(msg.strip()) + lower = text.lower() + if lower.startswith("warning:"): + self.warn(text[8:].strip(), device=device) + elif lower.startswith("recovery:"): + self.recovery(text[9:].strip(), device=device) + elif " prep ok" in lower or lower.startswith("ok:"): + self.ok(text, device=device) + elif any(x in lower for x in ("failed", "error", "fail:")): + self.error(text, device=device) + elif lower.startswith("serial watch:"): + self.info(text, area="watch", device=device) + elif lower.startswith("serial "): + self.info(text, area="serial", device=device) + elif lower.startswith("stack "): + self.info(text, area="stack", device=device) + elif lower.startswith("rx ") or lower.startswith("tx "): + self.event(text, device=device) + else: + self.info(text, device=device) + + def banner(self, title: str) -> None: + self._emit(f"{PREFIX} === {title} ===") + + def section(self, name: str) -> None: + self._emit(f"{PREFIX} [{name}]") + + def kv(self, key: str, value: str, *, indent: int = 0) -> None: + pad = " " * indent + self._emit(f"{PREFIX} {pad}{key}={value}") + + +LOGGER = DaemonLogger() + + +def device_serial_label(dev) -> str: + """Human-readable serial/KISS path for startup summary.""" + spec = (dev.device_spec or dev.serial_device or "").strip() + if dev.backend_type == "kiss-serial": + baud = dev.serial_baud or "?" + line = (dev.serial_line or "?").upper() + dtr = dev.serial_dtr_rts or "default" + kiss = dev.serial_kiss_entry or "default" + path = spec or dev.serial_device or "?" + return f"{path} {baud} {line} dtr={dtr} kiss_entry={kiss}" + if dev.backend_type == "baycom-kiss": + return f"baycom:{dev.baycom_modem or 'a'} kiss={dev.kiss_link or '?'}" + if dev.backend_type == "crdop-tcp": + host = dev.crdop_host or "127.0.0.1" + port = dev.crdop_port or "?" + return f"crdop-tcp {host}:{port}" + if spec: + return spec + return dev.backend_type or "auto" + + +def emit_startup_banner( + *, + config_path: Optional[str], + cfg, + devices: list, + tested_fn: Callable[[str], bool], +) -> None: + LOGGER.banner("MAX25d starting") + LOGGER.section("config") + if config_path: + LOGGER.kv("ini", config_path, indent=1) + else: + LOGGER.kv("ini", "(built-in defaults)", indent=1) + LOGGER.kv("mode", cfg.mode, indent=1) + LOGGER.kv("default_device", cfg.default_device or cfg.device, indent=1) + LOGGER.kv("devices", str(len(devices)), indent=1) + + LOGGER.section("network") + LOGGER.kv("tcp", f"{cfg.tcp_host}:{cfg.tcp_port}", indent=1) + LOGGER.kv("unix", cfg.unix_socket or "(disabled)", indent=1) + if cfg.tcp_password: + LOGGER.kv("tcp_auth", "enabled", indent=1) + else: + LOGGER.warn("TCP has no password — set tcp_password before exposing LAN", area="security") + + LOGGER.section("modem") + LOGGER.kv("callerid", cfg.callerid, indent=1) + LOGGER.kv("callid", cfg.callid, indent=1) + LOGGER.kv("ax25_ui", "yes" if cfg.ax25_ui else "no", indent=1) + if cfg.bans_file: + LOGGER.kv("bans_file", cfg.bans_file, indent=1) + + LOGGER.section("stack") + LOGGER.kv("auto_start", "yes" if cfg.auto_start else "no", indent=1) + LOGGER.kv("serial_enabled", "yes" if cfg.serial_enabled else "no", indent=1) + LOGGER.kv("stack_recover_only", "yes" if cfg.stack_recover_only else "no", indent=1) + LOGGER.kv("serial_watch", "yes" if cfg.serial_watch else "no", indent=1) + if cfg.serial_watch: + LOGGER.kv("serial_watch_interval", f"{cfg.serial_watch_interval}s", indent=1) + LOGGER.kv("serial_watch_startup_grace", f"{cfg.serial_watch_startup_grace}s", indent=1) + LOGGER.kv("serial_bootwait_escalate", "yes" if cfg.serial_bootwait_escalate else "no", indent=1) + + LOGGER.section("session") + LOGGER.kv( + "detach", + "max25d --session tmux | max25d-session start/attach/stop", + indent=1, + ) + + LOGGER.section("devices") + for dev in devices: + if not dev.enabled: + LOGGER.kv( + dev.device_id, + f"disabled backend={dev.backend_type or 'auto'}", + indent=1, + ) + continue + serial = device_serial_label(dev) + hw = dev.hardware or cfg.hardware + tested = tested_fn(dev.device_id) + note = "" if tested else " — not hardware-validated in CI" + LOGGER.kv( + dev.device_id, + f"{hw} {serial}{note}", + indent=1, + ) + if not tested and dev.backend_type == "kiss-serial": + LOGGER.warn( + "RF path not CI-validated — verify on hardware before production", + area="devices", + device=dev.device_id, + ) + + +def emit_startup_complete( + *, + device_lines: list[tuple[str, str, str]], + tcp_host: str, + tcp_port: int, + unix_socket: str, +) -> None: + LOGGER.banner("MAX25d ready") + LOGGER.section("listen") + LOGGER.kv("tcp", f"{tcp_host}:{tcp_port}", indent=1) + LOGGER.kv("unix", unix_socket or "(disabled)", indent=1) + if device_lines: + LOGGER.section("device_status") + for dev_id, stack_st, link_st in device_lines: + LOGGER.kv(dev_id, f"stack={stack_st} link={link_st}", indent=1) + LOGGER.info( + "attach: max25-terminal -U /run/max25/modem.sock | " + "detach session: max25d-session attach", + area="operator", + ) diff --git a/stacks/daemon/device_backends.py b/stacks/daemon/device_backends.py new file mode 100644 index 0000000..b4c6c29 --- /dev/null +++ b/stacks/daemon/device_backends.py @@ -0,0 +1,1076 @@ +""" +Device backends for max25d — heterogeneous RF paths (TNC, BayCom, CRDOP). + +Each enabled [devices] id gets one backend instance. Backends without hardware +validation log a startup warning but still wire real stack paths (not silent no-ops). +""" +from __future__ import annotations + +import os +import select +import socket +import struct +import termios +import threading +import time +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Callable, Optional + +from kiss_bridge import ( + MAX_PAYLOAD, + KissDecoder, + SerialProfile, + ax25_build_ui, + ax25_parse_ui, + format_rx_line, + kiss_data_frame, + serial_profile_for_device, +) +from kiss_bridge import KissBridge # noqa: E402 — re-exported wrapper target +from paths import normalize_max25_bcpr_path + +LogFn = Callable[[str], None] +RxFn = Callable[[str], None] +InvalidFn = Callable[[], None] + + +def _spec_int(raw: str, default: int) -> int: + try: + return int(str(raw).strip()) + except (TypeError, ValueError): + return default + +# max25e0 host address defaults (overridable in max25d.ini [device.max25e0]) +MAX25E0_DEFAULT_IPV4 = "127.0.0.25/8" +MAX25E0_DEFAULT_IPV6 = "::25/128" + +# manifest.yaml device ids → default hardware + backend kind +DEVICE_REGISTRY: dict[str, dict[str, str | bool]] = { + "tnc2c": {"hardware": "tncs", "backend": "kiss-serial", "tested": True}, + "pktnc2": {"hardware": "tncs", "backend": "kiss-serial", "tested": False}, + "tmodem": {"hardware": "tncs", "backend": "kiss-raw-serial", "tested": False}, + # Kernel baycom-ser12/par96 removed 2026-07-18 — use max25-bcpr → device max25e0 + "baycom-kiss": {"hardware": "modems", "backend": "kiss-raw-serial", "tested": False}, + "pccom-kiss": {"hardware": "modems", "backend": "kiss-raw-serial", "tested": False}, + "max25e0": {"hardware": "modems", "backend": "max25-bcpr-kiss", "tested": True}, + "max25e0:bc0": {"hardware": "modems", "backend": "max25-bcpr-kiss", "tested": True}, + "max25e0:bc1": {"hardware": "modems", "backend": "max25-bcpr-kiss", "tested": True}, + "soft-crdop": {"hardware": "soft-modems", "backend": "crdop-tcp", "tested": True}, + "audio-dummy": {"hardware": "acoustic-bench", "backend": "audio-dummy", "tested": True}, +} + + +def registry_hardware(device_id: str, fallback: str = "tncs") -> str: + entry = DEVICE_REGISTRY.get(device_id, {}) + return str(entry.get("hardware", fallback)) + + +def registry_backend(device_id: str) -> str: + entry = DEVICE_REGISTRY.get(device_id, {}) + return str(entry.get("backend", "kiss-serial")) + + +def registry_tested(device_id: str) -> bool: + entry = DEVICE_REGISTRY.get(device_id, {}) + return bool(entry.get("tested", False)) + + +def baycom_ctl_device_id(dev_cfg: DeviceBackendConfig) -> str: + """Legacy helper: kernel BayCom ctl device id (stack removed — prefer max25-bcpr).""" + entry = DEVICE_REGISTRY.get(dev_cfg.device_id, {}) + if entry.get("backend") == "baycom-kiss": + return dev_cfg.device_id + return "max25e0" + + +@dataclass +class DeviceBackendConfig: + device_id: str + hardware: str = "" + backend_type: str = "" + device_spec: str = "" + enabled: bool = True + # Serial (TNC / baycom-kiss USB) + serial_device: str = "" + serial_baud: int = 0 + serial_line: str = "" + serial_dtr_rts: str = "" + serial_kiss_entry: str = "" + # BayCom kernel KISS PTY + kiss_link: str = "" + baycom_modem: str = "a" + baycom_ini: str = "" + # max25-bcpr userspace SER12 — max25e0 (+ forks max25e0:bcN) + max25_bcpr_ini: str = "" + max25_bcpr_device: str = "" # bc0 | bc1 + # Host addresses (max25e0 family only; forks inherit from max25e0) + ipv4: str = "" + ipv6: str = "" + # Legacy field aliases (read-only mirrors filled by parser) + bcpr_ini: str = "" + bcpr_device: str = "" + # CRDOP TCP + crdop_host: str = "127.0.0.1" + crdop_port: int = 8515 + crdop_profile: str = "default" + crdop_listen: bool = True + # Acoustic bench / audio-dummy + audio_mode: str = "loopback" # loopback | alsa | host + audio_capture: str = "" + audio_playback: str = "" + audio_sample_rate: int = 48000 + audio_host_port: int = 8520 + + +class DeviceBackend(ABC): + """Common RX/TX/PTT surface for max25d.""" + + device_id: str + status: str = "closed" + backend_type: str = "" + + @abstractmethod + def open(self) -> bool: + ... + + @abstractmethod + def close(self) -> None: + ... + + @abstractmethod + def attach_session(self, mycall: str) -> bool: + ... + + @abstractmethod + def detach_session(self) -> None: + ... + + @abstractmethod + def transmit(self, src: str, dst: str, text: str, ax25_ui: bool) -> tuple[bool, str]: + ... + + +class KissSerialBackend(DeviceBackend): + """TNC2C / PK-TNC2 — command-mode serial entry into KISS.""" + + backend_type = "kiss-serial" + + def __init__( + self, + cfg: DeviceBackendConfig, + root: str, + on_rx: RxFn, + log: Optional[LogFn] = None, + prefix: Optional[str] = None, + on_invalid: Optional[InvalidFn] = None, + ) -> None: + self.device_id = cfg.device_id + self._cfg = cfg + self._root = root + self._prefix = prefix + self._on_rx = on_rx + self._on_invalid = on_invalid + self._log = log or (lambda _m: None) + self._bridge: Optional[KissBridge] = None + self.status = "closed" + + def _bridge_log(self, msg: str) -> None: + self._log(f"{self.device_id}: {msg}") + + def _ini_overrides(self) -> dict[str, str]: + out: dict[str, str] = {} + if self._cfg.serial_device: + out["device"] = self._cfg.serial_device + if self._cfg.serial_baud: + out["baud"] = str(self._cfg.serial_baud) + if self._cfg.serial_line: + out["line"] = self._cfg.serial_line + if self._cfg.serial_dtr_rts: + out["dtr_rts"] = self._cfg.serial_dtr_rts + if self._cfg.serial_kiss_entry: + out["kiss_entry"] = self._cfg.serial_kiss_entry + return out + + def open(self) -> bool: + profile = serial_profile_for_device( + self.device_id, + self._root, + self._ini_overrides(), + prefix=self._prefix, + ) + bridge = KissBridge( + profile, + self._on_rx, + self._bridge_log, + tree_root=self._root, + install_prefix=self._prefix, + on_invalid=self._on_invalid, + ) + if not bridge.open(): + self._bridge = bridge + self.status = bridge.status + return False + self._bridge = bridge + self.status = bridge.status + return True + + def close(self) -> None: + if self._bridge is not None: + self._bridge.close() + self.status = self._bridge.status + self._bridge = None + else: + self.status = "closed" + + def stabilize_session(self, mycall: str, *, force: bool = False) -> bool: + if self._bridge is None: + return False + ok = self._bridge.stabilize_session(mycall, force=force) + self.status = self._bridge.status + return ok + + def attach_session(self, mycall: str) -> bool: + if self._bridge is None: + return False + ok = self._bridge.attach_session(mycall) + self.status = self._bridge.status + return ok + + def detach_session(self) -> None: + if self._bridge is None: + return + self._bridge.detach_session() + self.status = self._bridge.status + + def transmit(self, src: str, dst: str, text: str, ax25_ui: bool) -> tuple[bool, str]: + if self._bridge is None: + return False, "serial not ready" + ok, display = self._bridge.transmit(src, dst, text, ax25_ui) + self.status = self._bridge.status + return ok, display + + +class KissRawBackend(DeviceBackend): + """Raw KISS on serial or BayCom KISS PTY (no command-mode entry).""" + + backend_type = "kiss-raw" + + def __init__( + self, + cfg: DeviceBackendConfig, + path: str, + profile: SerialProfile, + on_rx: RxFn, + log: Optional[LogFn] = None, + *, + is_pty: bool = False, + ) -> None: + self.device_id = cfg.device_id + self._path = path + self._profile = profile + self._on_rx = on_rx + self._log = log or (lambda _m: None) + self._is_pty = is_pty + self._fd: Optional[int] = None + self._thread: Optional[threading.Thread] = None + self._stop = threading.Event() + self._lock = threading.Lock() + self._mycall = "" + self._kiss_active = False + self._decoder = KissDecoder() + self.status = "closed" + + def open(self) -> bool: + path = self._path + if not path: + self.status = "error-no-path" + self._log(f"{self.device_id}: no KISS path configured") + return False + if not os.path.exists(path): + self.status = "error-no-device" + self._log(f"{self.device_id}: path missing: {path}") + return False + try: + fd = os.open(path, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK) + if not self._is_pty: + self._configure_serial(fd) + termios.tcflush(fd, termios.TCIOFLUSH) + except OSError as exc: + self.status = "error-open" + self._log(f"{self.device_id}: open failed: {exc}") + return False + self._fd = fd + self.status = "open" + self._stop.clear() + self._thread = threading.Thread( + target=self._rx_loop, + name=f"kiss-raw-{self.device_id}", + daemon=True, + ) + self._thread.start() + self._log(f"{self.device_id}: raw KISS open {path}") + return True + + def _configure_serial(self, fd: int) -> None: + from kiss_bridge import _parse_baud, _parse_line + + speed = _parse_baud(self._profile.baud) + databits, parity = _parse_line(self._profile.line) + t = termios.tcgetattr(fd) + t[0] = t[1] = 0 + t[2] = termios.CLOCAL | termios.CREAD | databits | parity + t[3] = t[4] = t[5] = speed + t[6][termios.VMIN] = 0 + t[6][termios.VTIME] = 5 + termios.tcsetattr(fd, termios.TCSANOW, t) + flags = struct.unpack("I", __import__("fcntl").ioctl(fd, 0x5415, struct.pack("I", 0)))[0] + if self._profile.dtr_rts: + flags |= 0x004 | 0x002 + __import__("fcntl").ioctl(fd, 0x5416, struct.pack("I", flags)) + + def close(self) -> None: + self._stop.set() + if self._thread is not None: + self._thread.join(timeout=2.0) + self._thread = None + with self._lock: + if self._fd is not None: + try: + os.close(self._fd) + except OSError: + pass + self._fd = None + self._kiss_active = False + self.status = "closed" + self._decoder = KissDecoder() + + def attach_session(self, mycall: str) -> bool: + if self._fd is None: + return False + self._mycall = mycall.upper() + self._kiss_active = True + self.status = "ready" + return True + + def detach_session(self) -> None: + self._kiss_active = False + if self._fd is not None: + self.status = "open" + + def stabilize_session(self, mycall: str, *, force: bool = False) -> bool: + """Reopen KISS path after dead PTY / EIO (e.g. bcprd recycled outside max25d).""" + path = self._path + if ( + not force + and self._kiss_active + and self.status == "ready" + and self._fd is not None + and path + and os.path.exists(path) + ): + if not self._is_pty: + return True + # PTY symlink may have been retargeted while our fd still points at a + # deleted slave — force reopen when the live path inode differs. + try: + cur = os.stat(path) + fd_st = os.fstat(self._fd) + if cur.st_ino == fd_st.st_ino and cur.st_dev == fd_st.st_dev: + return True + except OSError: + pass + was_active = self._kiss_active or force + call = (mycall or self._mycall or "").upper() + self.close() + if not self.open(): + return False + if was_active and call: + return self.attach_session(call) + return self._fd is not None + + def transmit(self, src: str, dst: str, text: str, ax25_ui: bool) -> tuple[bool, str]: + if self._fd is None or not self._kiss_active: + return False, "KISS not ready" + if len(text.encode("utf-8")) > MAX_PAYLOAD: + return False, "payload too long" + info = text.encode("utf-8") + frame = ax25_build_ui(src, dst, info) + pkt = kiss_data_frame(0, frame) + with self._lock: + try: + os.write(self._fd, pkt) + # PTY: never tcdrain — if the master side dies, drain can hang forever. + if not self._is_pty: + termios.tcdrain(self._fd) + except OSError as exc: + self.status = "error-tx" + return False, f"tx failed: {exc}" + display = format_rx_line(src, dst, info, ax25_ui) + return True, display + + def _rx_loop(self) -> None: + while not self._stop.is_set(): + fd = self._fd + if fd is None: + break + try: + chunk = os.read(fd, 4096) + except BlockingIOError: + time.sleep(0.05) + continue + except OSError: + break + if not chunk: + time.sleep(0.05) + continue + for _port, payload in self._decoder.feed(chunk): + if not payload: + continue + parsed = ax25_parse_ui(payload) + if parsed is None: + continue + src, dst, info = parsed + line = format_rx_line(src, dst, info, ax25_ui=True) + self._on_rx(line) + + +class BayComKissBackend(KissRawBackend): + """BayCom kernel modem (SER12 / PAR96) via baycom-pr KISS PTY.""" + + backend_type = "baycom-kiss" + + def __init__( + self, + cfg: DeviceBackendConfig, + on_rx: RxFn, + log: Optional[LogFn] = None, + ) -> None: + modem = cfg.baycom_modem or "a" + kiss = cfg.kiss_link or f"/var/run/baycom-pr/kiss-{modem}" + if modem == "a" and not cfg.kiss_link: + default = "/var/run/baycom-pr/kiss" + if os.path.exists(default) or not os.path.exists(kiss): + kiss = default + profile = SerialProfile(baud=9600, line="8n1", dtr_rts=False) + super().__init__(cfg, kiss, profile, on_rx, log, is_pty=True) + + +class Max25BcprKissBackend(KissRawBackend): + """max25-bcpr userspace SER12 via KISS PTY (max25e0 / max25e0:bcN). + + Hardware is a TCM3105-class AFSK modem chip (bits↔AFSK + PTT) only — not a TNC. + """ + + backend_type = "max25-bcpr-kiss" + + def __init__( + self, + cfg: DeviceBackendConfig, + on_rx: RxFn, + log: Optional[LogFn] = None, + ) -> None: + tag = (cfg.max25_bcpr_device or cfg.bcpr_device or "bc0").strip() or "bc0" + kiss = cfg.kiss_link or f"/tmp/max25-bcpr/kiss-{tag}" + kiss = normalize_max25_bcpr_path(kiss) + profile = SerialProfile(baud=9600, line="8n1", dtr_rts=False) + super().__init__(cfg, kiss, profile, on_rx, log, is_pty=True) + + +class KissRawSerialBackend(KissRawBackend): + """USB/async BayCom KISS serial (kiss-serial backend).""" + + backend_type = "kiss-raw-serial" + + def __init__( + self, + cfg: DeviceBackendConfig, + root: str, + on_rx: RxFn, + log: Optional[LogFn] = None, + prefix: Optional[str] = None, + ) -> None: + overrides: dict[str, str] = {} + if cfg.serial_device: + overrides["device"] = cfg.serial_device + if cfg.serial_baud: + overrides["baud"] = str(cfg.serial_baud) + if cfg.serial_line: + overrides["line"] = cfg.serial_line + if cfg.serial_dtr_rts: + overrides["dtr_rts"] = cfg.serial_dtr_rts + prof = serial_profile_for_device(cfg.device_id, root, overrides, prefix=prefix) + path = cfg.serial_device or prof.device + super().__init__(cfg, path, prof, on_rx, log, is_pty=False) + + +class CrdopTcpBackend(DeviceBackend): + """MAX25-SoftModem (CRDOP) via TCP host interface (:8515 / :8516). + + Native M25/KISS host protocol (MAX25-SoftModem) only. + """ + + backend_type = "crdop-tcp" + + def __init__( + self, + cfg: DeviceBackendConfig, + on_rx: RxFn, + log: Optional[LogFn] = None, + ) -> None: + self.device_id = cfg.device_id + self._cfg = cfg + self._on_rx = on_rx + self._log = log or (lambda _m: None) + self._ctrl: Optional[socket.socket] = None + self._data: Optional[socket.socket] = None + self._thread: Optional[threading.Thread] = None + self._stop = threading.Event() + self._lock = threading.RLock() + self._mycall = "" + self._connected = False + self.status = "closed" + + def _line_term(self) -> str: + return "\n" + + def open(self) -> bool: + host = self._cfg.crdop_host + port = self._cfg.crdop_port + ctrl = None + data = None + try: + ctrl = socket.create_connection((host, port), timeout=5.0) + ctrl.settimeout(0.5) + data = socket.create_connection((host, port + 1), timeout=5.0) + data.settimeout(0.5) + except OSError as exc: + if ctrl is not None: + try: + ctrl.close() + except OSError: + pass + self.status = "error-connect" + self._log(f"{self.device_id}: CRDOP TCP connect failed ({host}:{port}): {exc}") + return False + self._ctrl = ctrl + self._data = data + self.status = "open" + self._stop.clear() + self._thread = threading.Thread( + target=self._rx_loop, + name=f"crdop-rx-{self.device_id}", + daemon=True, + ) + self._thread.start() + self._log(f"{self.device_id}: CRDOP TCP open {host}:{port}") + return True + + def close(self) -> None: + self._stop.set() + if self._thread is not None: + self._thread.join(timeout=2.0) + self._thread = None + for sock in (self._ctrl, self._data): + if sock is not None: + try: + sock.close() + except OSError: + pass + self._ctrl = None + self._data = None + self._connected = False + self.status = "closed" + + def _cmd(self, text: str) -> str: + if self._ctrl is None: + return "" + term = self._line_term() + payload = (text.rstrip(term) + term).encode("ascii", errors="replace") + with self._lock: + self._ctrl.sendall(payload) + return self._read_line_unlocked() + + def _read_line_unlocked(self) -> str: + if self._ctrl is None: + return "" + term = self._line_term() + term_b = term.encode("ascii") + buf = b"" + deadline = time.time() + 3.0 + while time.time() < deadline: + try: + chunk = self._ctrl.recv(4096) + except socket.timeout: + continue + except OSError: + break + if not chunk: + break + buf += chunk + while term_b in buf: + raw, buf = buf.split(term_b, 1) + line = raw.decode("ascii", errors="replace").strip() + if line: + return line + return "" + + def attach_session(self, mycall: str) -> bool: + if self._ctrl is None: + return False + self._mycall = mycall.upper() + cmds = [ + "INITIALIZE", + "PROTOCOLMODE KISS", + f"MYCALL {self._mycall}", + ] + if self._cfg.crdop_listen: + cmds.append("LISTEN TRUE") + for cmd in cmds: + reply = self._cmd(cmd) + self._log(f"{self.device_id}: {cmd} → {reply or '(no reply)'}") + self._connected = True + self.status = "ready" + return True + + def detach_session(self) -> None: + if self._ctrl is not None and self._connected: + self._cmd("ABORT") + self._connected = False + if self._ctrl is not None: + self.status = "open" + + def transmit(self, src: str, dst: str, text: str, ax25_ui: bool) -> tuple[bool, str]: + if self._ctrl is None or self._data is None or not self._connected: + return False, "CRDOP not ready" + payload = text.encode("utf-8") + if len(payload) > MAX_PAYLOAD: + return False, "payload too long" + with self._lock: + try: + body = ax25_build_ui(src, dst, payload) + if len(body) >= 2: + body = body[:-2] + self._data.sendall(body) + except OSError as exc: + self.status = "error-tx" + return False, f"tx failed: {exc}" + if ax25_ui: + display = f"[CRDOP AX25 UI {src}>{dst}] {text}" + else: + display = text + return True, display + + def _rx_loop(self) -> None: + while not self._stop.is_set(): + ctrl = self._ctrl + if ctrl is None: + break + try: + ready, _, _ = select.select([ctrl], [], [], 0.5) + if not ready: + continue + chunk = ctrl.recv(4096) + except (OSError, socket.timeout): + continue + if not chunk: + time.sleep(0.05) + continue + term = self._line_term() + for line in chunk.decode("ascii", errors="replace").split(term): + line = line.strip() + if not line: + continue + if line.startswith("STATUS"): + self._on_rx(f"[CRDOP RX {self.device_id}] {line}") + + +class AudioDummyBackend(DeviceBackend): + """Acoustic bench dummy — loopback, ALSA sniff, or M25 host TCP.""" + + backend_type = "audio-dummy" + + def __init__( + self, + cfg: DeviceBackendConfig, + on_rx: RxFn, + log: Optional[LogFn] = None, + ) -> None: + self.device_id = cfg.device_id + self._cfg = cfg + self._on_rx = on_rx + self._log = log or (lambda _m: None) + self._ctrl: Optional[socket.socket] = None + self._data: Optional[socket.socket] = None + self._thread: Optional[threading.Thread] = None + self._stop = threading.Event() + self._lock = threading.RLock() + self._mycall = "" + self._connected = False + self._engine = None + self.status = "closed" + + def _import_engine(self): + import sys + from pathlib import Path + + lib = Path(__file__).resolve().parents[1] / "crdop" / "lib" + if str(lib) not in sys.path: + sys.path.insert(0, str(lib)) + from acoustic_engine import AcousticEngine # noqa: WPS433 + from sound_proxy import SoundConfig # noqa: WPS433 + + sound = SoundConfig( + capture=self._cfg.audio_capture or "default", + playback=self._cfg.audio_playback or self._cfg.audio_capture or "default", + sample_rate=self._cfg.audio_sample_rate, + ) + return AcousticEngine(sample_rate=self._cfg.audio_sample_rate, sound=sound) + + def open(self) -> bool: + mode = (self._cfg.audio_mode or "loopback").lower() + if mode == "host": + host = "127.0.0.1" + port = self._cfg.audio_host_port + ctrl = None + data = None + try: + ctrl = socket.create_connection((host, port), timeout=3.0) + ctrl.settimeout(0.5) + data = socket.create_connection((host, port + 1), timeout=3.0) + data.settimeout(0.5) + except OSError as exc: + if ctrl is not None: + try: + ctrl.close() + except OSError: + pass + self.status = "error-connect" + self._log(f"{self.device_id}: audio-dummy host connect failed: {exc}") + return False + self._ctrl = ctrl + self._data = data + self._stop.clear() + self._thread = threading.Thread( + target=self._host_rx_loop, + name=f"audio-dummy-{self.device_id}", + daemon=True, + ) + self._thread.start() + self.status = "open" + self._log(f"{self.device_id}: audio-dummy host {host}:{port}") + return True + + try: + self._engine = self._import_engine() + except Exception as exc: + self.status = "error-engine" + self._log(f"{self.device_id}: audio engine load failed: {exc}") + return False + + if mode == "alsa" and self._cfg.audio_capture: + self._stop.clear() + self._thread = threading.Thread( + target=self._alsa_sniff_loop, + name=f"audio-sniff-{self.device_id}", + daemon=True, + ) + self._thread.start() + self.status = "open" + self._log(f"{self.device_id}: audio-dummy mode={mode}") + return True + + def close(self) -> None: + self._stop.set() + if self._thread is not None: + self._thread.join(timeout=2.0) + self._thread = None + for sock in (self._ctrl, self._data): + if sock is not None: + try: + sock.close() + except OSError: + pass + self._ctrl = None + self._data = None + self._connected = False + self.status = "closed" + + def _host_cmd(self, text: str) -> str: + if self._ctrl is None: + return "" + payload = (text.rstrip("\n") + "\n").encode("ascii", errors="replace") + with self._lock: + self._ctrl.sendall(payload) + buf = b"" + deadline = time.time() + 2.0 + while time.time() < deadline: + try: + chunk = self._ctrl.recv(4096) + except socket.timeout: + continue + if not chunk: + break + buf += chunk + if b"\n" in buf: + line, _ = buf.split(b"\n", 1) + return line.decode("ascii", errors="replace").strip() + return "" + + def attach_session(self, mycall: str) -> bool: + self._mycall = mycall.upper() + if self._ctrl is not None: + for cmd in ( + "INITIALIZE", + "PROTOCOLMODE KISS", + f"MYCALL {self._mycall}", + "LISTEN TRUE", + ): + reply = self._host_cmd(cmd) + self._log(f"{self.device_id}: {cmd} → {reply or '(no reply)'}") + self._connected = True + self.status = "ready" + return True + + def detach_session(self) -> None: + self._connected = False + if self._ctrl is not None: + self.status = "open" + else: + self.status = "closed" + + def transmit(self, src: str, dst: str, text: str, ax25_ui: bool) -> tuple[bool, str]: + if not self._connected: + return False, "audio-dummy not ready" + payload = text.encode("utf-8") + if len(payload) > MAX_PAYLOAD: + return False, "payload too long" + + if self._engine is not None: + pcm = self._engine.encode_ax25_ui(src, dst, text) + rep = self._engine.analyze_pcm(pcm) + for line in rep.decode_lines: + self._on_rx(f"[AUDIO RX {self.device_id}] {line}") + display = f"[AX25 UI {src}>{dst}] {text}" if ax25_ui else text + return True, display + + if self._data is None: + return False, "no data channel" + import sys + from pathlib import Path + + lib = Path(__file__).resolve().parents[1] / "crdop" / "lib" + if str(lib) not in sys.path: + sys.path.insert(0, str(lib)) + from ax25_codec import ax25_build_ui # noqa: WPS433 + + body = ax25_build_ui(src, dst, payload) + if len(body) >= 2: + body = body[:-2] + try: + with self._lock: + self._data.sendall(body) + except OSError as exc: + return False, f"tx failed: {exc}" + display = f"[AX25 UI {src}>{dst}] {text}" if ax25_ui else text + return True, display + + def _alsa_sniff_loop(self) -> None: + import sys + from pathlib import Path + + lib = Path(__file__).resolve().parents[1] / "crdop" / "lib" + if str(lib) not in sys.path: + sys.path.insert(0, str(lib)) + from sound_proxy import SoundProxy # noqa: WPS433 + + if self._engine is None: + return + proxy = SoundProxy(self._engine.sound) + + def on_pcm(chunk: bytes) -> None: + rep = self._engine.analyze_pcm(chunk) + for line in rep.decode_lines: + self._on_rx(f"[SNIFF {self.device_id}] {line}") + + try: + proxy.sniff_loop(chunk_symbols=40, on_pcm=on_pcm, stop=self._stop) + except Exception as exc: + self._log(f"{self.device_id}: sniff error: {exc}") + + def _host_rx_loop(self) -> None: + while not self._stop.is_set(): + if self._ctrl is None: + break + try: + ready, _, _ = select.select([self._ctrl], [], [], 0.5) + if not ready: + continue + chunk = self._ctrl.recv(4096) + except (OSError, socket.timeout): + continue + if not chunk: + time.sleep(0.05) + continue + for line in chunk.decode("ascii", errors="replace").split("\n"): + line = line.strip() + if line.startswith("STATUS"): + self._on_rx(f"[AUDIO RX {self.device_id}] {line}") + + +def parse_device_spec(device_id: str, spec: str, cp, cfg_defaults: dict) -> DeviceBackendConfig: + """Build backend config from [devices] value and optional [device.<id>].""" + dev = DeviceBackendConfig(device_id=device_id) + dev.hardware = registry_hardware(device_id, cfg_defaults.get("hardware", "tncs")) + dev.backend_type = registry_backend(device_id) + + section = f"device.{device_id}" + sec_opts: dict[str, str] = {} + if cp.has_section(section): + sec_opts = {k: cp.get(section, k) for k in cp.options(section)} + + if sec_opts.get("hardware"): + dev.hardware = sec_opts["hardware"] + if sec_opts.get("backend"): + dev.backend_type = sec_opts["backend"] + + spec = (spec or "").strip() + dev.device_spec = spec + + if spec.startswith("baycom:"): + dev.backend_type = "baycom-kiss" + dev.baycom_modem = spec.split(":", 1)[1].strip() or "a" + dev.hardware = "modems" + elif spec.startswith("max25-bcpr:") or spec.startswith("bcpr:"): + # Userspace SER12 — product face max25-bcpr; device id remains max25e0 + dev.backend_type = "max25-bcpr-kiss" + tag = spec.split(":", 1)[1].strip() or "bc0" + dev.max25_bcpr_device = tag + dev.bcpr_device = tag # legacy alias + dev.hardware = "modems" + if not sec_opts.get("kiss_link"): + dev.kiss_link = f"/tmp/max25-bcpr/kiss-{tag}" + elif spec.startswith("crdop:"): + dev.backend_type = "crdop-tcp" + dev.crdop_profile = spec.split(":", 1)[1].strip() or "default" + dev.hardware = "soft-modems" + elif spec.startswith("audio:"): + dev.backend_type = "audio-dummy" + dev.audio_mode = spec.split(":", 1)[1].strip() or "loopback" + dev.hardware = "acoustic-bench" + elif spec.startswith("/") or spec.startswith("dev:"): + if dev.backend_type in ("baycom-kiss",): + dev.kiss_link = spec + else: + dev.serial_device = spec.removeprefix("dev:") + if dev.backend_type == "crdop-tcp": + dev.backend_type = "kiss-serial" + elif spec: + dev.serial_device = spec + + if sec_opts.get("kiss_link"): + dev.kiss_link = sec_opts["kiss_link"] + if dev.backend_type in ("max25-bcpr-kiss", "bcpr-kiss") and dev.kiss_link: + dev.kiss_link = normalize_max25_bcpr_path(dev.kiss_link) + if sec_opts.get("modem"): + dev.baycom_modem = sec_opts["modem"] + if sec_opts.get("baycom_ini"): + dev.baycom_ini = sec_opts["baycom_ini"] + if sec_opts.get("max25_bcpr_ini") or sec_opts.get("bcpr_ini"): + ini_path = sec_opts.get("max25_bcpr_ini") or sec_opts.get("bcpr_ini") or "" + dev.max25_bcpr_ini = ini_path + dev.bcpr_ini = ini_path + if sec_opts.get("max25_bcpr_device") or sec_opts.get("bcpr_device"): + tag = sec_opts.get("max25_bcpr_device") or sec_opts.get("bcpr_device") or "" + dev.max25_bcpr_device = tag + dev.bcpr_device = tag + if sec_opts.get("ipv4"): + dev.ipv4 = sec_opts["ipv4"].strip() + if sec_opts.get("ipv6"): + dev.ipv6 = sec_opts["ipv6"].strip() + if sec_opts.get("host"): + dev.crdop_host = sec_opts["host"] + if sec_opts.get("port"): + dev.crdop_port = _spec_int(sec_opts["port"], dev.crdop_port) + if sec_opts.get("listen"): + dev.crdop_listen = sec_opts["listen"].lower() in ("1", "yes", "true", "on") + if sec_opts.get("mode"): + dev.audio_mode = sec_opts["mode"] + if sec_opts.get("capture"): + dev.audio_capture = sec_opts["capture"] + if sec_opts.get("playback"): + dev.audio_playback = sec_opts["playback"] + if sec_opts.get("sample_rate"): + dev.audio_sample_rate = _spec_int(sec_opts["sample_rate"], dev.audio_sample_rate) + if sec_opts.get("host_port"): + dev.audio_host_port = _spec_int(sec_opts["host_port"], dev.audio_host_port) + + serial_sec = f"serial.{device_id}" + if cp.has_section(serial_sec): + if cp.has_option(serial_sec, "device"): + dev.serial_device = cp.get(serial_sec, "device") + if cp.has_option(serial_sec, "baud"): + dev.serial_baud = cp.getint(serial_sec, "baud") + if cp.has_option(serial_sec, "line"): + dev.serial_line = cp.get(serial_sec, "line") + if cp.has_option(serial_sec, "dtr_rts"): + dev.serial_dtr_rts = cp.get(serial_sec, "dtr_rts") + if cp.has_option(serial_sec, "kiss_entry"): + dev.serial_kiss_entry = cp.get(serial_sec, "kiss_entry") + + # max25e0 family: hardcoded host addresses (forks inherit from root max25e0) + if device_id == "max25e0" or device_id.startswith("max25e0:"): + root_opts: dict[str, str] = {} + if device_id != "max25e0" and cp.has_section("device.max25e0"): + root_opts = {k: cp.get("device.max25e0", k) for k in cp.options("device.max25e0")} + if not dev.ipv4: + dev.ipv4 = (root_opts.get("ipv4") or "").strip() or MAX25E0_DEFAULT_IPV4 + if not dev.ipv6: + dev.ipv6 = (root_opts.get("ipv6") or "").strip() or MAX25E0_DEFAULT_IPV6 + + return dev + + +def create_backend( + dev_cfg: DeviceBackendConfig, + root: str, + on_rx: RxFn, + log: Optional[LogFn] = None, + prefix: Optional[str] = None, + on_invalid: Optional[InvalidFn] = None, +) -> DeviceBackend: + kind = dev_cfg.backend_type or registry_backend(dev_cfg.device_id) + if kind == "kiss-serial": + return KissSerialBackend(dev_cfg, root, on_rx, log, prefix=prefix, on_invalid=on_invalid) + if kind == "baycom-kiss": + return BayComKissBackend(dev_cfg, on_rx, log) + if kind in ("max25-bcpr-kiss", "bcpr-kiss"): + return Max25BcprKissBackend(dev_cfg, on_rx, log) + if kind == "kiss-raw-serial": + return KissRawSerialBackend(dev_cfg, root, on_rx, log, prefix=prefix) + if kind == "crdop-tcp": + return CrdopTcpBackend(dev_cfg, on_rx, log) + if kind == "audio-dummy": + return AudioDummyBackend(dev_cfg, on_rx, log) + return KissSerialBackend(dev_cfg, root, on_rx, log, prefix=prefix) + + +def backend_needs_stack(kind: str) -> bool: + return kind in ( + "kiss-serial", + "baycom-kiss", + "max25-bcpr-kiss", "bcpr-kiss", + "kiss-raw-serial", + "crdop-tcp", + "audio-dummy", + ) + + +def backend_serial_label(backend: Optional[DeviceBackend]) -> str: + if backend is None: + return "n/a" + return backend.status + + +# Legacy alias (tests / transitional) +BcprKissBackend = Max25BcprKissBackend diff --git a/stacks/daemon/kiss_bridge.py b/stacks/daemon/kiss_bridge.py new file mode 100644 index 0000000..b433d0e --- /dev/null +++ b/stacks/daemon/kiss_bridge.py @@ -0,0 +1,574 @@ +""" +KISS serial bridge for max25d — AX.25 UI over TNC2C / PK-TNC2. + +PTT: TNC firmware keys on KISS DATA (requires MYCALL); kernel BayCom (baycom_ser_fdx) +keys RTS in the driver when the KISS bridge accepts a DATA frame — no max25d PTT command. +""" +from __future__ import annotations + +import fcntl +import os +import struct +import termios +import threading +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Optional + +from ax25_codec import ( # noqa: E402 + ax25_build_ui, + ax25_crc, + ax25_crc_valid, + ax25_parse_ui, + format_callsign, + parse_callsign, + validate_callsign, +) +from tx_pace import tx_pace_before_send # noqa: E402 + +FEND = 0xC0 +FESC = 0xDB +TFEND = 0xDC +TFESC = 0xDD + +# Native KISS return (TheFirmware TF 2.7) — firmware reset to banner, not TAPR kiss off +KISS_RETURN_FRAME = b"\xc0\xff\xc0" + +KISS_CMD_DATA = 0x00 +MAX_FRAME = 1024 +MAX_PAYLOAD = 256 + + +@dataclass +class SerialProfile: + device: str = "/dev/ttyS4" + baud: int = 19200 + line: str = "8n1" + dtr_rts: bool = True + kiss_entry: str = "kiss_on" # kiss_on | auto + + +class KissDecoder: + def __init__(self) -> None: + self._buf = bytearray() + self._in_frame = False + self._escape = False + + def feed(self, data: bytes) -> list[tuple[int, bytes]]: + frames: list[tuple[int, bytes]] = [] + for byte in data: + if byte == FEND: + if self._in_frame and self._buf: + parsed = self._deliver() + if parsed is not None: + frames.append(parsed) + self._in_frame = True + self._escape = False + self._buf.clear() + continue + if not self._in_frame: + continue + if self._escape: + if byte == TFEND: + byte = FEND + elif byte == TFESC: + byte = FESC + self._escape = False + elif byte == FESC: + self._escape = True + continue + if len(self._buf) >= MAX_FRAME: + self._buf.clear() + self._in_frame = False + continue + self._buf.append(byte) + return frames + + def _deliver(self) -> Optional[tuple[int, bytes]]: + if len(self._buf) < 1: + return None + cmd_byte = self._buf[0] + if (cmd_byte & 0x0F) != KISS_CMD_DATA: + return None + port = (cmd_byte >> 4) & 0x0F + payload = bytes(self._buf[1:]) + return port, payload + + +def kiss_escape(data: bytes) -> bytes: + out = bytearray() + for b in data: + if b == FEND: + out.extend((FESC, TFEND)) + elif b == FESC: + out.extend((FESC, TFESC)) + else: + out.append(b) + return bytes(out) + + +def kiss_encode(port: int, cmd: int, payload: bytes) -> bytes: + cmd_byte = ((port & 0x0F) << 4) | (cmd & 0x0F) + return b"\xC0" + kiss_escape(bytes([cmd_byte]) + payload) + b"\xC0" + + +def kiss_data_frame(port: int, ax25_frame: bytes) -> bytes: + """Build KISS DATA; strip FCS when CRC validates (ax25ipd / KISS convention).""" + if ax25_crc_valid(ax25_frame): + ax25_frame = ax25_frame[:-2] + return kiss_encode(port, KISS_CMD_DATA, ax25_frame) + + +def format_rx_line(src: str, dst: str, payload: bytes, ax25_ui: bool) -> str: + try: + text = payload.decode("utf-8") + except UnicodeDecodeError: + text = payload.decode("utf-8", errors="replace") + if ax25_ui: + return f"[AX25 UI {src}>{dst}] {text}" + return text + + +def load_env_file(path: str) -> dict[str, str]: + out: dict[str, str] = {} + if not path or not os.path.isfile(path): + return out + with open(path, encoding="utf-8") as fh: + for line in fh: + line = line.strip() + if not line or line.startswith("#"): + continue + if "=" in line: + key, val = line.split("=", 1) + out[key.strip()] = val.strip() + return out + + +def _load_serial_env(device_id: str, root: str, prefix: Optional[str] = None) -> dict[str, str]: + from paths import serial_env_candidates + + tree = Path(root) + pref = Path(prefix) if prefix else None + for path in serial_env_candidates(device_id, tree, pref): + env = load_env_file(str(path)) + if env: + return env + return {} + + +def serial_profile_for_device( + device_id: str, + root: str, + ini: dict[str, str], + prefix: Optional[str] = None, +) -> SerialProfile: + prof = SerialProfile() + if ini.get("device"): + prof.device = ini["device"] + if ini.get("baud"): + prof.baud = int(ini["baud"]) + if ini.get("line"): + prof.line = ini["line"].lower() + if ini.get("dtr_rts"): + prof.dtr_rts = ini["dtr_rts"].lower() in ("1", "yes", "true", "on") + if ini.get("kiss_entry"): + prof.kiss_entry = ini["kiss_entry"].lower() + + env = _load_serial_env(device_id, root, prefix) + + if device_id == "tnc2c": + prof.device = ini.get("device") or env.get("TNC2C_DEV", prof.device) + prof.baud = int(ini.get("baud") or env.get("TNC2C_BAUD", prof.baud)) + prof.line = (ini.get("line") or env.get("TNC2C_LINE", prof.line)).lower() + if "dtr_rts" not in ini: + prof.dtr_rts = True + if "kiss_entry" not in ini: + prof.kiss_entry = "kiss_on" + elif device_id == "pktnc2": + prof.device = ini.get("device") or env.get("PKTNC2_DEV") or env.get("TNC_DEV", prof.device) + prof.baud = int(ini.get("baud") or env.get("PKTNC2_BAUD") or env.get("TNC_BAUD", "9600")) + prof.line = (ini.get("line") or env.get("PKTNC2_LINE") or env.get("TNC_LINE", "8n1")).lower() + if "dtr_rts" not in ini: + prof.dtr_rts = False + if "kiss_entry" not in ini: + prof.kiss_entry = "auto" + elif device_id in ("pccom-kiss", "baycom-kiss"): + prof.device = ini.get("device") or env.get("PCCOM_KISS_DEV", prof.device or "/dev/ttyUSB0") + prof.baud = int(ini.get("baud") or env.get("PCCOM_KISS_BAUD", "9600")) + prof.line = (ini.get("line") or env.get("PCCOM_KISS_LINE", "8n1")).lower() + dtr = ini.get("dtr_rts") or env.get("PCCOM_KISS_DTR_RTS", "no") + prof.dtr_rts = str(dtr).lower() in ("1", "yes", "true", "on") + if "kiss_entry" not in ini: + prof.kiss_entry = "none" + elif device_id == "tmodem": + default_dev = "/dev/cuaU0" if sys.platform.startswith("freebsd") else "/dev/ttyACM0" + prof.device = ini.get("device") or env.get("TMODEM_DEV", prof.device or default_dev) + prof.baud = int(ini.get("baud") or env.get("TMODEM_BAUD", "115200")) + prof.line = (ini.get("line") or env.get("TMODEM_LINE", "8n1")).lower() + dtr = ini.get("dtr_rts") or env.get("TMODEM_DTR_RTS", "no") + prof.dtr_rts = str(dtr).lower() in ("1", "yes", "true", "on") + if "kiss_entry" not in ini: + prof.kiss_entry = "none" + return prof + + +def _parse_line(line: str) -> tuple[int, int]: + line = line.lower() + if line == "7e1": + return termios.CS7, termios.PARENB + return termios.CS8, 0 + + +def _parse_baud(baud: int) -> int: + table = { + 1200: termios.B1200, + 2400: termios.B2400, + 4800: termios.B4800, + 9600: termios.B9600, + 19200: termios.B19200, + } + if baud not in table: + raise ValueError(f"unsupported baud: {baud}") + return table[baud] + + +class KissBridge: + """Thread-safe KISS bridge on a serial TNC port.""" + + def __init__( + self, + profile: SerialProfile, + on_rx: Callable[[str], None], + log: Optional[Callable[[str], None]] = None, + *, + tree_root: str = "", + install_prefix: Optional[str] = None, + on_invalid: Optional[Callable[[], None]] = None, + ) -> None: + self.profile = profile + self._on_rx = on_rx + self._on_invalid = on_invalid + self._log = log or (lambda _m: None) + self._tree_root = tree_root + self._install_prefix = install_prefix + self._fd: Optional[int] = None + self._thread: Optional[threading.Thread] = None + self._stop = threading.Event() + self._lock = threading.Lock() + self._kiss_active = False + self._mycall = "" + self.status = "closed" + self._decoder = KissDecoder() + + def open(self) -> bool: + dev = self.profile.device + if not os.access(dev, os.R_OK | os.W_OK): + self.status = "error-no-device" + self._log(f"serial: no access to {dev}") + return False + try: + speed = _parse_baud(self.profile.baud) + databits, parity = _parse_line(self.profile.line) + except ValueError as exc: + self.status = "error-config" + self._log(f"serial: {exc}") + return False + try: + fd = os.open(dev, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK) + t = termios.tcgetattr(fd) + t[0] = t[1] = 0 + t[2] = termios.CLOCAL | termios.CREAD | databits | parity + t[3] = t[4] = t[5] = speed + t[6][termios.VMIN] = 0 + t[6][termios.VTIME] = 5 + termios.tcsetattr(fd, termios.TCSANOW, t) + termios.tcflush(fd, termios.TCIOFLUSH) + flags = struct.unpack("I", fcntl.ioctl(fd, 0x5415, struct.pack("I", 0)))[0] + if self.profile.dtr_rts: + flags |= 0x004 | 0x002 + fcntl.ioctl(fd, 0x5416, struct.pack("I", flags)) + except OSError as exc: + self.status = "error-open" + self._log(f"serial open failed: {exc}") + return False + self._fd = fd + self._log(f"serial open {dev} {self.profile.baud} {self.profile.line.upper()}") + if self.profile.dtr_rts: + time.sleep(2.0) + self._log("serial: DTR settle (2s)") + self.status = "open" + return True + + def close(self) -> None: + self._stop_rx_thread() + with self._lock: + if self._fd is not None: + if self._kiss_active: + self._write_unlocked(KISS_RETURN_FRAME) + try: + os.close(self._fd) + except OSError: + pass + self._fd = None + self._kiss_active = False + self.status = "closed" + self._decoder = KissDecoder() + + def _stop_rx_thread(self) -> None: + """Stop KISS RX thread so recovery owns the serial FD exclusively.""" + self._stop.set() + if self._thread is not None: + self._thread.join(timeout=2.0) + self._thread = None + self._stop.clear() + + def _start_rx_thread(self) -> None: + """Start KISS RX after terminal recovery and KISS entry succeed.""" + if self._fd is None or self._thread is not None or not self._kiss_active: + return + self._stop.clear() + self._thread = threading.Thread(target=self._rx_loop, name="kiss-rx", daemon=True) + self._thread.start() + + def attach_session(self, mycall: str) -> bool: + if self._fd is None: + return False + self._mycall = mycall.upper() + self._stop_rx_thread() + with self._lock: + ok = self._stabilize_unlocked(self._mycall, force_ladder=False) + if ok: + self._start_rx_thread() + return ok + + def stabilize_session(self, mycall: str, *, force: bool = False) -> bool: + """Probe terminal/KISS health and repair without closing the port (keeps DTR).""" + if self._fd is None: + self.status = "error-open" + return False + if not force and self._kiss_active and self.status == "ready": + return True + self._mycall = mycall.upper() + self._stop_rx_thread() + with self._lock: + ok = self._stabilize_unlocked(self._mycall, force_ladder=force) + if ok: + self._start_rx_thread() + return ok + + def _load_recovery_mod(self): + import importlib.util + + from paths import tnc_serial_recovery_path + + path = None + if self._tree_root: + prefix = Path(self._install_prefix) if self._install_prefix else None + path = tnc_serial_recovery_path(Path(self._tree_root), prefix) + if path is None: + path = Path(__file__).resolve().parents[1] / "tncs" / "tnc_serial_recovery.py" + if not path.is_file(): + return None + spec = importlib.util.spec_from_file_location("tnc_serial_recovery", path) + if spec is None or spec.loader is None: + return None + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + def _recovery_io(self) -> tuple[Callable[[bytes], None], Callable[[float], bytes]]: + def wf(data: bytes) -> None: + self._write_unlocked(data) + + def rf(seconds: float) -> bytes: + return self._drain_unlocked(seconds) + + return wf, rf + + def _leave_kiss_unlocked(self) -> None: + if not self._kiss_active: + return + self._write_unlocked(KISS_RETURN_FRAME) + time.sleep(0.3) + self._drain_unlocked(0.2) + self._kiss_active = False + + def _enter_kiss_session_unlocked(self) -> bool: + if not self._set_mycall_unlocked(self._mycall): + self._log("serial: MYCALL may have failed") + if not self._enter_kiss_unlocked(): + self.status = "error-kiss" + self._kiss_active = False + return False + self._kiss_active = True + self.status = "ready" + return True + + def _stabilize_unlocked(self, mycall: str, *, force_ladder: bool) -> bool: + """Host probe, optional recovery ladder, MYCALL + KISS — port stays open.""" + self._mycall = mycall.upper() + try: + mod = self._load_recovery_mod() + wf, rf = self._recovery_io() + self._leave_kiss_unlocked() + + if mod is None: + self._log("serial: tnc_serial_recovery.py not found") + self.status = "error-config" + self._kiss_active = False + return False + + ok, probe_data, only_echo = mod.probe_info(wf, rf, pause=0.25) + if not ok or only_echo or force_ladder: + self._log( + f"serial: initial probe — {mod.format_rx_brief(probe_data)}, " + f"echo_only={only_echo}, banner={mod.has_banner(probe_data)}" + ) + if ok and not only_echo and not force_ladder: + return self._enter_kiss_session_unlocked() + if only_echo or not ok or force_ladder: + label = "auto-repair" if force_ladder else "recovery ladder" + self._log(f"serial: {label}") + ok, _ = mod.recover_terminal(wf, rf, log=self._log) + if not ok: + self.status = "error-host" + self._kiss_active = False + return False + return self._enter_kiss_session_unlocked() + self.status = "error-host" + self._kiss_active = False + return False + except OSError as exc: + self.status = "error-io" + self._kiss_active = False + self._log(f"serial: stabilize I/O error ({exc})") + return False + + def _recover_terminal_unlocked(self) -> bool: + """Legacy hook — full ladder when probe fails.""" + try: + mod = self._load_recovery_mod() + if mod is None: + return True + wf, rf = self._recovery_io() + ok, _, only_echo = mod.probe_info(wf, rf) + if ok and not only_echo: + self._log("serial: terminal mode OK") + return True + self._log("serial: software recovery ladder") + ok, _ = mod.recover_terminal(wf, rf, log=self._log) + if ok: + self._log("serial: recovery OK") + return ok + except Exception as exc: + self._log(f"serial: recovery skipped ({exc})") + return True + + def detach_session(self) -> None: + self._stop_rx_thread() + with self._lock: + if self._fd is not None and self._kiss_active: + self._write_unlocked(KISS_RETURN_FRAME) + time.sleep(0.2) + self._kiss_active = False + if self._fd is not None: + self.status = "open" + + def transmit(self, src: str, dst: str, text: str, ax25_ui: bool) -> tuple[bool, str]: + if self._fd is None or not self._kiss_active: + return False, "serial not ready" + if len(text.encode("utf-8")) > MAX_PAYLOAD: + return False, "payload too long" + try: + validate_callsign(src) + validate_callsign(dst) + except ValueError as exc: + return False, str(exc) + info = text.encode("utf-8") + try: + frame = ax25_build_ui(src, dst, info) + except ValueError as exc: + return False, str(exc) + pkt = kiss_data_frame(0, frame) + with self._lock: + try: + tx_pace_before_send() + self._write_unlocked(pkt) + termios.tcdrain(self._fd) + except OSError as exc: + self.status = "error-tx" + return False, f"tx failed: {exc}" + display = format_rx_line(src, dst, info, ax25_ui) + return True, display + + def _write_unlocked(self, data: bytes) -> None: + if self._fd is None: + return + os.write(self._fd, data) + + def _drain_unlocked(self, seconds: float) -> bytes: + if self._fd is None: + return b"" + end = time.time() + seconds + chunks: list[bytes] = [] + while time.time() < end: + try: + chunk = os.read(self._fd, 4096) + if chunk: + chunks.append(chunk) + except BlockingIOError: + time.sleep(0.02) + return b"".join(chunks) + + def _set_mycall_unlocked(self, call: str) -> bool: + mod = self._load_recovery_mod() + if mod is not None and hasattr(mod, "tf_mycall_frame"): + cmd = mod.tf_mycall_frame(call) + else: + cmd = f"\x1bI {call.upper()}\r".encode("ascii", errors="replace") + self._write_unlocked(cmd) + time.sleep(0.4) + reply = self._drain_unlocked(0.6) + return b"?" not in reply[:32] + + def _enter_kiss_unlocked(self) -> bool: + entry = self.profile.kiss_entry + if entry == "tapr": + self._write_unlocked(b"kiss on\r") + time.sleep(0.5) + self._drain_unlocked(0.3) + return True + self._write_unlocked(b"\x1b@K") + time.sleep(0.5) + self._drain_unlocked(0.3) + return True + + def _rx_loop(self) -> None: + while not self._stop.is_set(): + fd = self._fd + if fd is None: + break + try: + chunk = os.read(fd, 4096) + except BlockingIOError: + time.sleep(0.05) + continue + except OSError: + self._log("serial: rx I/O error — watch will repair") + self.status = "error-io" + break + if not chunk: + time.sleep(0.05) + continue + for _port, payload in self._decoder.feed(chunk): + if not payload: + continue + parsed = ax25_parse_ui(payload) + if parsed is None: + if self._on_invalid is not None and len(payload) >= 16: + self._on_invalid() + continue + src, dst, info = parsed + line = format_rx_line(src, dst, info, ax25_ui=True) + self._on_rx(line) diff --git a/stacks/daemon/max25_platform.py b/stacks/daemon/max25_platform.py new file mode 100644 index 0000000..0ca1acb --- /dev/null +++ b/stacks/daemon/max25_platform.py @@ -0,0 +1,83 @@ +"""MAX25 host platform — Linux full stack, FreeBSD server + CRDOP/OSS.""" +from __future__ import annotations + +import os +import sys + + +def system_name() -> str: + return sys.platform + + +def is_linux() -> bool: + return sys.platform == "linux" + + +def is_freebsd() -> bool: + return sys.platform.startswith("freebsd") + + +def is_bsd() -> bool: + return sys.platform.startswith(("freebsd", "openbsd", "netbsd", "darwin")) + + +def max25d_supported() -> bool: + """Daemon may run (full stack on Linux, server+CRDOP on FreeBSD).""" + return is_linux() or is_freebsd() + + +def default_unix_socket() -> str: + if is_linux(): + return "/run/max25/modem.sock" + if is_freebsd(): + return "/var/run/max25/modem.sock" + return "/tmp/max25/modem.sock" + + +def default_bans_file() -> str: + if is_linux(): + return "/var/lib/max25/bans.txt" + return "/var/db/max25/bans.txt" + + +def supported_device_ids() -> frozenset[str]: + if is_linux(): + return frozenset( + { + "tnc2c", + "pktnc2", + "tmodem", + "baycom-kiss", + "pccom-kiss", + # BayCom/based SER12 (max25-bcpr -> device max25e0; in scope, default ON in Lite) + "max25e0", + "max25e0:bc0", + "max25e0:bc1", + "soft-crdop", + "audio-dummy", + } + ) + if is_freebsd(): + # USB KISS (tmodem/c1224) + CRDOP/OSS + max25-bcpr (SER12 via sysarch I386_SET_IOPERM) + return frozenset({"tmodem", "soft-crdop", "audio-dummy", "max25e0", "max25e0:bc0", "max25e0:bc1"}) + return frozenset({"soft-crdop"}) + + +def crdop_audio_backend() -> str: + """Host audio API for CRDOP sound-proxy.""" + env = os.environ.get("MAX25_AUDIO_BACKEND", "").strip().lower() + if env in ("alsa", "oss"): + return env + if is_linux(): + return "alsa" + if is_freebsd(): + return "oss" + return "alsa" + + +def platform_label() -> str: + if is_linux(): + return "Linux/KLinux" + if is_freebsd(): + return "FreeBSD" + return sys.platform diff --git a/stacks/daemon/max25d b/stacks/daemon/max25d new file mode 100755 index 0000000..d6c075b --- /dev/null +++ b/stacks/daemon/max25d @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# max25d launcher — ps(1) shows this path, not "python3 …/max25d". +set -euo pipefail +ROOT="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +case "${1:-}" in + session) + shift + for candidate in \ + "${ROOT}/../../scripts/max25d-session.sh" \ + "${ROOT}/../scripts/max25d-session.sh" \ + "$(command -v max25d-session 2>/dev/null || true)" + do + if [[ -n "${candidate}" && -x "${candidate}" ]]; then + exec "${candidate}" "$@" + fi + done + echo "max25d: max25d-session helper not found" >&2 + exit 1 + ;; +esac +exec -a "$0" python3 "${ROOT}/max25d.py" "$@" diff --git a/stacks/daemon/max25d.py b/stacks/daemon/max25d.py new file mode 100755 index 0000000..c6c03f3 --- /dev/null +++ b/stacks/daemon/max25d.py @@ -0,0 +1,2213 @@ +#!/usr/bin/env python3 +""" +max25d — MAX25-Stack-Lite daemon (localhost). + +Linux/*BSD/macOS: max25-terminal + CRDOP + BayCom/based on the same machine. +""" +from __future__ import annotations + +import argparse +import configparser +import os +import re +import select +import signal +import socket +import subprocess +import sys +import tempfile +import threading +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional, Set + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from device_backends import ( # noqa: E402 + DeviceBackend, + DeviceBackendConfig, + backend_serial_label, + baycom_ctl_device_id, + create_backend, + parse_device_spec, + registry_backend, + registry_tested, +) +from banlist import BanList, extract_ax25_source # noqa: E402 +from reporting_quality import DataQualityTracker, RxOutcome, classify_rx_line # noqa: E402 +from daemon_log import LOGGER, emit_startup_banner, emit_startup_complete # noqa: E402 +from kiss_bridge import KissBridge # noqa: E402 — tests patch this symbol +from paths import ( # noqa: E402 + ctl_path, + MAX25_BCPR_KISS_DEFAULT, + normalize_max25_bcpr_path, + resolve_baycom_ini, + resolve_layout, +) +from max25_platform import ( # noqa: E402 + default_bans_file, + default_unix_socket, + max25d_supported, + platform_label, + supported_device_ids, +) +from privilege_drop import RunAsConfig, drop_privileges_or_exit, parse_run_as # noqa: E402 +from modular_tcp_server import ModularTcpMainService, ModularTcpConfig, load_modular_tcp # noqa: E402 + +_EXE = Path(__file__).resolve() +TREE, PREFIX = resolve_layout(_EXE) +ROOT = TREE # dev checkout root or MAX25_ROOT / install prefix + +DEFAULT_TCP_PORT = 7325 +DEFAULT_UNIX = default_unix_socket() +M25_MAX_LINE_BUF = 65536 +CALLSIGN_RE = re.compile(r"^[A-Z0-9]{1,6}(-(1[0-5]|[0-9]))?$") +RESERVED_DEVICE_KEYS = frozenset({"default", "enabled"}) + + +def _device_is_baycom(dev: DeviceBackendConfig) -> bool: + if dev.device_id.startswith("baycom"): + return True + spec = (dev.device_spec or "").strip() + if spec.startswith("baycom:"): + return True + return dev.backend_type == "baycom-kiss" and dev.hardware == "modems" + + +def _device_is_pccom(dev: DeviceBackendConfig) -> bool: + if "pccom" in dev.device_id.lower(): + return True + ini = (dev.baycom_ini or "").lower() + return "pccom" in ini + + + +def _device_is_max25_bcpr(dev: DeviceBackendConfig) -> bool: + # Product device id = max25e0 (+ forks max25e0:bcN); backend = max25-bcpr + if dev.device_id == "max25e0" or dev.device_id.startswith("max25e0:"): + return True + spec = (dev.device_spec or "").strip() + if spec.startswith("max25-bcpr:") or spec.startswith("bcpr:"): + return True + return dev.backend_type in ("max25-bcpr-kiss", "bcpr-kiss") + + +def _device_is_tmodem(dev: DeviceBackendConfig) -> bool: + return dev.device_id == "tmodem" + + +def _device_allowed_by_features(dev: DeviceBackendConfig, cfg: DaemonConfig) -> bool: + if _device_is_baycom(dev) and not cfg.feature_baycom: + LOGGER.warn( + f"device {dev.device_id}: BayCom disabled — set [features] baycom=yes", + area="config", + ) + return False + if _device_is_pccom(dev) and not cfg.feature_pccom: + LOGGER.warn( + f"device {dev.device_id}: PC-COM disabled — set [features] pccom=yes", + area="config", + ) + return False + if _device_is_max25_bcpr(dev) and not cfg.feature_max25_bcpr: + LOGGER.warn( + f"device {dev.device_id}: max25-bcpr disabled — set [features] max25_bcpr=yes", + area="config", + ) + return False + if _device_is_tmodem(dev) and not cfg.feature_tmodem: + LOGGER.warn( + f"device {dev.device_id}: T-Modem disabled — set [features] tmodem=yes", + area="config", + ) + return False + return True + + +@dataclass +class TotConfig: + """Software TOT for BayCom/based (max25-bcpr) — host policy, not radio TOT.""" + + enabled: bool = True + max_key_sec: int = 25 + min_gap_sec: float = 1.5 + max_consecutive: int = 3 + max_bursts: int = 8 + recover_sec: int = 300 + + +@dataclass +class DaemonConfig: + mode: str = "standalone" + hardware: str = "tncs" + device: str = "tnc2c" + default_device: str = "" + devices: list[DeviceBackendConfig] = field(default_factory=list) + tcp_host: str = "127.0.0.1" + tcp_port: int = DEFAULT_TCP_PORT + unix_socket: str = DEFAULT_UNIX + tcp_password: str = "" + callerid: str = "CB-0" + callid: str = "QST" + ax25_ui: bool = True + auto_start: bool = True + serial_enabled: bool = True + serial_watch: bool = True + serial_watch_interval: int = 60 + serial_repair_cooldown: int = 20 + serial_watch_startup_grace: int = 45 + stack_recover_only: bool = True + stack_retry_interval: int = 120 + serial_bootwait_escalate: bool = True + serial_bootwait_escalate_after: int = 3 + serial_bootwait_escalate_cooldown: int = 300 + bans_file: str = field(default_factory=default_bans_file) + config_path: str = "" + feature_baycom: bool = True + feature_pccom: bool = True + feature_max25_bcpr: bool = True + feature_tmodem: bool = False + hybbx_release_attach: bool = False + run_user: str = "" + run_group: str = "" + run_uid: Optional[int] = None + run_gid: Optional[int] = None + report_error_transmissions: bool = True + report_voice_transmissions: bool = True + report_data_passes: int = 3 + report_data_quality_min: int = 50 + report_data_pass_seconds: int = 20 + # Legacy single-device [serial] overrides (used when [devices] absent). + serial_device: str = "" + serial_baud: int = 0 + serial_line: str = "" + serial_dtr_rts: str = "" + serial_kiss_entry: str = "" + modular_tcp: ModularTcpConfig = field(default_factory=ModularTcpConfig) + tot: TotConfig = field(default_factory=TotConfig) + + +@dataclass +class DeviceRuntime: + cfg: DeviceBackendConfig + backend: Optional[DeviceBackend] = None + stack_proc: Optional[subprocess.Popen] = None + stack_status: str = "stopped" + link_status: str = "n/a" + last_watch: float = 0.0 + last_repair: float = 0.0 + last_stack_retry: float = 0.0 + prep_done: bool = False + inline_repair_failures: int = 0 + last_bootwait_escalate: float = 0.0 + quality: DataQualityTracker = field(default_factory=DataQualityTracker) + tot_paused: bool = False + tot_paused_until: float = 0.0 + tot_trip_reason: str = "" + + +@dataclass +class DaemonState: + cfg: DaemonConfig + connected: bool = False + monitor_only: bool = False + selected_device: str = "" + devices: dict[str, DeviceRuntime] = field(default_factory=dict) + clients: Set[socket.socket] = field(default_factory=set) + bans: BanList = field(default_factory=BanList) + lock: threading.Lock = field(default_factory=threading.Lock) + started_at: float = 0.0 + + +def log(msg: str) -> None: + """Legacy log hook — structured stderr (human + machine readable).""" + LOGGER.emit_unstructured(msg) + + +def valid_callsign(value: str) -> bool: + return bool(value and CALLSIGN_RE.match(value.upper())) + + +def _truthy(value: str) -> bool: + return value.lower() in ("1", "yes", "true", "on") + + +def _ini_int( + cp: configparser.ConfigParser, + section: str, + key: str, + default: int, + *, + min_value: int = 1, + max_value: int = 86400, +) -> int: + if not cp.has_option(section, key): + return default + raw = cp.get(section, key) + try: + value = int(str(raw).strip()) + except (TypeError, ValueError): + LOGGER.warn( + f"[{section}] {key}={raw!r} invalid — using {default}", + area="config", + ) + return default + if value < min_value: + LOGGER.warn( + f"[{section}] {key}={value} below {min_value} — using {min_value}", + area="config", + ) + return min_value + if value > max_value: + LOGGER.warn( + f"[{section}] {key}={value} above {max_value} — using {max_value}", + area="config", + ) + return max_value + return value + + +def _safe_port(raw: str, default: int) -> int: + try: + port = int(str(raw).strip()) + except (TypeError, ValueError): + return default + if port < 1 or port > 65535: + return default + return port + + +def _serial_overrides_from_section(cp: configparser.ConfigParser, section: str) -> dict[str, str]: + if not cp.has_section(section): + return {} + out: dict[str, str] = {} + for key in ("device", "baud", "line", "dtr_rts", "kiss_entry"): + if cp.has_option(section, key): + out[key] = cp.get(section, key) + return out + + +def _apply_serial_overrides(dev: DeviceBackendConfig, overrides: dict[str, str]) -> None: + if overrides.get("device"): + dev.serial_device = overrides["device"] + if overrides.get("baud"): + dev.serial_baud = int(overrides["baud"]) + if overrides.get("line"): + dev.serial_line = overrides["line"] + if overrides.get("dtr_rts"): + dev.serial_dtr_rts = overrides["dtr_rts"] + if overrides.get("kiss_entry"): + dev.serial_kiss_entry = overrides["kiss_entry"] + + +def parse_devices(cp: configparser.ConfigParser, cfg: DaemonConfig) -> list[DeviceBackendConfig]: + """Build device list from [devices] or legacy [daemon] device= + [serial].""" + defaults = {"hardware": cfg.hardware} + if cp.has_section("devices"): + default_id = cp.get("devices", "default", fallback="").strip() + enabled_raw = cp.get("devices", "enabled", fallback="").strip() + enabled_set: Optional[set[str]] = None + if enabled_raw: + enabled_set = {x.strip() for x in enabled_raw.split(",") if x.strip()} + + entries: list[DeviceBackendConfig] = [] + for key in cp.options("devices"): + if key.lower() in RESERVED_DEVICE_KEYS: + continue + device_id = key.strip() + spec = cp.get("devices", key, fallback="").strip() + dev = parse_device_spec(device_id, spec, cp, defaults) + if enabled_set is not None: + dev.enabled = device_id in enabled_set + entries.append(dev) + + if not entries: + LOGGER.warn( + "[devices] section empty — falling back to legacy single device", + area="config", + ) + else: + if default_id: + cfg.default_device = default_id + if enabled_set is not None and default_id not in enabled_set: + enabled_entries = [d for d in entries if d.enabled] + if enabled_entries: + fallback = enabled_entries[0].device_id + LOGGER.warn( + f"[devices] default={default_id} not enabled — using {fallback}", + area="config", + ) + cfg.default_device = fallback + elif cfg.device: + cfg.default_device = cfg.device + else: + cfg.default_device = entries[0].device_id + return entries + + # Legacy single device + device_id = cfg.device or "tnc2c" + dev = parse_device_spec(device_id, "", cp, defaults) + legacy = _serial_overrides_from_section(cp, "serial") + if cfg.serial_device: + legacy.setdefault("device", cfg.serial_device) + if cfg.serial_baud: + legacy.setdefault("baud", str(cfg.serial_baud)) + if cfg.serial_line: + legacy.setdefault("line", cfg.serial_line) + if cfg.serial_dtr_rts: + legacy.setdefault("dtr_rts", cfg.serial_dtr_rts) + if cfg.serial_kiss_entry: + legacy.setdefault("kiss_entry", cfg.serial_kiss_entry) + _apply_serial_overrides(dev, legacy) + cfg.default_device = device_id + return [dev] + + +def _ini_float( + cp: configparser.ConfigParser, + section: str, + key: str, + default: float, + *, + min_value: float = 0.0, + max_value: float = 3600.0, +) -> float: + if not cp.has_option(section, key): + return default + raw = cp.get(section, key).strip() + try: + value = float(raw) + except ValueError: + LOGGER.warn(f"invalid float {section}.{key}={raw!r} — using {default}", area="config") + return default + if value < min_value: + return min_value + if value > max_value: + return max_value + return value + + +def load_config(path: Optional[Path]) -> DaemonConfig: + cfg = DaemonConfig() + if path is None: + for candidate in ( + Path(os.environ.get("MAX25D_INI", "")), + Path("/etc/max25/max25d.ini"), + ROOT / "share/max25/max25d.ini.example", + ): + if candidate and candidate.is_file(): + path = candidate + break + if path is None or not path.is_file(): + LOGGER.warn(f"using built-in defaults (no ini at {path})", area="config") + cfg.devices = [parse_device_spec(cfg.device, "", configparser.ConfigParser(), {"hardware": cfg.hardware})] + cfg.default_device = cfg.device + cfg.config_path = "" + return cfg + + cfg.config_path = str(path) + + cp = configparser.ConfigParser(strict=False) + cp.read(path) + if cp.has_section("daemon"): + cfg.mode = cp.get("daemon", "mode", fallback=cfg.mode) + cfg.hardware = cp.get("daemon", "hardware", fallback=cfg.hardware) + cfg.device = cp.get("daemon", "device", fallback=cfg.device) + run_as = parse_run_as(cp) + cfg.run_user = run_as.user + cfg.run_group = run_as.group + cfg.run_uid = run_as.uid + cfg.run_gid = run_as.gid + if cp.has_section("network"): + cfg.tcp_host = cp.get("network", "tcp_host", fallback=cfg.tcp_host) + cfg.tcp_port = _ini_int(cp, "network", "tcp_port", cfg.tcp_port, min_value=1, max_value=65535) + cfg.unix_socket = cp.get("network", "unix_socket", fallback=cfg.unix_socket) + cfg.tcp_password = cp.get("network", "tcp_password", fallback=cfg.tcp_password) + if cp.has_section("modem"): + cfg.callerid = cp.get("modem", "callerid", fallback=cfg.callerid).upper() + cfg.callid = cp.get("modem", "callid", fallback=cfg.callid).upper() + cfg.ax25_ui = _truthy(cp.get("modem", "ax25_ui", fallback="yes")) + cfg.bans_file = cp.get("modem", "bans_file", fallback=cfg.bans_file) + if cp.has_section("stack"): + cfg.auto_start = _truthy(cp.get("stack", "auto_start", fallback="yes")) + if cp.has_option("stack", "serial_watch"): + cfg.serial_watch = _truthy(cp.get("stack", "serial_watch")) + cfg.serial_watch_interval = _ini_int( + cp, "stack", "serial_watch_interval", cfg.serial_watch_interval + ) + cfg.serial_repair_cooldown = _ini_int( + cp, "stack", "serial_repair_cooldown", cfg.serial_repair_cooldown + ) + cfg.serial_watch_startup_grace = _ini_int( + cp, "stack", "serial_watch_startup_grace", cfg.serial_watch_startup_grace + ) + if cp.has_option("stack", "stack_recover_only"): + cfg.stack_recover_only = _truthy(cp.get("stack", "stack_recover_only")) + cfg.stack_retry_interval = _ini_int( + cp, "stack", "stack_retry_interval", cfg.stack_retry_interval + ) + if cp.has_option("stack", "serial_bootwait_escalate"): + cfg.serial_bootwait_escalate = _truthy(cp.get("stack", "serial_bootwait_escalate")) + cfg.serial_bootwait_escalate_after = _ini_int( + cp, "stack", "serial_bootwait_escalate_after", cfg.serial_bootwait_escalate_after + ) + cfg.serial_bootwait_escalate_cooldown = _ini_int( + cp, + "stack", + "serial_bootwait_escalate_cooldown", + cfg.serial_bootwait_escalate_cooldown, + ) + if cp.has_section("serial"): + cfg.serial_device = cp.get("serial", "device", fallback="") + cfg.serial_baud = cp.getint("serial", "baud", fallback=0) + cfg.serial_line = cp.get("serial", "line", fallback="") + cfg.serial_dtr_rts = cp.get("serial", "dtr_rts", fallback="") + cfg.serial_kiss_entry = cp.get("serial", "kiss_entry", fallback="") + + if cp.has_section("features"): + cfg.feature_baycom = _truthy(cp.get("features", "baycom", fallback="yes")) + cfg.feature_pccom = _truthy(cp.get("features", "pccom", fallback="yes")) + # Product key max25_bcpr; legacy bcpr= accepted + cfg.feature_max25_bcpr = _truthy( + cp.get("features", "max25_bcpr", fallback=cp.get("features", "bcpr", fallback="yes")) + ) + cfg.feature_tmodem = _truthy(cp.get("features", "tmodem", fallback="no")) + if cp.has_section("hybbx"): + cfg.hybbx_release_attach = _truthy( + cp.get("hybbx", "release_attach", fallback="no") + ) + if cp.has_option("hybbx", "attach"): + cfg.hybbx_release_attach = _truthy(cp.get("hybbx", "attach")) + cfg.report_error_transmissions = _truthy( + cp.get("reporting", "error_transmissions", fallback="yes") + ) + cfg.report_voice_transmissions = _truthy( + cp.get("reporting", "voice_transmissions", fallback="yes") + ) + cfg.report_data_passes = _ini_int( + cp, "reporting", "data_passes", 3, min_value=1, max_value=32 + ) + cfg.report_data_quality_min = _ini_int( + cp, "reporting", "data_quality_min", 50, min_value=1, max_value=100 + ) + cfg.report_data_pass_seconds = _ini_int( + cp, "reporting", "data_pass_seconds", 20, min_value=1, max_value=300 + ) + if cp.has_section("tot"): + cfg.tot.enabled = _truthy(cp.get("tot", "enabled", fallback="yes")) + cfg.tot.max_key_sec = _ini_int( + cp, "tot", "max_key_sec", cfg.tot.max_key_sec, min_value=1, max_value=600 + ) + if cp.has_option("tot", "max_key_ms"): + cfg.tot.max_key_sec = max( + 1, _ini_int(cp, "tot", "max_key_ms", cfg.tot.max_key_sec * 1000) // 1000 + ) + cfg.tot.min_gap_sec = _ini_float( + cp, "tot", "min_gap_sec", cfg.tot.min_gap_sec, min_value=0.0, max_value=60.0 + ) + if cp.has_option("tot", "min_gap_ms"): + cfg.tot.min_gap_sec = _ini_int(cp, "tot", "min_gap_ms", 1500) / 1000.0 + cfg.tot.max_consecutive = _ini_int( + cp, "tot", "max_consecutive", cfg.tot.max_consecutive, min_value=1, max_value=32 + ) + cfg.tot.max_bursts = _ini_int( + cp, "tot", "max_bursts", cfg.tot.max_bursts, min_value=1, max_value=64 + ) + cfg.tot.recover_sec = _ini_int( + cp, "tot", "recover_sec", cfg.tot.recover_sec, min_value=0, max_value=86400 + ) + + cfg.devices = parse_devices(cp, cfg) + cfg.devices = [d for d in cfg.devices if _device_allowed_by_features(d, cfg)] + allowed = supported_device_ids() + if allowed: + filtered: list[DeviceBackendConfig] = [] + for dev in cfg.devices: + if dev.device_id in allowed: + filtered.append(dev) + else: + LOGGER.warn( + f"device {dev.device_id} not supported on {platform_label()} — skipped", + area="config", + ) + cfg.devices = filtered + if not cfg.devices: + LOGGER.warn("no devices remain after config/platform filter", area="config") + cfg.modular_tcp = load_modular_tcp(cp) + if not cfg.default_device and cfg.devices: + cfg.default_device = cfg.devices[0].device_id + cfg.device = cfg.default_device + enforce_lite_localhost(cfg) + return cfg + + +def _is_loopback_tcp_host(host: str) -> bool: + """Lite allows TCP only on this machine.""" + h = (host or "").strip().lower() + if not h: + return False + if h in ("127.0.0.1", "::1", "localhost"): + return True + # Accept IPv4-mapped IPv6 loopback forms if ever written in INI. + if h in ("::ffff:127.0.0.1", "[::1]"): + return True + return False + + +def enforce_lite_localhost(cfg: DaemonConfig) -> None: + """Refuse LAN / wildcard TCP binds — one-PC product rule.""" + if _is_loopback_tcp_host(cfg.tcp_host): + return + msg = ( + f"max25d: Lite only allows TCP on this computer " + f"(127.0.0.1, ::1, or localhost) — got tcp_host={cfg.tcp_host!r}. " + "Fix your INI (use max25-lite-setup) or connect via the Unix socket." + ) + log(msg) + raise SystemExit(1) + + +def init_device_runtimes(state: DaemonState) -> None: + state.devices.clear() + for dev_cfg in state.cfg.devices: + if not dev_cfg.enabled: + continue + state.devices[dev_cfg.device_id] = DeviceRuntime( + cfg=dev_cfg, + quality=DataQualityTracker( + passes_required=state.cfg.report_data_passes, + min_good_percent=state.cfg.report_data_quality_min, + pass_window_sec=state.cfg.report_data_pass_seconds, + ), + ) + if not registry_tested(dev_cfg.device_id): + LOGGER.warn( + f"backend={dev_cfg.backend_type or 'auto'} — not hardware-validated in CI", + area="devices", + device=dev_cfg.device_id, + ) + if state.cfg.default_device in state.devices: + state.selected_device = state.cfg.default_device + elif state.devices: + state.selected_device = next(iter(state.devices)) + else: + state.selected_device = state.cfg.default_device + LOGGER.warn("no enabled device runtimes — SEND/CONNECT unavailable", area="devices") + + +def enabled_device_ids(state: DaemonState) -> list[str]: + return list(state.devices.keys()) + + +def device_hardware(state: DaemonState, dev_id: str) -> str: + rt = state.devices.get(dev_id) + if rt is None: + return state.cfg.hardware + return rt.cfg.hardware or state.cfg.hardware + + +def device_backend_kind(state: DaemonState, dev_id: str) -> str: + rt = state.devices.get(dev_id) + if rt is None: + return "kiss-serial" + return rt.cfg.backend_type or "kiss-serial" + + +def on_backend_rx(state: DaemonState, dev_id: str, line: str) -> None: + rt = state.devices.get(dev_id) + if rt is not None: + _sync_quality_tracker(state, rt) + rt.quality.record(classify_rx_line(line, state.cfg.callid)) + src = extract_ax25_source(line) + if src and state.bans.is_banned(src): + return + log(f"rx {dev_id}: {line}") + broadcast(state, f"RX device={dev_id} {line}") + + +def on_backend_invalid_frame(state: DaemonState, dev_id: str) -> None: + rt = state.devices.get(dev_id) + if rt is None: + return + _sync_quality_tracker(state, rt) + rt.quality.record_bad() + if state.cfg.report_error_transmissions: + broadcast(state, f"EVENT device={dev_id} error=invalid") + + +VOICE_BACKEND_KINDS = frozenset({"crdop-tcp", "audio-dummy"}) + + +def device_has_voice_path(rt: DeviceRuntime) -> bool: + kind = rt.cfg.backend_type or registry_backend(rt.cfg.device_id) + if kind in VOICE_BACKEND_KINDS: + return True + hw = (rt.cfg.hardware or "").lower() + return hw in ("acoustic-bench", "soft-modems") + + +def device_link_status(rt: DeviceRuntime) -> str: + if rt.backend is not None: + return backend_serial_label(rt.backend) + return rt.link_status + + +def link_status_is_healthy(status: str) -> bool: + if status in ("ready", "open", "n/a"): + return True + if status.startswith("error") or status in ("closed", "stopped"): + return False + return True + + +def _sync_quality_tracker(state: DaemonState, rt: DeviceRuntime) -> None: + required = state.cfg.report_data_passes + minimum = state.cfg.report_data_quality_min + window = state.cfg.report_data_pass_seconds + if ( + rt.quality.passes_required == required + and rt.quality.min_good_percent == minimum + and rt.quality.pass_window_sec == window + and rt.quality.passes.maxlen == required + ): + return + kept = list(rt.quality.passes)[-required:] + voice = rt.quality.voice_activity + current = rt.quality.current + rt.quality = DataQualityTracker( + passes_required=required, + min_good_percent=minimum, + pass_window_sec=window, + voice_activity=voice, + ) + for item in kept: + rt.quality.passes.append(item) + if current.started_at != 0.0: + rt.quality.current = current + + +def device_reporting_error(state: DaemonState, rt: DeviceRuntime) -> str: + if not state.cfg.report_error_transmissions: + return "invalid" + if not link_status_is_healthy(device_link_status(rt)): + return "invalid" + _sync_quality_tracker(state, rt) + if rt.quality.data_error_valid(reporting_enabled=True): + return "valid" + return "invalid" + + +def aggregate_reporting_error(state: DaemonState) -> str: + if not state.devices: + return "invalid" if not state.cfg.report_error_transmissions else "valid" + for rt in state.devices.values(): + if device_reporting_error(state, rt) == "invalid": + return "invalid" + return "valid" + + +def aggregate_reporting_voice(state: DaemonState) -> str: + if not state.cfg.report_voice_transmissions: + return "invalid" + voice_rts = [rt for rt in state.devices.values() if device_has_voice_path(rt)] + if not voice_rts: + return "valid" + for rt in voice_rts: + if not link_status_is_healthy(device_link_status(rt)): + return "invalid" + return "valid" + + +def device_reporting_voice(state: DaemonState, rt: DeviceRuntime) -> str: + if not device_has_voice_path(rt): + return "n/a" + _sync_quality_tracker(state, rt) + healthy = link_status_is_healthy(device_link_status(rt)) + if rt.quality.voice_signal_valid( + reporting_enabled=state.cfg.report_voice_transmissions, + link_healthy=healthy, + ): + return "valid" + return "invalid" + + +BACKEND_POLL_OPEN_STATUSES = frozenset( + { + "closed", + "error-open", + "error-connect", + "error-no-device", + "error-no-path", + } +) + +SERIAL_REPAIR_STATUSES = frozenset( + { + "error-host", + "error-kiss", + "error-tx", + "error-io", + "error-config", + } +) + +BACKEND_RETRY_STATUSES = BACKEND_POLL_OPEN_STATUSES | SERIAL_REPAIR_STATUSES + +HYBBX_ATTACH_MODES = frozenset({"hybbx-host", "hybbx-main", "hybbx-cohost"}) + + +def hybbx_release_attach(state: DaemonState) -> bool: + """HyBBX opens serial/KISS after max25d prep — max25d must not hold the fd.""" + mode = (state.cfg.mode or "").strip().lower() + if mode in HYBBX_ATTACH_MODES: + return True + return bool(getattr(state.cfg, "hybbx_release_attach", False)) + + +def uses_inline_tnc_prep(state: DaemonState, dev_id: str) -> bool: + """kiss-serial owned by max25d — no boot-wait subprocess (avoids port conflict).""" + if not state.cfg.stack_recover_only: + return False + return device_backend_kind(state, dev_id) == "kiss-serial" + + +def hybbx_host_hybbx_owns_serial(state: DaemonState, dev_id: str) -> bool: + """In HyBBX attach mode HyBBX opens serial/KISS after max25d prep.""" + if not hybbx_release_attach(state): + return False + kind = device_backend_kind(state, dev_id) + # kiss-raw + max25-bcpr: HyBBX owns the KISS attach; max25d must not hold the PTY. + if kind in ("kiss-raw-serial", "max25-bcpr-kiss", "bcpr-kiss"): + return True + if kind != "kiss-serial": + return False + rt = state.devices.get(dev_id) + return rt is not None and rt.prep_done and rt.backend is None + + +def prep_inline_serial_device(state: DaemonState, dev_id: str) -> None: + """Open serial and run initial recovery while holding DTR (no subprocess).""" + rt = state.devices[dev_id] + rt.stack_status = "ready" + LOGGER.info("inline prep — max25d owns serial recovery", area="stack", device=dev_id) + if not backend_enabled(state, dev_id): + return + if not open_backend(state, dev_id): + LOGGER.error(f"serial prep open failed status={rt.link_status}", area="serial", device=dev_id) + return + backend = rt.backend + stabilize = getattr(backend, "stabilize_session", None) + if stabilize is None: + return + ok = stabilize(state.cfg.callerid, force=False) + rt.prep_done = True + rt.link_status = backend.status + if ok: + LOGGER.ok("serial prep complete — terminal + KISS ready", area="serial", device=dev_id) + if hybbx_release_attach(state): + close_backend(state, dev_id) + rt.link_status = "ready" + LOGGER.info( + "HyBBX attach: serial released for HyBBX KISS attach", + area="serial", + device=dev_id, + ) + elif ( + backend.status == "error-host" + and state.cfg.serial_bootwait_escalate + and rt.stack_proc is None + ): + LOGGER.warn( + "inline ladder exhausted (error-host) — escalating to boot-wait + power-cycle hint", + area="serial", + device=dev_id, + ) + rt.last_bootwait_escalate = time.time() + escalate_to_bootwait_stack(state, dev_id) + else: + LOGGER.warn( + f"prep deferred status={backend.status} — serial watch will retry", + area="serial", + device=dev_id, + ) + + +def escalate_to_bootwait_stack(state: DaemonState, dev_id: str) -> None: + """Release inline serial and run boot-wait subprocess (DTR + power-cycle rescue).""" + rt = state.devices[dev_id] + close_backend(state, dev_id) + rt.prep_done = False + ctl = ctl_path(ROOT, PREFIX, _EXE) + if not ctl.is_file(): + rt.stack_status = "error-no-ctl" + log(f"serial watch: boot-wait escalate failed — no ctl ({dev_id})") + return + hw = device_hardware(state, dev_id) + args = [ + str(ctl), + "start", + "--mode", + state.cfg.mode, + "--hardware", + hw, + "--device", + dev_id, + ] + env = os.environ.copy() + env["MAX25_MODE"] = state.cfg.mode + env.pop("MAX25_TNC_PREP", None) + workdir = str(ROOT if (ROOT / "plugins").is_dir() else (PREFIX or ROOT)) + try: + proc = subprocess.Popen( + args, + cwd=workdir, + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + except OSError as exc: + log(f"serial watch: boot-wait escalate failed ({dev_id}): {exc}") + rt.stack_status = "error" + return + rt.stack_proc = proc + rt.stack_status = "running" + log( + f"serial watch: escalating to boot-wait ({dev_id}) pid={proc.pid} " + "— power OFF TNC 10s then ON while script runs (DTR held high)" + ) + broadcast(state, f"EVENT device={dev_id} serial=boot-wait-escalate") + + +def backend_needs_open(backend: Optional[DeviceBackend]) -> bool: + if backend is None: + return True + return backend.status in BACKEND_POLL_OPEN_STATUSES + + +def open_backend(state: DaemonState, dev_id: str) -> bool: + rt = state.devices.get(dev_id) + if rt is None or not backend_enabled(state, dev_id): + if rt is not None: + rt.link_status = "n/a" + return False + if hybbx_host_hybbx_owns_serial(state, dev_id): + rt.link_status = "ready" + rt.prep_done = True + rt.stack_status = "ready" + return True + if rt.backend is not None and rt.backend.status not in BACKEND_RETRY_STATUSES: + return rt.backend.status in ("open", "ready") + if ( + rt.backend is not None + and rt.backend.status in SERIAL_REPAIR_STATUSES + and rt.prep_done + ): + return rt.backend.status in ("open", "ready", "error-host", "error-kiss") + if rt.backend is not None and rt.backend.status != "closed": + rt.backend.close() + rt.backend = None + backend = create_backend( + rt.cfg, + str(ROOT), + lambda line, d=dev_id: on_backend_rx(state, d, line), + log, + prefix=str(PREFIX) if PREFIX else None, + on_invalid=lambda d=dev_id: on_backend_invalid_frame(state, d), + ) + if not backend.open(): + rt.backend = backend + rt.link_status = backend.status + return False + rt.backend = backend + rt.link_status = backend.status + return True + + +def close_backend(state: DaemonState, dev_id: str) -> None: + rt = state.devices.get(dev_id) + if rt is None or rt.backend is None: + return + rt.backend.close() + rt.link_status = rt.backend.status + rt.backend = None + + +def attach_backend_session(state: DaemonState, dev_id: str) -> bool: + if not backend_enabled(state, dev_id): + return True + if hybbx_host_hybbx_owns_serial(state, dev_id): + return True + rt = state.devices[dev_id] + if backend_needs_open(rt.backend): + if not open_backend(state, dev_id): + return False + assert rt.backend is not None + ok = rt.backend.attach_session(state.cfg.callerid) + rt.link_status = rt.backend.status + return ok + + +def detach_backend_session(state: DaemonState, dev_id: str) -> None: + rt = state.devices.get(dev_id) + if rt is None or rt.backend is None: + return + rt.backend.detach_session() + rt.link_status = rt.backend.status + + +def attach_all_sessions(state: DaemonState) -> bool: + ok = True + for dev_id in enabled_device_ids(state): + if backend_enabled(state, dev_id): + if not attach_backend_session(state, dev_id): + ok = False + return ok + + +def detach_all_sessions(state: DaemonState) -> None: + for dev_id in enabled_device_ids(state): + detach_backend_session(state, dev_id) + + +def backend_enabled(state: DaemonState, dev_id: str) -> bool: + if not state.cfg.serial_enabled: + return False + rt = state.devices.get(dev_id) + if rt is None: + return False + if rt.tot_paused: + return False + kind = rt.cfg.backend_type + return kind in ("kiss-serial", "baycom-kiss", "max25-bcpr-kiss", "bcpr-kiss", "kiss-raw-serial", "crdop-tcp") + + +def aggregate_stack_status(state: DaemonState) -> str: + if not state.devices: + return "stopped" + statuses = {rt.stack_status for rt in state.devices.values()} + if "running" in statuses: + return "running" + if any(s.startswith("error") for s in statuses): + return "error" + if statuses == {"ready"} or statuses == {"stopped"}: + return next(iter(statuses)) + if "ready" in statuses: + return "ready" + return "running" if "running" in statuses else "stopped" + + +def aggregate_link_status(state: DaemonState) -> str: + if not state.devices: + return "n/a" + if len(state.devices) == 1: + rt = next(iter(state.devices.values())) + return backend_serial_label(rt.backend) if rt.backend is not None else rt.link_status + parts: list[str] = [] + for dev_id in sorted(state.devices): + rt = state.devices[dev_id] + st = backend_serial_label(rt.backend) if rt.backend is not None else rt.link_status + parts.append(f"{dev_id}={st}") + return ",".join(parts) + + +def status_line(state: DaemonState) -> str: + c = state.cfg + dev_list = ",".join(enabled_device_ids(state)) + selected = state.selected_device or c.default_device or c.device + return ( + f"STATUS hardware={c.hardware} device={selected} devices={dev_list} " + f"mode={c.mode} callerid={c.callerid} callid={c.callid} " + f"ax25_ui={'on' if c.ax25_ui else 'off'} " + f"connected={'yes' if state.connected else 'no'} " + f"stack={aggregate_stack_status(state)} serial={aggregate_link_status(state)} " + f"error={aggregate_reporting_error(state)} " + f"voice={aggregate_reporting_voice(state)}" + ) + + +def broadcast(state: DaemonState, line: str, skip: Optional[socket.socket] = None) -> None: + payload = (line + "\n").encode("utf-8") + dead: list[socket.socket] = [] + with state.lock: + for sock in state.clients: + if sock is skip: + continue + try: + sock.sendall(payload) + except OSError: + dead.append(sock) + for sock in dead: + state.clients.discard(sock) + + +def send_line(sock: socket.socket, line: str) -> None: + sock.sendall((line + "\n").encode("utf-8")) + + +def unix_path_is_live(path: str, *, timeout: float = 0.3) -> bool: + """True if path exists and accepts a Unix connect (live max25d listener).""" + if not path or not os.path.exists(path): + return False + probe = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + probe.settimeout(timeout) + probe.connect(path) + return True + except OSError: + return False + finally: + try: + probe.close() + except OSError: + pass + + +def unix_path_id(path: str) -> tuple[int, int] | None: + """Filesystem identity of a Unix socket path (dev, ino), or None.""" + try: + st = os.stat(path) + except OSError: + return None + return (st.st_dev, st.st_ino) + + +def unlink_unix_if_ours(path: str, bind_id: tuple[int, int] | None) -> None: + """Unlink path only when it still names the inode we bound. + + AF_UNIX: fstat(listen_fd) is sockfs — must not compare to path st_ino. + A second max25d that unlinks+rebinds must not lose its path when the + first instance exits (orphaned listen FD + ENOENT for clients). + """ + if not path or bind_id is None: + return + cur = unix_path_id(path) + if cur is None or cur != bind_id: + return + try: + os.unlink(path) + except FileNotFoundError: + pass + except OSError: + pass + + +def resolve_max25_bcpr_ini(explicit: str = "") -> Path | None: + """Resolve max25-bcpr.ini for userspace SER12 (max25e0).""" + from pathlib import Path as _P + if explicit: + p = _P(explicit) + return p if p.is_file() else None + for cand in ( + ROOT / "local" / "max25-bcpr.ini", + _P("/etc/max25/max25-bcpr.ini"), + _P("/etc/max25/bcpr.ini"), + ROOT / "local" / "bcpr.ini", + ROOT / "stacks" / "max25-bcpr" / "share" / "max25-bcpr.ini.example", + ROOT / "share" / "max25-bcpr" / "max25-bcpr.ini.example", + ): + if cand.is_file(): + return cand + return None + + +def resolve_max25_bcpr_ctl() -> Path | None: + from pathlib import Path as _P + for cand in ( + ROOT / "stacks" / "max25-bcpr" / "tools" / "max25-bcpr-ctl", + _P("/usr/local/sbin/max25-bcpr-ctl"), + _P("/usr/sbin/max25-bcpr-ctl"), + ): + if cand.is_file(): + return cand + return None + + +def bcpr_bc_index(cfg: DeviceBackendConfig) -> int: + tag = (cfg.max25_bcpr_device or cfg.bcpr_device or "").strip() + if not tag and cfg.device_id.startswith("max25e0:"): + tag = cfg.device_id.split(":", 1)[1] + if not tag: + tag = "bc0" + if tag.startswith("bc") and tag[2:].isdigit(): + return int(tag[2:]) + return 0 + + +def read_bcpr_state_dir(ini_path: Path) -> str: + cp = configparser.ConfigParser() + cp.read(ini_path) + for sect in ("max25-bcpr", "bcpr"): + if cp.has_section(sect) and cp.has_option(sect, "state_dir"): + return cp.get(sect, "state_dir").strip() or "/tmp/max25-bcpr" + return "/tmp/max25-bcpr" + + +def sync_tot_to_bcpr_ini(ini_path: Path, tot: TotConfig) -> None: + """Push max25d [tot] policy into max25-bcpr.ini before bcprd start.""" + if not ini_path.is_file(): + return + cp = configparser.ConfigParser() + cp.read(ini_path) + sect = "max25-bcpr" + if not cp.has_section(sect): + if cp.has_section("bcpr"): + sect = "bcpr" + else: + cp.add_section(sect) + cp.set(sect, "tot", "yes" if tot.enabled else "no") + cp.set(sect, "tot_max_key_sec", str(tot.max_key_sec)) + cp.set(sect, "tot_min_gap_ms", str(int(tot.min_gap_sec * 1000.0))) + cp.set(sect, "tot_max_consecutive", str(tot.max_consecutive)) + cp.set(sect, "tot_max_bursts", str(tot.max_bursts)) + with ini_path.open("w", encoding="utf-8") as fh: + cp.write(fh) + + +def tot_trip_path(state_dir: str, bc_index: int) -> Path: + return Path(state_dir) / f"tot-trip-bc{bc_index}" + + +def parse_tot_trip_file(path: Path) -> dict[str, str]: + out: dict[str, str] = {} + try: + for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): + if "=" not in line: + continue + key, val = line.split("=", 1) + out[key.strip()] = val.strip() + except OSError: + pass + return out + + +def device_tot_operational(state: DaemonState, dev_id: str) -> bool: + rt = state.devices.get(dev_id) + if rt is None: + return False + return not rt.tot_paused + + +def handle_tot_trip(state: DaemonState, dev_id: str, reason: str) -> None: + rt = state.devices.get(dev_id) + if rt is None or rt.tot_paused: + return + log(f"TOT trip ({dev_id}) reason={reason} — pause, stop stack, reset bcpr") + rt.tot_paused = True + rt.tot_trip_reason = reason or "unknown" + recover = state.cfg.tot.recover_sec + rt.tot_paused_until = time.time() + recover if recover > 0 else 0.0 + stop_device_stack(state, dev_id) + rt.stack_status = "tot-paused" + rt.link_status = "tot-paused" + rt.prep_done = False + broadcast( + state, + f"EVENT device={dev_id} tot=trip reason={rt.tot_trip_reason} stack=tot-paused", + ) + + +def poll_tot_trips(state: DaemonState) -> None: + if not state.cfg.tot.enabled: + return + for dev_id, rt in state.devices.items(): + kind = rt.cfg.backend_type or "" + if kind not in ("max25-bcpr-kiss", "bcpr-kiss"): + continue + if rt.tot_paused: + continue + explicit = rt.cfg.max25_bcpr_ini or rt.cfg.bcpr_ini or "" + resolved = resolve_max25_bcpr_ini(explicit) + if resolved is None: + continue + state_dir = read_bcpr_state_dir(resolved) + trip = tot_trip_path(state_dir, bcpr_bc_index(rt.cfg)) + if not trip.is_file(): + continue + meta = parse_tot_trip_file(trip) + if meta.get("tripped") != "1": + continue + handle_tot_trip(state, dev_id, meta.get("reason", "unknown")) + + +def poll_tot_recovery(state: DaemonState) -> None: + if not state.cfg.tot.enabled: + return + now = time.time() + for dev_id, rt in state.devices.items(): + if not rt.tot_paused: + continue + kind = rt.cfg.backend_type or "" + if kind not in ("max25-bcpr-kiss", "bcpr-kiss"): + continue + recover = state.cfg.tot.recover_sec + if recover <= 0: + continue + if rt.tot_paused_until > 0 and now < rt.tot_paused_until: + continue + explicit = rt.cfg.max25_bcpr_ini or rt.cfg.bcpr_ini or "" + resolved = resolve_max25_bcpr_ini(explicit) + if resolved is not None: + trip = tot_trip_path(read_bcpr_state_dir(resolved), bcpr_bc_index(rt.cfg)) + try: + trip.unlink(missing_ok=True) + except OSError: + pass + rt.tot_paused = False + rt.tot_trip_reason = "" + rt.tot_paused_until = 0.0 + log(f"TOT recover ({dev_id}) — restarting max25-bcpr stack") + if state.cfg.auto_start and backend_enabled(state, dev_id): + start_device_stack_for_tot(state, dev_id) + + +def start_device_stack_for_tot(state: DaemonState, dev_id: str) -> None: + """Restart bcpr stack after TOT cooldown (single device, no full start_stacks).""" + rt = state.devices[dev_id] + kind = rt.cfg.backend_type or "" + if kind in ("max25-bcpr-kiss", "bcpr-kiss"): + explicit = rt.cfg.max25_bcpr_ini or rt.cfg.bcpr_ini or "" + resolved = resolve_max25_bcpr_ini(explicit) + if resolved is None: + rt.stack_status = "error-no-ini" + return + sync_tot_to_bcpr_ini(resolved, state.cfg.tot) + start_max25_bcpr_stack(state, dev_id, resolved) + + +def _read_subprocess_capture(fh, limit: int = 4000) -> str: + """Read captured ctl stdout/stderr (UTF-8, truncated tail).""" + try: + fh.seek(0) + raw = fh.read() + except OSError: + return "" + text = raw.decode("utf-8", errors="replace").strip() + if len(text) > limit: + return f"...\n{text[-limit:]}" + return text + + +def start_max25_bcpr_stack(state: DaemonState, dev_id: str, ini: Path) -> None: + """Start max25-bcprd once via max25-bcpr-ctl for shared ini.""" + rt = state.devices[dev_id] + ctl = resolve_max25_bcpr_ctl() + if ctl is None: + rt.stack_status = "error-no-ctl" + log(f"max25-bcpr-ctl not found ({dev_id})") + return + args = [str(ctl), "-c", str(ini), "start"] + env = os.environ.copy() + # Temp file (not PIPE): bcprd backgrounded by ctl must not block communicate(). + # Capture ctl output; log tail only when rc!=0 or after ctl timeout. + proc: subprocess.Popen[bytes] | None = None + ctl_output = "" + try: + with tempfile.TemporaryFile(mode="w+b") as capfh: + try: + proc = subprocess.Popen( + args, + cwd=str(ROOT), + env=env, + stdin=subprocess.DEVNULL, + stdout=capfh, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + except OSError as exc: + log(f"max25-bcpr start failed ({dev_id}): {exc}") + rt.stack_status = "error" + return + try: + proc.communicate(timeout=30) + except subprocess.TimeoutExpired: + proc.kill() + try: + proc.communicate(timeout=2) + except (subprocess.TimeoutExpired, OSError): + pass + ctl_output = _read_subprocess_capture(capfh) + # May still have spawned max25-bcprd — fall through to kiss probe. + log(f"max25-bcpr-ctl start timed out ({dev_id}) — probing live kiss/pid") + if ctl_output: + log(f"max25-bcpr-ctl output ({dev_id}): {ctl_output}") + else: + ctl_output = _read_subprocess_capture(capfh) + except OSError as exc: + log(f"max25-bcpr start failed ({dev_id}): {exc}") + rt.stack_status = "error" + return + if proc is None: + rt.stack_status = "error" + return + if proc.returncode not in (0, None, -9, -15): + # -9/-15: we killed a hung ctl; still probe kiss below. + if proc.returncode > 0: + rt.stack_status = "error" + if ctl_output: + log(f"max25-bcpr-ctl start rc={proc.returncode} ({dev_id}): {ctl_output}") + else: + log(f"max25-bcpr-ctl start rc={proc.returncode} ({dev_id})") + return + # max25-bcpr-ctl itself exits; live daemon is max25-bcprd (pidfile under state_dir). + rt.stack_proc = None + rt.stack_status = "running" + kiss = normalize_max25_bcpr_path( + (rt.cfg.kiss_link or "").strip() or MAX25_BCPR_KISS_DEFAULT + ) + deadline = time.time() + 5.0 + while time.time() < deadline: + if os.path.exists(kiss): + break + time.sleep(0.1) + if not os.path.exists(kiss): + rt.stack_status = "error-no-kiss" + log(f"max25-bcpr kiss_link missing after start ({dev_id}: {kiss})") + return + addrs = f"ipv4={rt.cfg.ipv4 or '-'} ipv6={rt.cfg.ipv6 or '-'}" + log(f"max25-bcpr started ({dev_id}, ini={ini}, kiss={kiss}, {addrs})") + rt.stack_status = "ready" + # HyBBX attach: HyBBX opens kiss_link — do not hold the PTY here. + if hybbx_release_attach(state): + rt.link_status = "ready" + rt.prep_done = True + log(f"max25-bcpr ready — HyBBX owns KISS attach ({dev_id}: {kiss})") + return + # Standalone: open+hold KISS so max25-terminal TX works (stack_proc=None). + if backend_enabled(state, dev_id): + if open_backend(state, dev_id): + if attach_backend_session(state, dev_id): + # UI/datagram TX uses CONNECT as session arm — arm at start so + # max25-terminal SEND keys MCR without a separate CONNECT race. + state.connected = True + log(f"max25-bcpr KISS open ({dev_id}: {kiss})") + else: + log(f"max25-bcpr KISS open but attach failed ({dev_id})") + else: + log(f"max25-bcpr KISS open failed ({dev_id}) status={rt.link_status}") + + +def start_device_stack(state: DaemonState, dev_id: str) -> None: + if uses_inline_tnc_prep(state, dev_id): + prep_inline_serial_device(state, dev_id) + return + rt = state.devices[dev_id] + ctl = ctl_path(ROOT, PREFIX, _EXE) + if not ctl.is_file(): + rt.stack_status = "error-no-ctl" + return + hw = device_hardware(state, dev_id) + dev_cfg = rt.cfg + ctl_device = dev_id + if (dev_cfg.backend_type or "") == "baycom-kiss": + ctl_device = baycom_ctl_device_id(dev_cfg) + args = [ + str(ctl), + "start", + "--mode", + state.cfg.mode, + "--hardware", + hw, + "--device", + ctl_device, + ] + kind = dev_cfg.backend_type or "" + if kind == "baycom-kiss": + explicit = dev_cfg.baycom_ini or "" + resolved = resolve_baycom_ini(dev_id, ROOT, PREFIX, explicit) + if resolved: + args.extend(["--baycom-ini", str(resolved)]) + env = os.environ.copy() + env["MAX25_MODE"] = state.cfg.mode + if hw == "tncs" and state.cfg.stack_recover_only: + env["MAX25_TNC_PREP"] = "recover" + workdir = str(ROOT if (ROOT / "plugins").is_dir() else (PREFIX or ROOT)) + try: + proc = subprocess.Popen( + args, + cwd=workdir, + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + except OSError as exc: + log(f"stack start failed ({dev_id}): {exc}") + rt.stack_status = "error" + return + rt.stack_proc = proc + rt.stack_status = "running" + log(f"stack started pid={proc.pid} ({hw}/{dev_id})") + + +def start_stacks(state: DaemonState) -> None: + """Start per-device stacks; one max25-bcpr-ctl per shared ini.""" + started_baycom_ini: dict[str, str] = {} + started_max25_bcpr_ini: dict[str, str] = {} + for dev_id in enabled_device_ids(state): + rt = state.devices[dev_id] + kind = rt.cfg.backend_type or "" + if uses_inline_tnc_prep(state, dev_id): + prep_inline_serial_device(state, dev_id) + continue + if kind in ("max25-bcpr-kiss", "bcpr-kiss"): + explicit = rt.cfg.max25_bcpr_ini or rt.cfg.bcpr_ini or "" + resolved = resolve_max25_bcpr_ini(explicit) + ini_key = str(resolved) if resolved else "" + if ini_key and ini_key in started_max25_bcpr_ini: + primary = started_max25_bcpr_ini[ini_key] + primary_rt = state.devices[primary] + rt.stack_proc = primary_rt.stack_proc + rt.stack_status = primary_rt.stack_status + log(f"max25-bcpr stack shared with {primary} ({dev_id}, ini={ini_key})") + # Shared max25-bcprd — open this device's kiss only when max25d owns it. + if ( + not hybbx_release_attach(state) + and rt.stack_status in ("ready", "running") + and backend_enabled(state, dev_id) + ): + if open_backend(state, dev_id): + attach_backend_session(state, dev_id) + elif hybbx_release_attach(state): + rt.link_status = "ready" + rt.prep_done = True + continue + if resolved: + sync_tot_to_bcpr_ini(resolved, state.cfg.tot) + start_max25_bcpr_stack(state, dev_id, resolved) + started_max25_bcpr_ini[str(resolved)] = dev_id + else: + rt.stack_status = "error-no-ini" + log(f"max25-bcpr.ini not found ({dev_id})") + continue + if kind == "baycom-kiss": + explicit = rt.cfg.baycom_ini or "" + resolved = resolve_baycom_ini(dev_id, ROOT, PREFIX, explicit) + ini_key = str(resolved) if resolved else "" + if ini_key and ini_key in started_baycom_ini: + primary = started_baycom_ini[ini_key] + primary_rt = state.devices[primary] + rt.stack_proc = primary_rt.stack_proc + rt.stack_status = primary_rt.stack_status + log(f"stack shared with {primary} ({dev_id}, ini={ini_key})") + continue + start_device_stack(state, dev_id) + if kind == "baycom-kiss": + explicit = rt.cfg.baycom_ini or "" + resolved = resolve_baycom_ini(dev_id, ROOT, PREFIX, explicit) + if resolved: + started_baycom_ini[str(resolved)] = dev_id + + +def stop_device_stack(state: DaemonState, dev_id: str) -> None: + close_backend(state, dev_id) + rt = state.devices[dev_id] + kind = rt.cfg.backend_type or "" + if kind in ("max25-bcpr-kiss", "bcpr-kiss"): + ctl = resolve_max25_bcpr_ctl() + explicit = rt.cfg.max25_bcpr_ini or rt.cfg.bcpr_ini or "" + resolved = resolve_max25_bcpr_ini(explicit) + if ctl is not None and resolved is not None: + subprocess.run( + [str(ctl), "-c", str(resolved), "stop"], + cwd=str(ROOT), + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + rt.stack_proc = None + rt.stack_status = "stopped" + return + proc = rt.stack_proc + if proc is not None and proc.poll() is None: + try: + os.killpg(proc.pid, signal.SIGTERM) + except ProcessLookupError: + pass + except OSError: + proc.terminate() + rt.stack_proc = None + rt.stack_status = "stopped" + hw = device_hardware(state, dev_id) + ctl = ctl_path(ROOT, PREFIX, _EXE) + if ctl.is_file(): + workdir = str(ROOT if (ROOT / "plugins").is_dir() else (PREFIX or ROOT)) + stop_args = [str(ctl), "stop", "--hardware", hw, "--device", dev_id] + if kind == "baycom-kiss": + explicit = rt.cfg.baycom_ini or "" + resolved = resolve_baycom_ini(dev_id, ROOT, PREFIX, explicit) + if resolved: + stop_args.extend(["--baycom-ini", str(resolved)]) + subprocess.run( + stop_args, + cwd=workdir, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + +def stop_stacks(state: DaemonState) -> None: + for dev_id in list(state.devices): + stop_device_stack(state, dev_id) + log("all stacks stopped") + + +def poll_device_stack(state: DaemonState, dev_id: str) -> None: + rt = state.devices[dev_id] + proc = rt.stack_proc + if proc is None: + return + rc = proc.poll() + if rc is None: + return + rt.stack_proc = None + if rc == 0: + rt.stack_status = "ready" + log(f"stack boot-wait finished ({dev_id}) rc={rc}") + if backend_enabled(state, dev_id): + open_backend(state, dev_id) + backend = rt.backend + stabilize = getattr(backend, "stabilize_session", None) if backend else None + if stabilize is not None: + ok = stabilize(state.cfg.callerid, force=False) + rt.prep_done = True + rt.link_status = backend.status + rt.inline_repair_failures = 0 + if ok: + log(f"serial post boot-wait OK ({dev_id})") + if state.connected: + attach_backend_session(state, dev_id) + else: + log( + f"serial post boot-wait deferred ({dev_id}) " + f"status={backend.status}" + ) + else: + rt.stack_status = f"error-rc{rc}" + log(f"stack boot-wait failed ({dev_id}) rc={rc}") + + +def poll_stacks(state: DaemonState) -> None: + for dev_id in enabled_device_ids(state): + poll_device_stack(state, dev_id) + retry_pending_backends(state) + + +def retry_pending_backends(state: DaemonState) -> None: + """Re-attach KISS PTY/serial when stack is up but the link was not ready yet.""" + for dev_id in enabled_device_ids(state): + if not backend_enabled(state, dev_id): + continue + rt = state.devices[dev_id] + if hybbx_host_hybbx_owns_serial(state, dev_id): + continue + # bcpr uses "running" until kiss open flips to "ready"; accept both. + if rt.stack_status not in ("ready", "stopped", "running"): + continue + if not backend_needs_open(rt.backend): + continue + if open_backend(state, dev_id): + attach_backend_session(state, dev_id) + if rt.stack_status == "running": + rt.stack_status = "ready" + + +def poll_reporting_passes(state: DaemonState) -> None: + """Advance timed data-quality pass windows (default 20s each, 3 passes).""" + now = time.time() + for rt in state.devices.values(): + _sync_quality_tracker(state, rt) + rt.quality.tick(now) + + +def poll_serial_stability(state: DaemonState) -> None: + """Periodic TNC health probe + software recovery (no power cycle).""" + cfg = state.cfg + if not cfg.serial_watch: + return + now = time.time() + if state.started_at and now - state.started_at < cfg.serial_watch_startup_grace: + return + for dev_id in enabled_device_ids(state): + if device_backend_kind(state, dev_id) != "kiss-serial": + continue + if hybbx_host_hybbx_owns_serial(state, dev_id): + continue + rt = state.devices[dev_id] + if rt.stack_proc is not None and rt.stack_proc.poll() is None: + continue + if ( + not uses_inline_tnc_prep(state, dev_id) + and cfg.stack_recover_only + and rt.stack_status.startswith("error") + and rt.stack_proc is None + and now - rt.last_stack_retry >= cfg.stack_retry_interval + ): + rt.last_stack_retry = now + log(f"serial watch: stack retry recover-only ({dev_id})") + start_device_stack(state, dev_id) + continue + if not backend_enabled(state, dev_id): + continue + backend = rt.backend + force = backend is not None and backend.status in SERIAL_REPAIR_STATUSES + due = now - rt.last_watch >= cfg.serial_watch_interval + if not force and not due: + continue + if backend is None: + if backend_needs_open(None): + open_backend(state, dev_id) + backend = rt.backend + if backend is None: + continue + if now - rt.last_repair < cfg.serial_repair_cooldown and not force: + continue + if backend.status == "ready" and not force: + if due: + rt.last_watch = now + continue + stabilize = getattr(backend, "stabilize_session", None) + if stabilize is None: + continue + rt.last_watch = now + rt.last_repair = now + ok = stabilize(state.cfg.callerid, force=force) + rt.link_status = backend.status + if ok: + rt.inline_repair_failures = 0 + if force: + log(f"serial watch: repaired ({dev_id})") + broadcast(state, f"EVENT device={dev_id} serial=ready") + else: + log(f"serial watch: repair failed ({dev_id}) status={backend.status}") + if ( + uses_inline_tnc_prep(state, dev_id) + and backend.status == "error-host" + and cfg.serial_bootwait_escalate + ): + rt.inline_repair_failures += 1 + if ( + rt.inline_repair_failures >= cfg.serial_bootwait_escalate_after + and now - rt.last_bootwait_escalate >= cfg.serial_bootwait_escalate_cooldown + ): + rt.last_bootwait_escalate = now + rt.inline_repair_failures = 0 + escalate_to_bootwait_stack(state, dev_id) + elif rt.inline_repair_failures >= cfg.serial_bootwait_escalate_after: + log( + f"serial watch: boot-wait escalate cooldown ({dev_id}) " + f"— manual: stacks/tncs/{dev_id}-boot-wait.sh" + ) + if backend.status in ("error-io", "error-open", "error-no-device"): + close_backend(state, dev_id) + if open_backend(state, dev_id) and state.connected: + attach_backend_session(state, dev_id) + + +def format_tx(state: DaemonState, text: str) -> str: + if state.cfg.ax25_ui: + return f"[AX25 UI {state.cfg.callerid}>{state.cfg.callid}] {text}" + return text + + +def resolve_selected_device(state: DaemonState) -> Optional[str]: + dev_id = state.selected_device + if dev_id in state.devices: + return dev_id + ids = enabled_device_ids(state) + return ids[0] if ids else None + + +def device_line(state: DaemonState, dev_id: str) -> str: + rt = state.devices[dev_id] + link = backend_serial_label(rt.backend) if rt.backend is not None else rt.link_status + hw = device_hardware(state, dev_id) + backend = rt.cfg.backend_type or "auto" + enabled = "yes" if rt.cfg.enabled else "no" + return ( + f"DEVICE id={dev_id} hardware={hw} backend={backend} serial={link} " + f"stack={rt.stack_status} enabled={enabled} " + f"error={device_reporting_error(state, rt)} " + f"voice={device_reporting_voice(state, rt)}" + ) + + +def handle_command(state: DaemonState, sock: socket.socket, line: str) -> None: + line = line.strip("\r\n") + if not line: + return + upper = line.upper() + + if upper == "PING": + send_line(sock, "OK") + return + + if upper == "GET STATUS": + send_line(sock, status_line(state)) + send_line(sock, "OK") + return + + if upper == "GET DEVICES": + for dev_id in sorted(state.devices): + send_line(sock, device_line(state, dev_id)) + send_line(sock, "OK") + return + + if upper.startswith("SET DEVICE ") or upper.startswith("SELECT DEVICE "): + prefix = "SET DEVICE " if upper.startswith("SET DEVICE ") else "SELECT DEVICE " + dev_id = line[len(prefix) :].strip() + if dev_id not in state.devices: + send_line(sock, f"ERR unknown device: {dev_id}") + return + state.selected_device = dev_id + state.cfg.device = dev_id + send_line(sock, "OK") + return + + if upper.startswith("SET CALLERID "): + value = line[13:].strip().upper() + if not valid_callsign(value): + send_line(sock, "ERR invalid CALLERID") + return + state.cfg.callerid = value + send_line(sock, "OK") + return + + if upper.startswith("SET CALLID "): + value = line[11:].strip().upper() + if not valid_callsign(value): + send_line(sock, "ERR invalid CALLID") + return + state.cfg.callid = value + send_line(sock, "OK") + return + + if upper.startswith("SET AX25_UI "): + flag = line[12:].strip().lower() + if flag in ("on", "yes", "1", "true"): + state.cfg.ax25_ui = True + elif flag in ("off", "no", "0", "false"): + state.cfg.ax25_ui = False + else: + send_line(sock, "ERR ax25_ui on|off") + return + send_line(sock, "OK") + return + + if upper == "CONNECT": + if not attach_all_sessions(state): + send_line(sock, "ERR link not ready") + return + state.connected = True + send_line(sock, "EVENT connected") + send_line(sock, "OK") + return + + if upper == "DISCONNECT": + detach_all_sessions(state) + state.connected = False + send_line(sock, "EVENT disconnected") + send_line(sock, "OK") + return + + if upper.startswith("MONITOR "): + flag = line[8:].strip().lower() + state.monitor_only = flag in ("on", "yes", "1", "true") + send_line(sock, "OK") + return + + if upper.startswith("BAN "): + value = line[4:].strip().upper() + if not valid_callsign(value): + send_line(sock, "ERR invalid callsign") + return + try: + state.bans.add(value) + except OSError as exc: + send_line(sock, f"ERR ban save failed: {exc}") + return + send_line(sock, "OK") + return + + if upper.startswith("UNBAN "): + value = line[6:].strip().upper() + if not valid_callsign(value): + send_line(sock, "ERR invalid callsign") + return + try: + if not state.bans.remove(value): + send_line(sock, "ERR not banned") + return + except OSError as exc: + send_line(sock, f"ERR ban save failed: {exc}") + return + send_line(sock, "OK") + return + + if upper == "BANS": + for entry in state.bans.list(): + send_line(sock, f"BAN {entry}") + send_line(sock, "OK") + return + + if upper.startswith("SEND "): + if state.monitor_only: + send_line(sock, "ERR monitor-only") + return + # UI frames: auto-arm session if stack/KISS is up. Terminal Enter/F10→SEND + # must key PTT/MCR without requiring a prior CONNECT (L4 writes kiss + # directly and already keys; unix SEND must match). + if not state.connected: + if not attach_all_sessions(state): + send_line(sock, "ERR not connected") + return + state.connected = True + send_line(sock, "EVENT connected") + dev_id = resolve_selected_device(state) + if dev_id is None: + send_line(sock, "ERR no device configured") + return + payload = line[5:] + framed = format_tx(state, payload) + rt = state.devices[dev_id] + if backend_enabled(state, dev_id): + if rt.backend is None or rt.backend.status != "ready": + # Re-attach after DISCONNECT left kiss inactive but stack ready. + if not attach_backend_session(state, dev_id): + send_line(sock, "ERR link not ready") + return + if rt.backend is None or rt.backend.status != "ready": + send_line(sock, "ERR link not ready") + return + ok, display = rt.backend.transmit( + state.cfg.callerid, + state.cfg.callid, + payload, + state.cfg.ax25_ui, + ) + if not ok and hasattr(rt.backend, "stabilize_session"): + log(f"serial watch: tx retry after repair ({dev_id})") + if rt.backend.stabilize_session(state.cfg.callerid, force=True): + rt.link_status = rt.backend.status + ok, display = rt.backend.transmit( + state.cfg.callerid, + state.cfg.callid, + payload, + state.cfg.ax25_ui, + ) + if not ok: + send_line(sock, f"ERR {display}") + return + framed = f"device={dev_id} {display}" + log(f"tx {dev_id}: {framed}") + send_line(sock, f"RX {framed}") + broadcast(state, f"RX {framed}", skip=sock) + send_line(sock, "OK") + return + + send_line(sock, f"ERR unknown command: {line.split()[0]}") + + +def tcp_auth_ok(sock: socket.socket, expected: str, timeout: float = 30.0) -> bool: + if not expected: + return True + send_line(sock, "AUTH required") + sock.settimeout(timeout) + buf = b"" + try: + while True: + try: + chunk = sock.recv(4096) + except socket.timeout: + return False + if not chunk: + return False + buf += chunk + if len(buf) > M25_MAX_LINE_BUF: + return False + while b"\n" in buf: + raw, buf = buf.split(b"\n", 1) + try: + line = raw.decode("utf-8").strip("\r") + except UnicodeDecodeError: + return False + if not line: + continue + if line.upper().startswith("AUTH "): + supplied = line[5:] + return supplied == expected + return False + finally: + sock.settimeout(300.0) + + +def client_thread(state: DaemonState, sock: socket.socket, from_tcp: bool) -> None: + sock.settimeout(300.0) + buf = b"" + try: + if from_tcp and state.cfg.tcp_password: + if not tcp_auth_ok(sock, state.cfg.tcp_password): + send_line(sock, "ERR auth failed") + return + send_line(sock, "OK") + send_line(sock, status_line(state)) + while True: + try: + chunk = sock.recv(4096) + except socket.timeout: + continue + if not chunk: + break + buf += chunk + if len(buf) > M25_MAX_LINE_BUF: + send_line(sock, "ERR line too long") + break + while b"\n" in buf: + raw, buf = buf.split(b"\n", 1) + try: + line = raw.decode("utf-8") + except UnicodeDecodeError: + send_line(sock, "ERR invalid utf-8") + continue + handle_command(state, sock, line) + except OSError: + pass + finally: + with state.lock: + state.clients.discard(sock) + try: + sock.close() + except OSError: + pass + + +def serve( + state: DaemonState, + listeners: list[tuple[socket.socket, bool, tuple[int, int] | None]], +) -> None: + running = True + + def on_signal(_signum, _frame): + nonlocal running + running = False + + signal.signal(signal.SIGTERM, on_signal) + signal.signal(signal.SIGINT, on_signal) + + if state.cfg.auto_start: + start_stacks(state) + + state.started_at = time.time() + + device_lines: list[tuple[str, str, str]] = [] + for dev_id in enabled_device_ids(state): + rt = state.devices[dev_id] + link = backend_serial_label(rt.backend) if rt.backend is not None else rt.link_status + device_lines.append((dev_id, rt.stack_status, link)) + + emit_startup_complete( + device_lines=device_lines, + tcp_host=state.cfg.tcp_host, + tcp_port=state.cfg.tcp_port, + unix_socket=state.cfg.unix_socket, + ) + + drop_privileges_or_exit( + RunAsConfig( + user=state.cfg.run_user, + group=state.cfg.run_group, + uid=state.cfg.run_uid, + gid=state.cfg.run_gid, + ), + log=lambda msg, area="privilege": LOGGER.info(msg, area=area), + ) + + while running: + poll_stacks(state) + poll_tot_trips(state) + poll_tot_recovery(state) + poll_reporting_passes(state) + poll_serial_stability(state) + socks = [lsock for lsock, _tcp, _bid in listeners] + rlist, _, _ = select.select(socks, [], [], 1.0) + for lsock, from_tcp, _bid in listeners: + if lsock not in rlist: + continue + try: + client, _addr = lsock.accept() + except OSError: + continue + client.setblocking(True) + with state.lock: + state.clients.add(client) + threading.Thread( + target=client_thread, + args=(state, client, from_tcp), + daemon=True, + ).start() + + stop_stacks(state) + for lsock, from_tcp, bind_id in listeners: + if not from_tcp and state.cfg.unix_socket: + unlink_unix_if_ours(state.cfg.unix_socket, bind_id) + lsock.close() + + +def make_listeners( + cfg: DaemonConfig, +) -> list[tuple[socket.socket, bool, tuple[int, int] | None]]: + listeners: list[tuple[socket.socket, bool, tuple[int, int] | None]] = [] + + tcp = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + tcp.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + tcp.bind((cfg.tcp_host, cfg.tcp_port)) + except OSError as exc: + tcp.close() + log(f"TCP bind {cfg.tcp_host}:{cfg.tcp_port} failed: {exc}") + raise SystemExit(1) from exc + tcp.listen(32) + tcp.setblocking(False) + listeners.append((tcp, True, None)) + + if cfg.unix_socket: + sock_path = Path(cfg.unix_socket) + try: + sock_path.parent.mkdir(parents=True, exist_ok=True) + except OSError: + fallback = Path("/tmp/max25/modem.sock") + log(f"unix {cfg.unix_socket} unavailable, using {fallback}") + cfg.unix_socket = str(fallback) + sock_path = fallback + try: + sock_path.parent.mkdir(parents=True, exist_ok=True) + except OSError: + log("unix socket disabled (no writable path)") + cfg.unix_socket = "" + return listeners + # Never unlink a live peer path — that orphans the other max25d FD + # (ss still shows the name; clients get ENOENT). + if unix_path_is_live(cfg.unix_socket): + log( + f"unix socket {cfg.unix_socket} already live — not stealing " + "(refuse second max25d unix bind)" + ) + else: + try: + os.unlink(cfg.unix_socket) + except FileNotFoundError: + pass + except OSError: + pass + unix = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + unix.bind(cfg.unix_socket) + except OSError as exc: + log(f"unix socket {cfg.unix_socket} skipped ({exc})") + unix.close() + else: + try: + os.chmod(cfg.unix_socket, 0o660) + except OSError: + pass + unix.listen(32) + unix.setblocking(False) + listeners.append((unix, False, unix_path_id(cfg.unix_socket))) + + return listeners + + +def main(argv: Optional[list[str]] = None) -> int: + if not max25d_supported(): + log(f"max25d is not supported on {sys.platform}") + return 1 + + parser = argparse.ArgumentParser(description=f"MAX25 daemon ({platform_label()})") + parser.add_argument( + "-c", + "--config", + type=Path, + default=None, + help="Path to max25d.ini", + ) + parser.add_argument( + "--no-stack", + action="store_true", + help="Do not auto-start hardware stack", + ) + parser.add_argument( + "--tcp-port", + type=int, + default=None, + help="Override TCP listen port", + ) + parser.add_argument( + "--no-serial", + action="store_true", + help="Disable KISS serial bridge (loopback SEND only)", + ) + parser.add_argument( + "--session", + choices=("tmux", "screen"), + metavar="BACKEND", + help="Re-exec via max25d-session (detach in tmux/screen); use max25d-session attach", + ) + args = parser.parse_args(argv) + + if args.session: + session_sh = ROOT / "scripts" / "max25d-session.sh" + if not session_sh.is_file(): + session_sh = Path(PREFIX) / "bin" / "max25d-session" if PREFIX else session_sh + if not session_sh.is_file(): + LOGGER.error( + "max25d-session not found — install scripts/max25d-session.sh or use tmux/screen manually", + area="session", + ) + return 1 + cmd = [str(session_sh), "start", f"--{args.session}"] + if args.config: + cmd.extend(["-c", str(args.config)]) + os.execv(cmd[0], cmd) + + cfg = load_config(args.config) + if args.no_stack: + cfg.auto_start = False + if args.tcp_port is not None: + cfg.tcp_port = args.tcp_port + if args.no_serial: + cfg.serial_enabled = False + enforce_lite_localhost(cfg) + + if cfg.modular_tcp.enabled and cfg.modular_tcp.role == "main": + svc = ModularTcpMainService(cfg.modular_tcp, cfg.tcp_host, cfg.tcp_port, log) + svc.start() + log( + f"modular TCP/IP Servers Service — Main '{cfg.modular_tcp.service_name}' " + f"({len(cfg.modular_tcp.secondaries)} secondaries)" + ) + try: + while True: + time.sleep(1.0) + except KeyboardInterrupt: + pass + finally: + svc.stop() + return 0 + + state = DaemonState(cfg=cfg, bans=BanList(cfg.bans_file)) + init_device_runtimes(state) + emit_startup_banner( + config_path=cfg.config_path or None, + cfg=cfg, + devices=cfg.devices, + tested_fn=registry_tested, + ) + listeners = make_listeners(cfg) + try: + serve(state, listeners) + except KeyboardInterrupt: + stop_stacks(state) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/stacks/daemon/modular_tcp_server.py b/stacks/daemon/modular_tcp_server.py new file mode 100644 index 0000000..b7f5612 --- /dev/null +++ b/stacks/daemon/modular_tcp_server.py @@ -0,0 +1,208 @@ +""" +Modular TCP/IP Servers Service — central Main + Secondary routing for MAX25. + +Public name in docs: "modular TCP/IP Servers Service". +Main instance accepts M25/1 clients and forwards sessions to Secondary max25d peers. +""" +from __future__ import annotations + +import configparser +import selectors +import socket +import threading +from dataclasses import dataclass, field +from typing import Callable, Optional + + +LogFn = Callable[[str], None] + + +@dataclass +class SecondaryEndpoint: + name: str + host: str + port: int + + +@dataclass +class ModularTcpConfig: + enabled: bool = False + role: str = "standalone" # standalone | main | secondary + service_name: str = "modular-tcp-server" + instance_id: str = "" + secondaries: list[SecondaryEndpoint] = field(default_factory=list) + connect_timeout: float = 5.0 + buffer_size: int = 65536 + + +def load_modular_tcp(cp: configparser.ConfigParser) -> ModularTcpConfig: + cfg = ModularTcpConfig() + if not cp.has_section("modular_tcp"): + return cfg + sec = cp["modular_tcp"] + cfg.enabled = sec.get("enabled", "no").strip().lower() in ("1", "yes", "true", "on") + cfg.role = sec.get("role", cfg.role).strip().lower() or cfg.role + cfg.service_name = sec.get("service_name", cfg.service_name).strip() or cfg.service_name + cfg.instance_id = sec.get("instance_id", cfg.instance_id).strip() + if cp.has_option("modular_tcp", "connect_timeout"): + try: + cfg.connect_timeout = float(cp.get("modular_tcp", "connect_timeout")) + except (TypeError, ValueError): + cfg.connect_timeout = 5.0 + if cfg.connect_timeout <= 0.0: + cfg.connect_timeout = 5.0 + if cp.has_section("modular_tcp.secondaries"): + for key in cp.options("modular_tcp.secondaries"): + raw = cp.get("modular_tcp.secondaries", key).strip() + if not raw: + continue + host, _, port_s = raw.partition(":") + host = host.strip() + if not host: + continue + try: + port = int(port_s or "7326") + except (TypeError, ValueError): + port = 7326 + if port < 1 or port > 65535: + port = 7326 + cfg.secondaries.append(SecondaryEndpoint(name=key.strip(), host=host, port=port)) + if cfg.enabled and cfg.role == "main" and not cfg.secondaries: + # Legacy comma list: secondaries = host:port,host:port + raw = sec.get("secondaries", "").strip() + if raw: + for idx, item in enumerate(x.strip() for x in raw.split(",") if x.strip()): + host, _, port_s = item.partition(":") + host = host.strip() + if not host: + continue + try: + port = int(port_s or "7326") + except (TypeError, ValueError): + port = 7326 + if port < 1 or port > 65535: + port = 7326 + cfg.secondaries.append( + SecondaryEndpoint( + name=f"secondary-{idx + 1}", + host=host, + port=port, + ) + ) + return cfg + + +class ModularTcpMainService: + """Bidirectional relay: operator client <-> Secondary max25d M25/1.""" + + def __init__( + self, + cfg: ModularTcpConfig, + listen_host: str, + listen_port: int, + log: LogFn, + ) -> None: + self.cfg = cfg + self.listen_host = listen_host + self.listen_port = listen_port + self.log = log + self._stop = threading.Event() + self._thread: Optional[threading.Thread] = None + self._rr_index = 0 + self._lock = threading.Lock() + + def _pick_secondary(self) -> Optional[SecondaryEndpoint]: + with self._lock: + if not self.cfg.secondaries: + return None + ep = self.cfg.secondaries[self._rr_index % len(self.cfg.secondaries)] + self._rr_index += 1 + return ep + + def _relay(self, client: socket.socket, upstream: socket.socket) -> None: + sel = selectors.DefaultSelector() + sel.register(client, selectors.EVENT_READ) + sel.register(upstream, selectors.EVENT_READ) + try: + while not self._stop.is_set(): + for key, _ in sel.select(timeout=0.5): + sock = key.fileobj + assert isinstance(sock, socket.socket) + try: + data = sock.recv(self.cfg.buffer_size) + except OSError: + return + if not data: + return + target = upstream if sock is client else client + try: + target.sendall(data) + except OSError: + return + finally: + sel.close() + + def _handle_client(self, client: socket.socket, addr: tuple[str, int]) -> None: + ep = self._pick_secondary() + if ep is None: + self.log(f"modular_tcp: no secondaries configured — drop {addr}") + client.close() + return + upstream = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + upstream.settimeout(self.cfg.connect_timeout) + try: + upstream.connect((ep.host, ep.port)) + except OSError as exc: + self.log(f"modular_tcp: upstream {ep.name} {ep.host}:{ep.port} failed ({exc})") + client.close() + return + upstream.settimeout(None) + self.log(f"modular_tcp: {addr} -> {ep.name} {ep.host}:{ep.port}") + try: + self._relay(client, upstream) + finally: + for s in (client, upstream): + try: + s.shutdown(socket.SHUT_RDWR) + except OSError: + pass + s.close() + + def _serve(self) -> None: + srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv.bind((self.listen_host, self.listen_port)) + srv.listen(64) + srv.settimeout(1.0) + self.log( + f"modular_tcp: Main service '{self.cfg.service_name}' " + f"listen {self.listen_host}:{self.listen_port} " + f"secondaries={len(self.cfg.secondaries)}" + ) + try: + while not self._stop.is_set(): + try: + client, addr = srv.accept() + except socket.timeout: + continue + except OSError: + break + threading.Thread( + target=self._handle_client, + args=(client, addr), + daemon=True, + ).start() + finally: + srv.close() + + def start(self) -> None: + if self._thread is not None: + return + self._thread = threading.Thread(target=self._serve, name="modular-tcp-main", daemon=True) + self._thread.start() + + def stop(self) -> None: + self._stop.set() + if self._thread is not None: + self._thread.join(timeout=3.0) + self._thread = None diff --git a/stacks/daemon/paths.py b/stacks/daemon/paths.py new file mode 100644 index 0000000..62d4974 --- /dev/null +++ b/stacks/daemon/paths.py @@ -0,0 +1,281 @@ +"""MAX25 layout detection — dev checkout vs cmake install prefix.""" +from __future__ import annotations + +import configparser +import os +import sys +from pathlib import Path + +MAX25_BCPR_KISS_DEFAULT = "/tmp/max25-bcpr/kiss-bc0" + + +def normalize_max25_bcpr_path(path: str) -> str: + """Map legacy /tmp/bcpr and /var/run/bcpr paths to canonical max25-bcpr layout.""" + p = (path or "").strip() + if not p: + return p + for old, new in ( + ("/tmp/bcpr/", "/tmp/max25-bcpr/"), + ("/tmp/bcpr", "/tmp/max25-bcpr"), + ("/var/run/bcpr/", "/tmp/max25-bcpr/"), + ("/var/run/bcpr", "/tmp/max25-bcpr"), + ): + if p == old: + return new + if p.startswith(old + "/") or (old.endswith("/") and p.startswith(old)): + return new + p[len(old) :] + return p + + +_MANIFEST = Path("plugins/manifest.yaml") +_SHARE_MAX25 = Path("share/max25") +_STACKS = Path("stacks") + + +def _repo_from_daemon_dir(here: Path) -> Path | None: + if here.name == "daemon" and (here.parent / "tncs").is_dir(): + repo = here.parent.parent + if (repo / _MANIFEST).is_file(): + return repo + return None + + +def _prefix_from_bindir(bindir: Path) -> Path | None: + prefix = bindir.parent + if (prefix / _SHARE_MAX25).is_dir() and not (prefix / _MANIFEST).is_file(): + return prefix + return None + + +def resolve_layout(exe: Path) -> tuple[Path, Path | None]: + """Return (tree_root, install_prefix). + + tree_root is the repo root in dev, or MAX25_ROOT / prefix when installed. + install_prefix is set when exe lives under PREFIX/bin, else None. + """ + exe = exe.resolve() + here = exe.parent + + repo = _repo_from_daemon_dir(here) + if repo is not None: + return repo, None + + prefix = _prefix_from_bindir(here) + if prefix is not None: + tree = Path(os.environ.get("MAX25_ROOT", str(prefix))) + return tree, prefix + + if (here.parent / _MANIFEST).is_file(): + return here.parent, None + + return exe.parents[2], None + + +def share_max25_dir(tree: Path, prefix: Path | None) -> Path: + if prefix is not None: + share = prefix / _SHARE_MAX25 + if share.is_dir(): + return share + return tree / _SHARE_MAX25 + + +def stacks_dir(tree: Path, prefix: Path | None) -> Path: + tree_stacks = tree / _STACKS + if tree_stacks.is_dir(): + return tree_stacks + if prefix is not None: + installed = prefix / _SHARE_MAX25 / _STACKS + if installed.is_dir(): + return installed + return tree_stacks + + +def tnc_serial_recovery_path(tree: Path, prefix: Path | None) -> Path | None: + """Locate tnc_serial_recovery.py (dev checkout or cmake install).""" + for candidate in ( + stacks_dir(tree, prefix) / "tncs" / "tnc_serial_recovery.py", + tree / _STACKS / "tncs" / "tnc_serial_recovery.py", + ): + if candidate.is_file(): + return candidate + return None + + +def ctl_path(tree: Path, prefix: Path | None, exe: Path) -> Path: + if prefix is not None: + sibling = exe.resolve().parent / "max25-ctl" + if sibling.is_file(): + return sibling + script = tree / "scripts" / "max25-ctl" + if script.is_file(): + return script + if prefix is not None: + return prefix / "bin" / "max25-ctl" + return script + + +def default_ini_candidates(tree: Path, prefix: Path | None) -> list[Path]: + candidates: list[Path] = [] + env_ini = os.environ.get("MAX25D_INI", "").strip() + if env_ini: + candidates.append(Path(env_ini)) + candidates.append(Path("/etc/max25/max25d.ini")) + candidates.append(share_max25_dir(tree, prefix) / "max25d.ini.example") + if sys.platform.startswith("freebsd"): + candidates.append(share_max25_dir(tree, prefix) / "max25d.freebsd.ini.example") + if prefix is None or tree != prefix: + candidates.append(tree / _SHARE_MAX25 / "max25d.ini.example") + return candidates + + +def serial_env_candidates(device_id: str, tree: Path, prefix: Path | None) -> list[Path]: + paths: list[Path] = [Path(f"/etc/max25/{device_id}-serial.env")] + env_root = os.environ.get("MAX25_ROOT", "").strip() + if env_root: + paths.append(Path(env_root) / "local" / f"{device_id}-serial.env") + paths.append(Path(env_root) / _STACKS / "tncs" / f"{device_id}-serial.env") + cwd = os.environ.get("PWD", "").strip() + if cwd: + paths.append(Path(cwd) / "local" / f"{device_id}-serial.env") + if prefix is not None: + paths.append(prefix / _SHARE_MAX25 / "serial" / f"{device_id}-serial.env") + paths.append(prefix / _SHARE_MAX25 / _STACKS / "tncs" / f"{device_id}-serial.env") + paths.append(tree / "local" / f"{device_id}-serial.env") + paths.append(tree / _STACKS / "tncs" / f"{device_id}-serial.env") + return paths + + +def baycom_share_dir(tree: Path, prefix: Path | None) -> Path: + if prefix is not None: + installed = prefix / _SHARE_MAX25 / "baycom" + if installed.is_dir(): + return installed + return tree / "share" / "baycom" + + +_SITE_BAYCOM_INI = Path("/etc/baycom/baycom-pr.ini") +_SINGLE_SER12_EXAMPLE = "baycom-pr.pccom-ttyS0-only.ini.example" + + +def is_dual_baycom_ini(path: Path) -> bool: + """True when profile lists more than one modem or name is 'dual'.""" + if not path.is_file(): + return False + cp = configparser.ConfigParser() + try: + cp.read(path, encoding="utf-8") + except OSError: + return False + if "profile" not in cp: + return False + prof = cp["profile"] + name = prof.get("name", "").strip().lower() + modem_ids = [x.strip() for x in prof.get("modems", "").split(",") if x.strip()] + if len(modem_ids) > 1: + return True + return name == "dual" + + +def canonical_dual_baycom_example( + device_id: str, tree: Path, prefix: Path | None +) -> Path | None: + """Shipped dual-modem template for kernel-ser12 service mode.""" + if device_id not in ("baycom-ser12", "baycom-par96"): + return None + for candidate in ( + tree / _STACKS / "baycom-pr" / "config" / "examples" / "baycom-pr.dual.ini", + stacks_dir(tree, prefix) / "baycom-pr" / "config" / "examples" / "baycom-pr.dual.ini", + ): + if candidate.is_file(): + return candidate + site = _SITE_BAYCOM_INI + if site.is_file() and is_dual_baycom_ini(site): + return site + return None + + +def canonical_single_baycom_example( + device_id: str, tree: Path, prefix: Path | None +) -> Path | None: + """Shipped single-modem template for kernel BayCom devices.""" + share = baycom_share_dir(tree, prefix) + if device_id == "baycom-par96": + for candidate in ( + share / "baycom-pr.par96-single.ini.example", + tree / _STACKS / "baycom-pr" / "config" / "examples" / "baycom-pr.par96.ini", + stacks_dir(tree, prefix) / "baycom-pr" / "config" / "examples" / "baycom-pr.par96.ini", + ): + if candidate.is_file(): + return candidate + return None + ser12 = share / _SINGLE_SER12_EXAMPLE + if ser12.is_file(): + return ser12 + return None + + +def baycom_ini_candidates(device_id: str, tree: Path, prefix: Path | None) -> list[Path]: + """Search order for baycom-pr.ini (local → site single → shipped example).""" + paths: list[Path] = [] + env_ini = os.environ.get("BAYCOM_INI", "").strip() + if env_ini: + paths.append(Path(env_ini)) + env_root = os.environ.get("MAX25_ROOT", "").strip() + if env_root: + paths.append(Path(env_root) / "local" / "baycom-pr.ini") + cwd = os.environ.get("PWD", "").strip() + if cwd: + paths.append(Path(cwd) / "local" / "baycom-pr.ini") + paths.append(tree / "local" / "baycom-pr.ini") + + # Site INI before shipped example so operator edits in /etc win when single-modem. + paths.append(_SITE_BAYCOM_INI) + paths.append(Path("/etc/baycom/baycom-pr-single.ini")) + + canonical = canonical_single_baycom_example(device_id, tree, prefix) + if canonical is not None: + paths.append(canonical) + + if prefix is not None: + paths.append(prefix / _SHARE_MAX25 / "baycom" / _SINGLE_SER12_EXAMPLE) + paths.append(prefix / "etc" / "baycom" / "baycom-pr.ini.example") + + paths.append(tree / _STACKS / "baycom-pr" / "config" / "baycom-pr.ini") + return paths + + +def resolve_baycom_profile( + profile: str, + device_id: str, + tree: Path, + prefix: Path | None, +) -> Path | None: + """Resolve named profile: single (default) or dual (service mode).""" + name = profile.strip().lower() + if name == "dual": + return canonical_dual_baycom_example(device_id, tree, prefix) + if name in ("single", "default", ""): + return resolve_baycom_ini(device_id, tree, prefix) + return None + + +def resolve_baycom_ini( + device_id: str, + tree: Path, + prefix: Path | None, + explicit: str = "", +) -> Path | None: + """Return first existing baycom-pr.ini path, or None. + + Skips dual-modem /etc/baycom/baycom-pr.ini unless explicit (or env BAYCOM_INI). + """ + if explicit: + path = Path(explicit) + return path if path.is_file() else None + for candidate in baycom_ini_candidates(device_id, tree, prefix): + if not candidate.is_file(): + continue + if candidate == _SITE_BAYCOM_INI and is_dual_baycom_ini(candidate): + continue + return candidate + return None diff --git a/stacks/daemon/privilege_drop.py b/stacks/daemon/privilege_drop.py new file mode 100644 index 0000000..5e68ca8 --- /dev/null +++ b/stacks/daemon/privilege_drop.py @@ -0,0 +1,110 @@ +"""Drop max25d privileges after hardware stack is up (Linux).""" +from __future__ import annotations + +import configparser +import grp +import os +import pwd +import sys +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class RunAsConfig: + user: str = "" + group: str = "" + uid: Optional[int] = None + gid: Optional[int] = None + + +def _parse_optional_int(raw: str) -> Optional[int]: + text = str(raw).strip() + if not text: + return None + return int(text) + + +def parse_run_as(cp: configparser.ConfigParser) -> RunAsConfig: + cfg = RunAsConfig() + if not cp.has_section("daemon"): + return cfg + sec = cp["daemon"] + cfg.user = sec.get("user", fallback="").strip() + cfg.group = sec.get("group", fallback="").strip() + if cp.has_option("daemon", "uid"): + cfg.uid = _parse_optional_int(sec.get("uid", fallback="")) + if cp.has_option("daemon", "gid"): + cfg.gid = _parse_optional_int(sec.get("gid", fallback="")) + return cfg + + +def run_as_configured(cfg: RunAsConfig) -> bool: + return bool( + cfg.user + or cfg.group + or cfg.uid is not None + or cfg.gid is not None + ) + + +def resolve_run_as(cfg: RunAsConfig) -> tuple[int, int, str]: + pw: pwd.struct_passwd | None = None + name = cfg.user + + if cfg.user: + pw = pwd.getpwnam(cfg.user) + uid = pw.pw_uid + gid = pw.pw_gid + elif cfg.uid is not None: + pw = pwd.getpwuid(cfg.uid) + uid = pw.pw_uid + gid = pw.pw_gid + name = pw.pw_name + else: + raise ValueError("[daemon] user= or uid= required for privilege drop") + + if cfg.uid is not None: + uid = cfg.uid + if cfg.group: + gid = grp.getgrnam(cfg.group).gr_gid + elif cfg.gid is not None: + gid = cfg.gid + + return uid, gid, name + + +def drop_privileges(cfg: RunAsConfig, *, log) -> None: + if os.name != "posix": + return + if not run_as_configured(cfg): + if os.geteuid() == 0: + log( + "running as root after stack start — set [daemon] user= to drop", + area="privilege", + ) + return + if os.geteuid() != 0: + return + + uid, gid, name = resolve_run_as(cfg) + + os.initgroups(name, gid) + os.setgid(gid) + os.setuid(uid) + + if os.geteuid() == 0: + raise OSError("privilege drop failed — still root") + + log( + f"dropped privileges to uid={uid} gid={gid} ({name})", + area="privilege", + ) + + +def drop_privileges_or_exit(cfg: RunAsConfig, *, log) -> None: + try: + drop_privileges(cfg, log=log) + except (KeyError, ValueError, OSError) as exc: + log(f"privilege drop failed: {exc}", area="privilege") + raise SystemExit(1) from exc diff --git a/stacks/daemon/reporting_quality.py b/stacks/daemon/reporting_quality.py new file mode 100644 index 0000000..f755f08 --- /dev/null +++ b/stacks/daemon/reporting_quality.py @@ -0,0 +1,178 @@ +"""RX data-quality window for max25d STATUS error=/voice= reporting.""" +from __future__ import annotations + +import re +import time +from collections import deque +from dataclasses import dataclass, field +from enum import Enum +from typing import Deque, Optional + +_AX25_UI_LINE_RE = re.compile( + r"^\[(?:CRDOP )?AX25 UI ([^>]+)>([^\]]+)\]\s?(.*)$", + re.DOTALL, +) +_AX25_UI_PARTIAL_RE = re.compile(r"\[AX25 UI ", re.IGNORECASE) +_VOICE_NOISE_LINE_RE = re.compile( + r"^\[(?:CRDOP RX|AUDIO RX|VOICE)[^\]]*\]", + re.IGNORECASE, +) + + +class RxOutcome(str, Enum): + GOOD = "good" + BAD = "bad" + IGNORE = "ignore" + + +def classify_rx_line(line: str, callid: str) -> RxOutcome: + """ + Classify an RX display line for data-quality tracking. + + - GOOD: complete AX.25 UI frame whose destination matches configured CALLID. + - BAD: incomplete AX.25 UI or wrong CALLID (incomplete data counts as error). + - IGNORE: voice/noise/interference — excluded from data pass statistics. + """ + text = line.strip() + if not text: + return RxOutcome.IGNORE + + match = _AX25_UI_LINE_RE.match(text) + if match: + dest = match.group(2).strip().upper() + expected = callid.strip().upper() + if not expected or dest != expected: + return RxOutcome.BAD + return RxOutcome.GOOD + + if _AX25_UI_PARTIAL_RE.search(text): + return RxOutcome.BAD + + if _VOICE_NOISE_LINE_RE.match(text): + return RxOutcome.IGNORE + + return RxOutcome.IGNORE + + +@dataclass +class _PassWindow: + good: int = 0 + bad: int = 0 + ignored: bool = False + started_at: float = 0.0 + + +@dataclass +class DataQualityTracker: + """ + Rolling pass window: each pass lasts up to pass_window_sec (default 20s). + passes_required consecutive passes (default 3) form one evaluation cycle + (3 × max 20s = up to 60s). Voice/noise does not fill a pass slot. + """ + + passes_required: int = 3 + min_good_percent: int = 50 + pass_window_sec: int = 20 + passes: Deque[str] = field(default_factory=deque) + voice_activity: bool = False + current: _PassWindow = field(default_factory=_PassWindow) + + def __post_init__(self) -> None: + if self.passes_required < 1: + self.passes_required = 1 + if self.min_good_percent < 1: + self.min_good_percent = 1 + elif self.min_good_percent > 100: + self.min_good_percent = 100 + if self.pass_window_sec < 1: + self.pass_window_sec = 1 + elif self.pass_window_sec > 300: + self.pass_window_sec = 300 + self.passes = deque(maxlen=self.passes_required) + + def _now(self, now: Optional[float]) -> float: + return time.time() if now is None else now + + def _ensure_window(self, now: float) -> None: + if self.current.started_at == 0.0: + self.current.started_at = now + + def _pass_outcome_from_counts(self, good: int, bad: int) -> str: + if bad > 0: + return "bad" + if good > 0: + return "good" + return "bad" + + def _finalize_current_pass(self) -> None: + good = self.current.good + bad = self.current.bad + if good == 0 and bad == 0: + if self.current.ignored: + return + self.passes.append("bad") + return + self.passes.append(self._pass_outcome_from_counts(good, bad)) + + def _roll_window(self, now: float) -> None: + self._finalize_current_pass() + self.current = _PassWindow(started_at=now) + + def tick(self, now: Optional[float] = None) -> None: + """Expire the active pass when pass_window_sec elapsed.""" + ts = self._now(now) + if self.current.started_at == 0.0: + return + if ts - self.current.started_at < self.pass_window_sec: + return + self._roll_window(ts) + + def record(self, outcome: RxOutcome, now: Optional[float] = None) -> None: + ts = self._now(now) + self.tick(ts) + self._ensure_window(ts) + + if outcome == RxOutcome.IGNORE: + self.voice_activity = True + self.current.ignored = True + return + if outcome == RxOutcome.GOOD: + self.current.good += 1 + elif outcome == RxOutcome.BAD: + self.current.bad += 1 + + def record_bad(self, now: Optional[float] = None) -> None: + self.record(RxOutcome.BAD, now) + + def good_ratio_percent(self) -> Optional[int]: + if len(self.passes) < self.passes_required: + return None + good = sum(1 for item in self.passes if item == "good") + total = len(self.passes) + if total == 0: + return None + return (100 * good) // total + + def data_error_valid(self, *, reporting_enabled: bool) -> bool: + if not reporting_enabled: + return False + ratio = self.good_ratio_percent() + if ratio is None: + return False + return ratio >= self.min_good_percent + + def voice_signal_valid(self, *, reporting_enabled: bool, link_healthy: bool) -> bool: + """ + Voice/noise/disturbance path: when link is healthy and voice activity was + seen (non-data RX), treat as 100% valid even if the content was noise only. + """ + if not reporting_enabled: + return False + if not link_healthy: + return False + if self.voice_activity: + return True + return True + + def max_cycle_seconds(self) -> int: + return self.passes_required * self.pass_window_sec diff --git a/stacks/daemon/test_audio_dummy_backend.py b/stacks/daemon/test_audio_dummy_backend.py new file mode 100644 index 0000000..c5ba31d --- /dev/null +++ b/stacks/daemon/test_audio_dummy_backend.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Acoustic engine loopback + audio-dummy backend smoke tests.""" +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import MagicMock + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "stacks" / "daemon")) +sys.path.insert(0, str(ROOT / "stacks" / "crdop" / "lib")) + +from acoustic_engine import AcousticEngine # noqa: E402 +from device_backends import AudioDummyBackend, DeviceBackendConfig, registry_tested # noqa: E402 + + +def test_loopback_decode() -> None: + eng = AcousticEngine() + rep = eng.loopback_self_test("TST-0", "QST") + assert rep.symbols > 0 + assert rep.transitions > 0 + + +def test_audio_dummy_backend_loopback_tx() -> None: + cfg = DeviceBackendConfig(device_id="audio-dummy", backend_type="audio-dummy", audio_mode="loopback") + rx: list[str] = [] + backend = AudioDummyBackend(cfg, on_rx=rx.append) + assert backend.open() + assert backend.attach_session("TST-0") + ok, display = backend.transmit("TST-0", "QST", "hi", ax25_ui=True) + assert ok + assert "QST" in display + backend.close() + + +def test_registry() -> None: + assert registry_tested("audio-dummy") is True + + +def main() -> int: + test_loopback_decode() + test_audio_dummy_backend_loopback_tx() + test_registry() + print("OK: audio-dummy tests") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/stacks/daemon/test_auth.py b/stacks/daemon/test_auth.py new file mode 100644 index 0000000..263a2e5 --- /dev/null +++ b/stacks/daemon/test_auth.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""TCP auth smoke test for max25d M25/1.""" +import socket +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +DAEMON = ROOT / "stacks/daemon/max25d" +BASE_INI = ROOT / "share/max25/max25d.ini.example" + + +class LineReader: + def __init__(self, sock: socket.socket) -> None: + self.sock = sock + self.buf = b"" + + def read(self, timeout: float = 5.0) -> str: + self.sock.settimeout(timeout) + while b"\n" not in self.buf: + chunk = self.sock.recv(4096) + if not chunk: + raise RuntimeError("connection closed") + self.buf += chunk + line, self.buf = self.buf.split(b"\n", 1) + return line.decode("utf-8") + + +def free_port() -> int: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + sock.close() + return port + + +def main() -> int: + port = free_port() + password = "preview-secret" + + with tempfile.NamedTemporaryFile("w", suffix=".ini", delete=False) as tmp: + tmp.write(BASE_INI.read_text()) + tmp.write(f"\n[network]\ntcp_password = {password}\n") + ini_path = Path(tmp.name) + + proc = subprocess.Popen( + [str(DAEMON), "--no-stack", "-c", str(ini_path), "--tcp-port", str(port)], + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + ) + time.sleep(0.6) + try: + sock = socket.create_connection(("127.0.0.1", port), timeout=3) + except OSError as exc: + proc.terminate() + proc.wait(timeout=3) + print(f"FAIL: connect: {exc}", file=sys.stderr) + return 1 + + reader = LineReader(sock) + try: + assert reader.read() == "AUTH required" + + sock.sendall(b"AUTH wrong\n") + assert reader.read() == "ERR auth failed" + sock.close() + + sock = socket.create_connection(("127.0.0.1", port), timeout=3) + reader = LineReader(sock) + assert reader.read() == "AUTH required" + sock.sendall(f"AUTH {password}\n".encode()) + assert reader.read() == "OK" + status = reader.read() + assert status.startswith("STATUS ") + + sock.sendall(b"PING\n") + assert reader.read() == "OK" + + print("OK: max25d TCP auth smoke") + return 0 + finally: + try: + sock.close() + except OSError: + pass + proc.terminate() + proc.wait(timeout=5) + ini_path.unlink(missing_ok=True) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/stacks/daemon/test_ax25_codec.py b/stacks/daemon/test_ax25_codec.py new file mode 100644 index 0000000..b12e43d --- /dev/null +++ b/stacks/daemon/test_ax25_codec.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Unit tests for ax25_codec (RFC 1171 FCS + libax25 address rules).""" +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from ax25_codec import ( # noqa: E402 + ax25_build_ui, + ax25_crc, + ax25_crc_valid, + ax25_encode_address, + ax25_parse_ui, + format_callsign, + parse_callsign, + validate_callsign, +) + + +def test_rfc1171_crc_vector() -> None: + body = bytes(range(256)) + crc = ax25_crc(body) + frame = body + bytes((crc & 0xFF, crc >> 8)) + assert ax25_crc_valid(frame) + assert crc == 0x303C + + +def test_address_roundtrip() -> None: + raw = ax25_encode_address("DG1ABC", 7, last=True) + frame = ax25_build_ui("DG1ABC-7", "CQ", b"73") + parsed = ax25_parse_ui(frame) + assert parsed == ("DG1ABC-7", "CQ", b"73") + + +def test_format_callsign_omit_zero_ssid() -> None: + assert format_callsign("DG1ABC", 0) == "DG1ABC" + assert format_callsign("CB", 0) == "CB" + + +def test_parse_callsign_legacy() -> None: + assert parse_callsign("DK0WC-7") == ("DK0WC", 7) + assert parse_callsign("bad-ssid") == ("BAD", 0) + + +def test_validate_callsign_strict() -> None: + assert validate_callsign("CB-0") == ("CB", 0) + assert validate_callsign("N0CALL-15") == ("N0CALL", 15) + try: + validate_callsign("CB_0") + raise AssertionError("expected ValueError") + except ValueError: + pass + + +def test_ui_frame_without_fcs() -> None: + frame = ax25_build_ui("DG1ABC", "QST", b"hi") + body = frame[:-2] + parsed = ax25_parse_ui(body) + assert parsed == ("DG1ABC", "QST", b"hi") + + +def test_invalid_callsign_build() -> None: + try: + ax25_build_ui("BAD_CALL", "CQ", b"x") + raise AssertionError("expected ValueError") + except ValueError: + pass + + +def test_non_ui_rejected() -> None: + frame = ax25_build_ui("A", "B", b"x") + frame = bytearray(frame) + frame[14] = 0x00 # not UI control + assert ax25_parse_ui(bytes(frame)) is None + + +def main() -> int: + tests = [ + test_rfc1171_crc_vector, + test_address_roundtrip, + test_format_callsign_omit_zero_ssid, + test_parse_callsign_legacy, + test_validate_callsign_strict, + test_ui_frame_without_fcs, + test_invalid_callsign_build, + test_non_ui_rejected, + ] + for fn in tests: + fn() + print("OK: ax25_codec unit tests") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/stacks/daemon/test_bans.py b/stacks/daemon/test_bans.py new file mode 100644 index 0000000..80176ae --- /dev/null +++ b/stacks/daemon/test_bans.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +"""Unit and protocol tests for max25d AX.25 source ban list.""" +from __future__ import annotations + +import importlib.util +import socket +import subprocess +import sys +import tempfile +import time +from pathlib import Path +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[2] +LAUNCHER = ROOT / "stacks/daemon/max25d" +DAEMON = ROOT / "stacks/daemon/max25d.py" + +sys.path.insert(0, str(DAEMON.parent)) +from banlist import BanList, callsign_banned, extract_ax25_source # noqa: E402 + + +def load_max25d_module(): + from importlib.machinery import SourceFileLoader + + loader = SourceFileLoader("max25d", str(DAEMON)) + spec = importlib.util.spec_from_loader("max25d", loader) + mod = importlib.util.module_from_spec(spec) + sys.modules["max25d"] = mod + loader.exec_module(mod) + return mod + + +class LineReader: + def __init__(self, sock: socket.socket) -> None: + self.sock = sock + self.buf = b"" + + def read(self, timeout: float = 5.0) -> str: + self.sock.settimeout(timeout) + while b"\n" not in self.buf: + chunk = self.sock.recv(4096) + if not chunk: + raise RuntimeError("connection closed") + self.buf += chunk + line, self.buf = self.buf.split(b"\n", 1) + return line.decode("utf-8") + + +def free_port() -> int: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + sock.close() + return port + + +def test_extract_ax25_source() -> None: + assert extract_ax25_source("[AX25 UI DG1ABC>QST] 73") == "DG1ABC" + assert extract_ax25_source("[AX25 UI DK0WC-7>CB-0] hi") == "DK0WC-7" + assert extract_ax25_source("plain text") is None + assert extract_ax25_source("[CRDOP RX soft-crdop] hello") is None + + +def test_callsign_banned_matching() -> None: + assert callsign_banned("DG1ABC", "DG1ABC") + assert callsign_banned("DG1ABC", "DG1ABC-7") + assert not callsign_banned("DG1ABC", "DG1ABD") + assert callsign_banned("DK0WC-7", "DK0WC-7") + assert not callsign_banned("DK0WC-7", "DK0WC") + assert not callsign_banned("DK0WC-7", "DK0WC-8") + + +def test_banlist_file_roundtrip() -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "bans.txt" + path.write_text("# ignored comment\nDG1ABC\n\nDK0WC-7\n", encoding="utf-8") + bans = BanList(path) + assert bans.list() == ["DG1ABC", "DK0WC-7"] + assert bans.is_banned("DG1ABC-3") + assert bans.is_banned("DK0WC-7") + assert not bans.is_banned("DK0WC") + + bans.add("DL1TEST") + bans.remove("DG1ABC") + assert bans.list() == ["DK0WC-7", "DL1TEST"] + reloaded = BanList(path) + assert reloaded.list() == ["DK0WC-7", "DL1TEST"] + + +def test_on_backend_rx_silent_drop() -> None: + mod = load_max25d_module() + with tempfile.TemporaryDirectory() as tmp: + bans_path = Path(tmp) / "bans.txt" + bans_path.write_text("DG1ABC\n", encoding="utf-8") + cfg = mod.DaemonConfig(bans_file=str(bans_path)) + state = mod.DaemonState(cfg=cfg, bans=BanList(bans_path)) + mod.init_device_runtimes(state) + + sent: list[str] = [] + + def capture(_state, line, skip=None): + sent.append(line) + + with patch.object(mod, "broadcast", side_effect=capture), patch.object(mod, "log"): + mod.on_backend_rx(state, "tnc2c", "[AX25 UI DG1ABC>QST] blocked") + assert sent == [] + + mod.on_backend_rx(state, "tnc2c", "[AX25 UI DK0WC>QST] allowed") + assert sent == ["RX device=tnc2c [AX25 UI DK0WC>QST] allowed"] + + +def test_ban_commands_protocol() -> None: + port = free_port() + with tempfile.TemporaryDirectory() as tmp: + bans_path = Path(tmp) / "bans.txt" + ini_path = Path(tmp) / "max25d.ini" + ini_path.write_text( + f""" +[daemon] +device = tnc2c +hardware = tncs +[network] +tcp_host = 127.0.0.1 +tcp_port = {port} +[modem] +bans_file = {bans_path} +[stack] +auto_start = no +""", + encoding="utf-8", + ) + + proc = subprocess.Popen( + [ + str(LAUNCHER), + "--no-stack", + "--no-serial", + "-c", + str(ini_path), + "--tcp-port", + str(port), + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + ) + time.sleep(0.6) + try: + sock = socket.create_connection(("127.0.0.1", port), timeout=3) + except OSError as exc: + proc.terminate() + proc.wait(timeout=3) + raise RuntimeError(f"connect failed: {exc}") from exc + + reader = LineReader(sock) + try: + assert reader.read() == "OK" + reader.read() # STATUS + + sock.sendall(b"BAN DG1ABC\n") + assert reader.read() == "OK" + assert bans_path.read_text(encoding="utf-8").strip() == "DG1ABC" + + sock.sendall(b"BANS\n") + assert reader.read() == "BAN DG1ABC" + assert reader.read() == "OK" + + sock.sendall(b"UNBAN DG1ABC\n") + assert reader.read() == "OK" + assert bans_path.read_text(encoding="utf-8") == "" + + sock.sendall(b"UNBAN DG1ABC\n") + assert reader.read() == "ERR not banned" + + sock.sendall(b"BAN BAD!\n") + assert reader.read() == "ERR invalid callsign" + finally: + sock.close() + proc.terminate() + proc.wait(timeout=5) + + +def main() -> int: + tests = [ + test_extract_ax25_source, + test_callsign_banned_matching, + test_banlist_file_roundtrip, + test_on_backend_rx_silent_drop, + test_ban_commands_protocol, + ] + for test in tests: + test() + print(f"OK: {test.__name__}") + print("OK: banlist tests") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/stacks/daemon/test_baycom_backend.py b/stacks/daemon/test_baycom_backend.py new file mode 100644 index 0000000..ebff5a4 --- /dev/null +++ b/stacks/daemon/test_baycom_backend.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Offline unit tests for BayComKissBackend (no kernel hardware).""" +from __future__ import annotations + +import os +import sys +from pathlib import Path +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "stacks" / "daemon")) + +from device_backends import BayComKissBackend, DeviceBackendConfig # noqa: E402 +from ax25_codec import ax25_build_ui, ax25_parse_ui # noqa: E402 +from kiss_bridge import KissDecoder, format_rx_line, kiss_data_frame # noqa: E402 + + +def test_baycom_kiss_default_link() -> None: + cfg = DeviceBackendConfig(device_id="baycom-ser12", backend_type="baycom-kiss", baycom_modem="a") + backend = BayComKissBackend(cfg, on_rx=lambda _l: None) + assert backend._path in ("/var/run/baycom-pr/kiss", f"/var/run/baycom-pr/kiss-a") + assert backend.backend_type == "baycom-kiss" + assert backend._is_pty is True + + +def test_baycom_kiss_transmit_kiss_frame() -> None: + cfg = DeviceBackendConfig( + device_id="baycom-ser12", + backend_type="baycom-kiss", + kiss_link="/var/run/baycom-pr/kiss", + ) + backend = BayComKissBackend(cfg, on_rx=lambda _l: None) + backend._fd = 99 + backend._kiss_active = True + written: list[bytes] = [] + + with patch("os.write", side_effect=lambda _fd, data: written.append(data) or len(data)): + with patch("termios.tcdrain"): + ok, display = backend.transmit("CB-0", "QST", "hello", ax25_ui=True) + assert ok, display + assert written and written[0].startswith(b"\xc0") + assert "hello" in display + + +def test_baycom_kiss_rx_decode_path() -> None: + """RX path: KISS bytes → AX.25 UI → on_rx line (same decoder as _rx_loop).""" + rx_lines: list[str] = [] + frame = ax25_build_ui("REMOTE-0", "CB-0", b"73") + pkt = kiss_data_frame(0, frame) + decoder = KissDecoder() + for _port, payload in decoder.feed(pkt): + parsed = ax25_parse_ui(payload) + assert parsed is not None + src, dst, info = parsed + assert src == "REMOTE" + assert dst == "CB" + assert info == b"73" + from kiss_bridge import format_rx_line + + rx_lines.append(format_rx_line(src, dst, info, ax25_ui=True)) + assert rx_lines and "73" in rx_lines[0] + + +def test_baycom_kiss_modem_b_link() -> None: + cfg = DeviceBackendConfig( + device_id="baycom-b", + backend_type="baycom-kiss", + baycom_modem="b", + kiss_link="/var/run/baycom-pr/kiss-b", + ) + backend = BayComKissBackend(cfg, on_rx=lambda _l: None) + assert backend._path == "/var/run/baycom-pr/kiss-b" + + +def test_baycom_kiss_open_missing_path() -> None: + backend = BayComKissBackend( + DeviceBackendConfig(device_id="baycom-ser12", kiss_link="/nonexistent/kiss/path"), + on_rx=lambda _l: None, + ) + assert not backend.open() + assert backend.status == "error-no-device" + + +def main() -> int: + test_baycom_kiss_default_link() + test_baycom_kiss_modem_b_link() + test_baycom_kiss_transmit_kiss_frame() + test_baycom_kiss_rx_decode_path() + test_baycom_kiss_open_missing_path() + print("OK: baycom backend tests") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/stacks/daemon/test_bcpr_backend.py b/stacks/daemon/test_bcpr_backend.py new file mode 100644 index 0000000..4d196ad --- /dev/null +++ b/stacks/daemon/test_bcpr_backend.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +"""Offline unit tests for bcpr device parse + BcprKissBackend (no UART).""" +from __future__ import annotations + +import configparser +import importlib.util +import sys +from pathlib import Path +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "stacks" / "daemon")) + +from device_backends import ( # noqa: E402 + BcprKissBackend, + DeviceBackendConfig, + parse_device_spec, +) + + +def load_max25d(): + from importlib.machinery import SourceFileLoader + + loader = SourceFileLoader("max25d", str(ROOT / "stacks" / "daemon" / "max25d.py")) + spec = importlib.util.spec_from_loader("max25d", loader) + mod = importlib.util.module_from_spec(spec) + sys.modules["max25d"] = mod + loader.exec_module(mod) + return mod + + +def test_bcpr_kiss_default_link() -> None: + cfg = DeviceBackendConfig( + device_id="max25e0", + backend_type="max25-bcpr-kiss", + bcpr_device="bc0", + ) + backend = BcprKissBackend(cfg, on_rx=lambda _l: None) + assert backend._path == "/tmp/max25-bcpr/kiss-bc0" + assert backend.backend_type == "max25-bcpr-kiss" + assert backend._is_pty is True + + +def test_max25e0_default_addrs() -> None: + from device_backends import MAX25E0_DEFAULT_IPV4, MAX25E0_DEFAULT_IPV6 + text = """ +[devices] +max25e0 = max25-bcpr:bc0 +""" + cp = configparser.ConfigParser() + cp.read_string(text) + from device_backends import parse_device_spec + dev = parse_device_spec("max25e0", "max25-bcpr:bc0", cp, {"hardware": "modems"}) + assert dev.ipv4 == MAX25E0_DEFAULT_IPV4 + assert dev.ipv6 == MAX25E0_DEFAULT_IPV6 + # override + text2 = """ +[devices] +max25e0 = max25-bcpr:bc0 +[device.max25e0] +ipv4 = 10.0.0.25/24 +ipv6 = fd00::25/128 +""" + cp2 = configparser.ConfigParser() + cp2.read_string(text2) + dev2 = parse_device_spec("max25e0", "max25-bcpr:bc0", cp2, {"hardware": "modems"}) + assert dev2.ipv4 == "10.0.0.25/24" + assert dev2.ipv6 == "fd00::25/128" + # fork inherits from [device.max25e0] (INI colon keys need care — parse by id) + text3 = """ +[device.max25e0] +ipv4 = 10.0.0.25/24 +ipv6 = fd00::25/128 +""" + cp3 = configparser.ConfigParser() + cp3.read_string(text3) + fork = parse_device_spec("max25e0:bc1", "max25-bcpr:bc1", cp3, {"hardware": "modems"}) + assert fork.ipv4 == "10.0.0.25/24" + assert fork.ipv6 == "fd00::25/128" + + +def test_parse_bcpr_spec() -> None: + cp = configparser.ConfigParser() + cp.read_string( + """ +[device.max25e0] +kiss_link = /var/run/max25-bcpr/kiss-bc0 +max25_bcpr_ini = /etc/max25/max25-bcpr.ini +""" + ) + dev = parse_device_spec("max25e0", "max25-bcpr:bc0", cp, {"hardware": "tncs"}) + assert dev.backend_type == "max25-bcpr-kiss" + assert dev.max25_bcpr_device == "bc0" + assert dev.kiss_link == "/var/run/max25-bcpr/kiss-bc0" + assert dev.max25_bcpr_ini == "/etc/max25/max25-bcpr.ini" + + +def test_feature_gate_bcpr() -> None: + mod = load_max25d() + cp = configparser.ConfigParser() + cp.read_string( + """ +[features] +max25_bcpr = no +[devices] +default = max25e0 +max25e0 = max25-bcpr:bc0 +""" + ) + cfg = mod.DaemonConfig() + cfg.feature_max25_bcpr = False + devices = mod.parse_devices(cp, cfg) + # parse_devices itself may include; load_config filters by features + assert any(d.device_id == "max25e0" for d in devices) + filtered = [d for d in devices if mod._device_allowed_by_features(d, cfg)] + assert filtered == [] + + cfg.feature_max25_bcpr = True + filtered = [d for d in devices if mod._device_allowed_by_features(d, cfg)] + assert len(filtered) == 1 + assert filtered[0].backend_type == "max25-bcpr-kiss" + + +def test_bcpr_kiss_pty_skips_tcdrain() -> None: + cfg = DeviceBackendConfig( + device_id="max25e0", + backend_type="max25-bcpr-kiss", + kiss_link="/tmp/max25-bcpr/kiss-bc0", + bcpr_device="bc0", + ) + backend = BcprKissBackend(cfg, on_rx=lambda _l: None) + backend._fd = 99 + backend._kiss_active = True + written: list[bytes] = [] + drained = {"n": 0} + + def fake_drain(_fd: int) -> None: + drained["n"] += 1 + + with patch("os.write", side_effect=lambda _fd, data: written.append(data) or len(data)): + with patch("termios.tcdrain", side_effect=fake_drain): + ok, display = backend.transmit("CB-0", "QST", "hello", ax25_ui=True) + assert ok, display + assert written and written[0].startswith(b"\xc0") + assert drained["n"] == 0 + + +def test_bcpr_kiss_stabilize_reopens_on_inode_mismatch() -> None: + cfg = DeviceBackendConfig( + device_id="max25e0", + backend_type="max25-bcpr-kiss", + kiss_link="/tmp/max25-bcpr/kiss-bc0", + bcpr_device="bc0", + ) + backend = BcprKissBackend(cfg, on_rx=lambda _l: None) + backend._fd = 7 + backend._kiss_active = True + backend.status = "ready" + backend._mycall = "CB-0" + calls: list[str] = [] + + class _St: + def __init__(self, ino: int) -> None: + self.st_ino = ino + self.st_dev = 1 + + with patch("os.path.exists", return_value=True): + with patch("os.stat", return_value=_St(200)): + with patch("os.fstat", return_value=_St(100)): + with patch.object(backend, "close", side_effect=lambda: calls.append("close")): + with patch.object(backend, "open", side_effect=lambda: calls.append("open") or True): + with patch.object( + backend, + "attach_session", + side_effect=lambda c: calls.append(f"attach:{c}") or True, + ): + ok = backend.stabilize_session("CB-0", force=False) + assert ok + assert calls == ["close", "open", "attach:CB-0"] + + +def main() -> int: + test_bcpr_kiss_default_link() + test_max25e0_default_addrs() + test_parse_bcpr_spec() + test_feature_gate_bcpr() + test_bcpr_kiss_pty_skips_tcdrain() + test_bcpr_kiss_stabilize_reopens_on_inode_mismatch() + print("OK: bcpr backend tests") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/stacks/daemon/test_crdop_backend.py b/stacks/daemon/test_crdop_backend.py new file mode 100644 index 0000000..a83f54b --- /dev/null +++ b/stacks/daemon/test_crdop_backend.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Offline unit tests for CrdopTcpBackend (no crdopc process).""" +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "stacks" / "daemon")) + +from device_backends import CrdopTcpBackend, DeviceBackendConfig # noqa: E402 + + +class _MockSock: + """Minimal socket double for ctrl/data channels.""" + + def __init__(self, term: str = "\n") -> None: + self.sent: list[bytes] = [] + self._closed = False + self._term = term + + def settimeout(self, _t: float) -> None: + return None + + def sendall(self, data: bytes) -> None: + self.sent.append(data) + + def recv(self, _n: int) -> bytes: + if self._closed: + return b"" + return f"OK{self._term}".encode("ascii") + + def close(self) -> None: + self._closed = True + + +def _mock_connections(ctrl: _MockSock, data: _MockSock): + def _create(addr, timeout=5.0): + _host, port = addr + return ctrl if port == 8515 else data + + return _create + + +def _open_ctx(ctrl: _MockSock, data: _MockSock): + mock_thread = MagicMock() + return ( + patch("device_backends.socket.create_connection", side_effect=_mock_connections(ctrl, data)), + patch("device_backends.threading.Thread", return_value=mock_thread), + ) + + +def test_crdop_backend_open_and_attach() -> None: + ctrl = _MockSock() + data = _MockSock() + cfg = DeviceBackendConfig( + device_id="soft-crdop", + backend_type="crdop-tcp", + crdop_host="127.0.0.1", + crdop_port=8515, + crdop_listen=True, + ) + backend = CrdopTcpBackend(cfg, on_rx=lambda _l: None) + p_sock, p_thread = _open_ctx(ctrl, data) + with p_sock, p_thread: + assert backend.open() + assert backend.attach_session("CB-0") + assert backend.status == "ready" + joined = b"".join(ctrl.sent).decode("ascii") + assert "INITIALIZE" in joined + assert "PROTOCOLMODE KISS" in joined + assert "LISTEN TRUE" in joined + backend.close() + assert backend.status == "closed" + + +def test_crdop_backend_transmit() -> None: + ctrl = _MockSock() + data = _MockSock() + cfg = DeviceBackendConfig( + device_id="soft-crdop", + backend_type="crdop-tcp", + crdop_host="127.0.0.1", + crdop_port=8515, + ) + backend = CrdopTcpBackend(cfg, on_rx=lambda _l: None) + p_sock, p_thread = _open_ctx(ctrl, data) + with p_sock, p_thread: + assert backend.open() + assert backend.attach_session("CB-0") + ok, display = backend.transmit("CB-0", "QST", "hello", ax25_ui=True) + assert ok, display + assert len(data.sent) == 1 + assert "FECSEND" not in b"".join(ctrl.sent).decode("ascii") + assert "[CRDOP AX25 UI CB-0>QST]" in display + backend.close() + assert backend.status == "closed" + + +def test_crdop_backend_connect_failure() -> None: + cfg = DeviceBackendConfig( + device_id="soft-crdop", + backend_type="crdop-tcp", + crdop_host="127.0.0.1", + crdop_port=59999, + ) + backend = CrdopTcpBackend(cfg, on_rx=lambda _l: None) + with patch("device_backends.socket.create_connection", side_effect=OSError("refused")): + assert not backend.open() + assert backend.status == "error-connect" + + +def test_crdop_registry_tested() -> None: + from device_backends import registry_tested # noqa: E402 + + assert registry_tested("soft-crdop") is True + + +def main() -> int: + test_crdop_backend_connect_failure() + test_crdop_backend_open_and_attach() + test_crdop_backend_transmit() + test_crdop_registry_tested() + print("OK: crdop backend tests") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/stacks/daemon/test_daemon_log.py b/stacks/daemon/test_daemon_log.py new file mode 100644 index 0000000..0569ca1 --- /dev/null +++ b/stacks/daemon/test_daemon_log.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Unit tests for max25d structured logging.""" +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "stacks" / "daemon")) + +from daemon_log import DaemonLogger, emit_startup_banner # noqa: E402 +from device_backends import DeviceBackendConfig # noqa: E402 + + +def test_structured_levels() -> None: + lines: list[str] = [] + log = DaemonLogger(emit=lines.append) + log.ok("prep complete", area="serial", device="tnc2c") + log.recovery("JHOST 0 — banner", device="pktnc2") + log.warn("no password", area="security") + assert lines[0] == "max25d [OK] [serial] [tnc2c] prep complete" + assert lines[1] == "max25d [RECOVERY] [serial] [pktnc2] JHOST 0 — banner" + assert lines[2] == "max25d [WARN] [security] no password" + + +def test_emit_unstructured_device_prefix() -> None: + lines: list[str] = [] + log = DaemonLogger(emit=lines.append) + log.emit_unstructured("tnc2c: recovery: OK after JHOST 0") + assert "[tnc2c]" in lines[0] + assert "[RECOVERY]" in lines[0] + assert "OK after JHOST 0" in lines[0] + + +def test_startup_banner_sections() -> None: + from dataclasses import dataclass + + @dataclass + class Cfg: + mode: str = "standalone" + device: str = "tnc2c" + default_device: str = "tnc2c" + tcp_host: str = "127.0.0.1" + tcp_port: int = 7325 + unix_socket: str = "/run/max25/modem.sock" + tcp_password: str = "" + callerid: str = "CB-0" + callid: str = "QST" + ax25_ui: bool = True + bans_file: str = "" + auto_start: bool = True + serial_enabled: bool = True + stack_recover_only: bool = True + serial_watch: bool = True + serial_watch_interval: int = 60 + serial_watch_startup_grace: int = 45 + serial_bootwait_escalate: bool = True + hardware: str = "tncs" + + lines: list[str] = [] + from daemon_log import LOGGER + + orig = LOGGER._emit + try: + LOGGER._emit = lines.append + dev = DeviceBackendConfig(device_id="tnc2c", backend_type="kiss-serial") + dev.serial_device = "/dev/ttyS4" + dev.serial_baud = 19200 + dev.serial_line = "8n1" + emit_startup_banner( + config_path="/etc/max25/max25d.ini", + cfg=Cfg(), + devices=[dev], + tested_fn=lambda _d: True, + ) + finally: + LOGGER._emit = orig + + text = "\n".join(lines) + assert "=== MAX25d starting ===" in text + assert "[config]" in text + assert "ini=/etc/max25/max25d.ini" in text + assert "[devices]" in text + assert "tnc2c=" in text + + +def main() -> int: + test_structured_levels() + print("OK test_structured_levels") + test_emit_unstructured_device_prefix() + print("OK test_emit_unstructured_device_prefix") + test_startup_banner_sections() + print("OK test_startup_banner_sections") + print("All daemon_log tests passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/stacks/daemon/test_kiss_bridge.py b/stacks/daemon/test_kiss_bridge.py new file mode 100644 index 0000000..066d1d0 --- /dev/null +++ b/stacks/daemon/test_kiss_bridge.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Offline unit tests for kiss_bridge (no serial hardware).""" +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from ax25_codec import ax25_build_ui, ax25_crc, ax25_parse_ui, parse_callsign # noqa: E402 +from kiss_bridge import ( # noqa: E402 + KissDecoder, + format_rx_line, + kiss_data_frame, + kiss_encode, + serial_profile_for_device, +) + + +def test_callsign_parse() -> None: + assert parse_callsign("DG1ABC") == ("DG1ABC", 0) + assert parse_callsign("DK0WC-7") == ("DK0WC", 7) + + +def test_ax25_roundtrip() -> None: + frame = ax25_build_ui("DG1ABC", "CQ", b"73") + parsed = ax25_parse_ui(frame) + assert parsed is not None + src, dst, payload = parsed + assert src == "DG1ABC" + assert dst == "CQ" + assert payload == b"73" + + +def test_kiss_fcs_strip() -> None: + frame = ax25_build_ui("CB-0", "QST", b"hi") + pkt = kiss_data_frame(0, frame) + assert pkt.startswith(b"\xc0") + assert pkt.endswith(b"\xc0") + dec = KissDecoder() + frames = dec.feed(pkt) + assert len(frames) == 1 + _port, payload = frames[0] + assert len(payload) == len(frame) - 2 + parsed = ax25_parse_ui(payload) + assert parsed is not None + src, dst, info = parsed + assert src == "CB" + assert dst == "QST" + assert info == b"hi" + + +def test_kiss_escape_roundtrip() -> None: + raw = bytes([0xC0, 0xDB, 0x00, 0xFF]) + pkt = kiss_encode(0, 0x00, raw) + dec = KissDecoder() + frames = dec.feed(pkt) + assert frames[0][1] == raw + + +def test_format_rx() -> None: + line = format_rx_line("DG1ABC", "QST", b"73", ax25_ui=True) + assert line == "[AX25 UI DG1ABC>QST] 73" + + +def test_serial_profile_tnc2c() -> None: + root = str(Path(__file__).resolve().parents[2]) + prof = serial_profile_for_device("tnc2c", root, {}) + assert prof.baud == 19200 + assert prof.dtr_rts is True + assert prof.kiss_entry == "kiss_on" + + +def test_serial_profile_pktnc2() -> None: + root = str(Path(__file__).resolve().parents[2]) + prof = serial_profile_for_device("pktnc2", root, {}) + assert prof.baud == 9600 + assert prof.dtr_rts is False + assert prof.kiss_entry == "auto" + + +def test_serial_profile_pccom_kiss_env() -> None: + import os + import tempfile + + tmp = Path(tempfile.mkdtemp()) + env = tmp / "local" / "pccom-kiss-serial.env" + env.parent.mkdir(parents=True) + env.write_text( + "PCCOM_KISS_DEV=/dev/ttyUSB2\nPCCOM_KISS_BAUD=9600\nPCCOM_KISS_DTR_RTS=no\n", + encoding="utf-8", + ) + old = os.environ.get("MAX25_ROOT") + os.environ["MAX25_ROOT"] = str(tmp) + try: + prof = serial_profile_for_device("pccom-kiss", str(tmp), {}) + assert prof.device == "/dev/ttyUSB2" + assert prof.baud == 9600 + assert prof.dtr_rts is False + assert prof.kiss_entry == "none" + finally: + if old is None: + os.environ.pop("MAX25_ROOT", None) + else: + os.environ["MAX25_ROOT"] = old + + +def test_crc_known_vector() -> None: + body = bytes(range(256)) + assert ax25_crc(body) == 0x303C + + +def test_kiss_non_data_ignored() -> None: + pkt = kiss_encode(0, 0x06, b"ignored") # TXDELAY cmd + dec = KissDecoder() + assert dec.feed(pkt) == [] + + +def test_invalid_callsign_tx() -> None: + from kiss_bridge import KissBridge, SerialProfile + + bridge = KissBridge(SerialProfile(), on_rx=lambda _m: None) + bridge._kiss_active = True + bridge._fd = -1 # not used — validate before write + ok, msg = bridge.transmit("BAD!", "CQ", "hi", ax25_ui=True) + assert not ok + assert "invalid" in msg.lower() + + +def test_rx_thread_not_started_on_open() -> None: + from kiss_bridge import KissBridge, SerialProfile + + bridge = KissBridge(SerialProfile(), on_rx=lambda _m: None) + bridge._fd = 1 + bridge.status = "open" + assert bridge._thread is None + + +def main() -> int: + tests = [ + test_callsign_parse, + test_ax25_roundtrip, + test_kiss_fcs_strip, + test_kiss_escape_roundtrip, + test_format_rx, + test_serial_profile_tnc2c, + test_serial_profile_pktnc2, + test_serial_profile_pccom_kiss_env, + test_crc_known_vector, + test_kiss_non_data_ignored, + test_invalid_callsign_tx, + test_rx_thread_not_started_on_open, + ] + for fn in tests: + fn() + print("OK: kiss_bridge unit tests") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/stacks/daemon/test_max25_platform.py b/stacks/daemon/test_max25_platform.py new file mode 100644 index 0000000..5073e01 --- /dev/null +++ b/stacks/daemon/test_max25_platform.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Platform helpers for max25d.""" +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from max25_platform import ( # noqa: E402 + crdop_audio_backend, + default_bans_file, + default_unix_socket, + is_freebsd, + is_linux, + max25d_supported, + supported_device_ids, +) + + +def test_linux_defaults() -> None: + # Run on Linux CI — skip assertions on other hosts + if not is_linux(): + return + assert max25d_supported() + assert "tnc2c" in supported_device_ids() + assert "baycom-kiss" in supported_device_ids() + assert "pccom-kiss" in supported_device_ids() + assert "max25e0" in supported_device_ids() + assert "max25e0" in supported_device_ids() + assert "max25e0:bc1" in supported_device_ids() + assert "baycom-ser12" not in supported_device_ids() + assert "baycom-a" not in supported_device_ids() + assert crdop_audio_backend() == "alsa" + assert default_unix_socket() == "/run/max25/modem.sock" + assert default_bans_file() == "/var/lib/max25/bans.txt" + + +def test_freebsd_profile() -> None: + if not is_freebsd(): + return + assert max25d_supported() + assert "soft-crdop" in supported_device_ids() + assert "tmodem" in supported_device_ids() + assert "max25e0" in supported_device_ids() + assert "tnc2c" not in supported_device_ids() + assert crdop_audio_backend() == "oss" + assert default_unix_socket() == "/var/run/max25/modem.sock" + assert default_bans_file() == "/var/db/max25/bans.txt" + + +if __name__ == "__main__": + test_linux_defaults() + test_freebsd_profile() + print("test_max25_platform: ok") diff --git a/stacks/daemon/test_modular_tcp.py b/stacks/daemon/test_modular_tcp.py new file mode 100644 index 0000000..a0b8551 --- /dev/null +++ b/stacks/daemon/test_modular_tcp.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""Offline tests for modular_tcp_server.""" +from __future__ import annotations + +import configparser +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from modular_tcp_server import load_modular_tcp # noqa: E402 + + +def test_load_main_secondaries() -> None: + cp = configparser.ConfigParser() + cp.read_string( + """ +[modular_tcp] +enabled = yes +role = main + +[modular_tcp.secondaries] +a = 127.0.0.1:7326 +b = 127.0.0.1:7327 +""" + ) + cfg = load_modular_tcp(cp) + assert cfg.enabled is True + assert cfg.role == "main" + assert len(cfg.secondaries) == 2 + assert cfg.secondaries[0].port == 7326 + + +if __name__ == "__main__": + test_load_main_secondaries() + print("test_modular_tcp: ok") diff --git a/stacks/daemon/test_multi_device.py b/stacks/daemon/test_multi_device.py new file mode 100644 index 0000000..a46a449 --- /dev/null +++ b/stacks/daemon/test_multi_device.py @@ -0,0 +1,518 @@ +#!/usr/bin/env python3 +"""Multi-device config and M25/1 protocol tests for max25d (no serial hardware).""" +from __future__ import annotations + +import configparser +import importlib.util +import socket +import subprocess +import sys +import tempfile +import time +from pathlib import Path +from typing import Callable +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[2] +LAUNCHER = ROOT / "stacks/daemon/max25d" +DAEMON = ROOT / "stacks/daemon/max25d.py" + + +def load_max25d_module(): + from importlib.machinery import SourceFileLoader + + loader = SourceFileLoader("max25d", str(DAEMON)) + spec = importlib.util.spec_from_loader("max25d", loader) + mod = importlib.util.module_from_spec(spec) + sys.modules["max25d"] = mod + loader.exec_module(mod) + return mod + + +class LineReader: + def __init__(self, sock: socket.socket) -> None: + self.sock = sock + self.buf = b"" + + def read(self, timeout: float = 5.0) -> str: + self.sock.settimeout(timeout) + while b"\n" not in self.buf: + chunk = self.sock.recv(4096) + if not chunk: + raise RuntimeError("connection closed") + self.buf += chunk + line, self.buf = self.buf.split(b"\n", 1) + return line.decode("utf-8") + + +def free_port() -> int: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + sock.close() + return port + + +class FakeBackend: + backend_type = "mock" + + def __init__(self, cfg, root, on_rx, log=None): + self.device_id = cfg.device_id + self._on_rx = on_rx + self.status = "ready" + + def open(self): + return True + + def attach_session(self, mycall): + return True + + def transmit(self, src, dst, text, ax25_ui): + return True, f"[AX25 UI {src}>{dst}] {text}" + + def close(self): + self.status = "closed" + + def detach_session(self): + pass + + +def test_parse_legacy_single_device() -> None: + mod = load_max25d_module() + cp = configparser.ConfigParser() + cp.read_string( + """ +[daemon] +device = tnc2c +hardware = tncs +[serial] +device = /dev/ttyS4 +baud = 19200 +""" + ) + cfg = mod.DaemonConfig(device="tnc2c", hardware="tncs") + devices = mod.parse_devices(cp, cfg) + assert len(devices) == 1 + assert devices[0].device_id == "tnc2c" + assert devices[0].serial_device == "/dev/ttyS4" + assert devices[0].serial_baud == 19200 + assert devices[0].backend_type == "kiss-serial" + + +def test_parse_multi_devices() -> None: + mod = load_max25d_module() + cp = configparser.ConfigParser() + cp.read_string( + """ +[devices] +default = tnc2c +tnc2c = /dev/ttyS4 +pktnc2 = /dev/ttyS5 +dev3 = /dev/ttyUSB0 +dev4 = /dev/ttyUSB1 +dev5 = /dev/ttyUSB2 +[serial.dev3] +baud = 9600 +""" + ) + cfg = mod.DaemonConfig() + devices = mod.parse_devices(cp, cfg) + assert len(devices) == 5 + ids = {d.device_id for d in devices} + assert ids == {"tnc2c", "pktnc2", "dev3", "dev4", "dev5"} + by_id = {d.device_id: d for d in devices} + assert by_id["tnc2c"].serial_device == "/dev/ttyS4" + assert by_id["dev3"].serial_baud == 9600 + assert cfg.default_device == "tnc2c" + + +def test_parse_pccom_kiss_device() -> None: + mod = load_max25d_module() + cp = configparser.ConfigParser() + cp.read_string( + """ +[features] +baycom = yes +pccom = yes +[devices] +default = pccom-kiss +pccom-kiss = /dev/ttyUSB0 +""" + ) + cfg = mod.DaemonConfig() + cfg.feature_baycom = True + cfg.feature_pccom = True + devices = mod.parse_devices(cp, cfg) + by_id = {d.device_id: d for d in devices} + assert by_id["pccom-kiss"].backend_type == "kiss-raw-serial" + assert by_id["pccom-kiss"].serial_device == "/dev/ttyUSB0" + + +def test_parse_heterogeneous_devices() -> None: + mod = load_max25d_module() + cp = configparser.ConfigParser() + cp.read_string( + """ +[devices] +default = tnc2c +tnc2c = /dev/ttyS4 +baycom-ser12 = baycom:a +baycom-kiss = /dev/ttyUSB0 +soft-crdop = crdop:default +[device.baycom-ser12] +kiss_link = /var/run/baycom-pr/kiss +modem = a +baycom_ini = /etc/baycom/baycom-pr.ini +[device.soft-crdop] +port = 8515 +""" + ) + cfg = mod.DaemonConfig() + devices = mod.parse_devices(cp, cfg) + by_id = {d.device_id: d for d in devices} + assert by_id["tnc2c"].backend_type == "kiss-serial" + assert by_id["baycom-ser12"].backend_type == "baycom-kiss" + assert by_id["baycom-ser12"].kiss_link == "/var/run/baycom-pr/kiss" + assert by_id["baycom-ser12"].baycom_ini == "/etc/baycom/baycom-pr.ini" + assert by_id["baycom-kiss"].backend_type == "kiss-raw-serial" + assert by_id["soft-crdop"].backend_type == "crdop-tcp" + assert by_id["soft-crdop"].crdop_port == 8515 + + +def test_parse_enabled_filter() -> None: + mod = load_max25d_module() + cp = configparser.ConfigParser() + cp.read_string( + """ +[devices] +enabled = tnc2c,pktnc2 +tnc2c = /dev/ttyS4 +pktnc2 = /dev/ttyS5 +extra = /dev/ttyUSB9 +""" + ) + cfg = mod.DaemonConfig() + devices = mod.parse_devices(cp, cfg) + enabled = {d.device_id for d in devices if d.enabled} + disabled = {d.device_id for d in devices if not d.enabled} + assert enabled == {"tnc2c", "pktnc2"} + assert disabled == {"extra"} + + +def test_backend_open_retry_on_error() -> None: + """CONNECT retries KISS open after a transient error-no-device.""" + mod = load_max25d_module() + cfg = mod.DaemonConfig(hardware="modems", serial_enabled=True) + cfg.devices = [ + mod.DeviceBackendConfig( + device_id="baycom-ser12", + backend_type="baycom-kiss", + kiss_link="/nonexistent/kiss", + enabled=True, + ) + ] + cfg.default_device = "baycom-ser12" + state = mod.DaemonState(cfg=cfg) + mod.init_device_runtimes(state) + + class ErrorThenReady(FakeBackend): + attempts = 0 + + def open(self): + ErrorThenReady.attempts += 1 + if ErrorThenReady.attempts == 1: + self.status = "error-no-device" + return False + self.status = "ready" + return True + + ErrorThenReady.attempts = 0 + with patch.object( + mod, + "create_backend", + side_effect=lambda c, r, rx, log, prefix=None, on_invalid=None: ErrorThenReady(c, r, rx, log), + ): + assert not mod.attach_backend_session(state, "baycom-ser12") + assert mod.attach_backend_session(state, "baycom-ser12") + assert ErrorThenReady.attempts == 2 + + +def test_five_mock_backends() -> None: + """Five concurrent backend instances via mocked create_backend.""" + mod = load_max25d_module() + opened: list[str] = [] + + class CountingBackend(FakeBackend): + def open(self): + opened.append(self.device_id) + return True + + cfg = mod.DaemonConfig(hardware="tncs", serial_enabled=True) + cfg.devices = [ + mod.DeviceBackendConfig(device_id=f"d{i}", serial_device=f"/dev/fake{i - 1}", enabled=True) + for i in range(1, 6) + ] + for d in cfg.devices: + d.backend_type = "kiss-serial" + cfg.default_device = "d1" + state = mod.DaemonState(cfg=cfg) + mod.init_device_runtimes(state) + + with patch.object( + mod, + "create_backend", + side_effect=lambda c, r, rx, log, prefix=None, on_invalid=None: CountingBackend(c, r, rx, log), + ): + assert mod.attach_all_sessions(state) + assert len(opened) == 5 + assert set(opened) == {f"d{i}" for i in range(1, 6)} + + state.selected_device = "d3" + dev_id = mod.resolve_selected_device(state) + assert dev_id == "d3" + rt = state.devices["d3"] + ok, display = rt.backend.transmit("CB-0", "QST", "hi", True) + assert ok + assert "hi" in display + + +def run_daemon_test(ini_text: str, fn: Callable[[LineReader, socket.socket], None]) -> None: + port = free_port() + with tempfile.NamedTemporaryFile("w", suffix=".ini", delete=False) as tmp: + tmp.write(ini_text) + ini_path = Path(tmp.name) + + proc = subprocess.Popen( + [ + str(LAUNCHER), + "--no-stack", + "--no-serial", + "-c", + str(ini_path), + "--tcp-port", + str(port), + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + ) + time.sleep(0.6) + try: + sock = socket.create_connection(("127.0.0.1", port), timeout=3) + except OSError as exc: + proc.terminate() + proc.wait(timeout=3) + ini_path.unlink(missing_ok=True) + raise RuntimeError(f"connect failed: {exc}") from exc + + reader = LineReader(sock) + try: + assert reader.read() == "OK" + fn(reader, sock) + finally: + sock.close() + proc.terminate() + proc.wait(timeout=5) + ini_path.unlink(missing_ok=True) + + +def test_parse_dual_baycom_devices() -> None: + mod = load_max25d_module() + cp = configparser.ConfigParser() + cp.read_string( + """ +[devices] +default = baycom-a +baycom-a = baycom:a +baycom-b = baycom:b +[device.baycom-a] +kiss_link = /var/run/baycom-pr/kiss-a +modem = a +baycom_ini = /etc/baycom/baycom-pr.ini +[device.baycom-b] +kiss_link = /var/run/baycom-pr/kiss-b +modem = b +baycom_ini = /etc/baycom/baycom-pr.ini +""" + ) + cfg = mod.DaemonConfig() + devices = mod.parse_devices(cp, cfg) + by_id = {d.device_id: d for d in devices} + assert set(by_id) == {"baycom-a", "baycom-b"} + assert by_id["baycom-a"].backend_type == "baycom-kiss" + assert by_id["baycom-b"].backend_type == "baycom-kiss" + assert by_id["baycom-a"].baycom_modem == "a" + assert by_id["baycom-b"].baycom_modem == "b" + assert by_id["baycom-a"].kiss_link == "/var/run/baycom-pr/kiss-a" + assert by_id["baycom-b"].kiss_link == "/var/run/baycom-pr/kiss-b" + assert cfg.default_device == "baycom-a" + + +def test_protocol_dual_baycom() -> None: + # Legacy ids baycom-a/baycom-b are not on the Linux allowlist (product path: max25e0). + # Protocol dual-device coverage uses allowlisted modem-class ids instead. + ini = """ +[features] +baycom = yes +pccom = yes +[daemon] +mode = service +hardware = modems +device = baycom-kiss +[devices] +default = baycom-kiss +baycom-kiss = /dev/null +pccom-kiss = /dev/null +[network] +tcp_host = 127.0.0.1 +tcp_port = 7325 +[modem] +callerid = N0CALL-0 +callid = QST +[stack] +auto_start = no +""" + + def exercise(reader: LineReader, sock: socket.socket) -> None: + status = reader.read() + assert "devices=baycom-kiss,pccom-kiss" in status + assert "device=baycom-kiss" in status + + sock.sendall(b"GET DEVICES\n") + lines = [] + while True: + line = reader.read() + lines.append(line) + if line == "OK": + break + device_lines = [l for l in lines if l.startswith("DEVICE ")] + assert len(device_lines) == 2 + assert any("id=baycom-kiss" in l for l in device_lines) + assert any("id=pccom-kiss" in l for l in device_lines) + + sock.sendall(b"SET DEVICE pccom-kiss\n") + assert reader.read() == "OK" + + sock.sendall(b"GET STATUS\n") + st = reader.read() + assert "device=pccom-kiss" in st + assert reader.read() == "OK" + + run_daemon_test(ini, exercise) + + +def test_protocol_multi_device() -> None: + # Explicit two-device INI — share/max25d.ini.example is single-device by design. + ini = """ +[daemon] +mode = standalone +hardware = tncs +device = tnc2c +[devices] +default = tnc2c +tnc2c = /dev/ttyS4 +pktnc2 = /dev/ttyS5 +[network] +tcp_host = 127.0.0.1 +tcp_port = 7325 +[modem] +callerid = CB-0 +callid = QST +[stack] +auto_start = no +""" + + def exercise(reader: LineReader, sock: socket.socket) -> None: + status = reader.read() + assert status.startswith("STATUS ") + assert "devices=tnc2c,pktnc2" in status + assert "device=tnc2c" in status + + sock.sendall(b"GET DEVICES\n") + lines = [] + while True: + line = reader.read() + lines.append(line) + if line == "OK": + break + device_lines = [l for l in lines if l.startswith("DEVICE ")] + assert len(device_lines) == 2 + assert any("id=tnc2c" in l for l in device_lines) + assert any("id=pktnc2" in l for l in device_lines) + assert any("backend=kiss-serial" in l for l in device_lines) + + sock.sendall(b"SET DEVICE pktnc2\n") + assert reader.read() == "OK" + + sock.sendall(b"GET STATUS\n") + st = reader.read() + assert "device=pktnc2" in st + assert reader.read() == "OK" + + sock.sendall(b"SELECT DEVICE tnc2c\n") + assert reader.read() == "OK" + + sock.sendall(b"CONNECT\n") + assert reader.read() == "EVENT connected" + assert reader.read() == "OK" + + sock.sendall(b"SEND 73\n") + rx = reader.read() + assert rx.startswith("RX ") + assert reader.read() == "OK" + + sock.sendall(b"DISCONNECT\n") + assert reader.read() == "EVENT disconnected" + assert reader.read() == "OK" + + run_daemon_test(ini, exercise) + + +def test_protocol_legacy_single() -> None: + ini = """ +[daemon] +mode = standalone +hardware = tncs +device = tnc2c +[network] +tcp_host = 127.0.0.1 +tcp_port = 7325 +[modem] +callerid = CB-0 +callid = QST +[stack] +auto_start = no +""" + + def exercise(reader: LineReader, sock: socket.socket) -> None: + status = reader.read() + assert "device=tnc2c" in status + assert "devices=tnc2c" in status + + sock.sendall(b"SET DEVICE tnc2c\n") + assert reader.read() == "OK" + + run_daemon_test(ini, exercise) + + +def main() -> int: + tests = [ + test_parse_legacy_single_device, + test_parse_multi_devices, + test_parse_heterogeneous_devices, + test_parse_dual_baycom_devices, + test_parse_enabled_filter, + test_backend_open_retry_on_error, + test_five_mock_backends, + test_protocol_multi_device, + test_protocol_dual_baycom, + test_protocol_legacy_single, + ] + for fn in tests: + fn() + print("OK: multi-device tests") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/stacks/daemon/test_path_normalize.py b/stacks/daemon/test_path_normalize.py new file mode 100644 index 0000000..c7b24e6 --- /dev/null +++ b/stacks/daemon/test_path_normalize.py @@ -0,0 +1,17 @@ +"""Tests for legacy bcpr path normalization.""" + +from paths import MAX25_BCPR_KISS_DEFAULT, normalize_max25_bcpr_path + + +def test_normalize_legacy_tmp_bcpr(): + assert normalize_max25_bcpr_path("/tmp/bcpr/kiss-bc0") == "/tmp/max25-bcpr/kiss-bc0" + assert normalize_max25_bcpr_path("/tmp/bcpr") == "/tmp/max25-bcpr" + assert normalize_max25_bcpr_path("") == "" + + +def test_canonical_unchanged(): + assert ( + normalize_max25_bcpr_path("/tmp/max25-bcpr/kiss-bc0") + == "/tmp/max25-bcpr/kiss-bc0" + ) + assert MAX25_BCPR_KISS_DEFAULT == "/tmp/max25-bcpr/kiss-bc0" diff --git a/stacks/daemon/test_paths.py b/stacks/daemon/test_paths.py new file mode 100644 index 0000000..6a3d7f5 --- /dev/null +++ b/stacks/daemon/test_paths.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""Offline tests for install/dev path resolution.""" +from __future__ import annotations + +import os +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from paths import ( # noqa: E402 + baycom_ini_candidates, + canonical_dual_baycom_example, + canonical_single_baycom_example, + ctl_path, + default_ini_candidates, + is_dual_baycom_ini, + resolve_baycom_ini, + resolve_baycom_profile, + resolve_layout, + serial_env_candidates, + share_max25_dir, + stacks_dir, +) + +REPO = Path(__file__).resolve().parents[2] + + +def test_dev_layout() -> None: + exe = REPO / "stacks/daemon/max25d" + tree, prefix = resolve_layout(exe) + assert prefix is None + assert tree == REPO + assert (stacks_dir(tree, prefix) / "crdop").is_dir() + assert ctl_path(tree, prefix, exe) == REPO / "scripts/max25-ctl" + + +def test_installed_layout() -> None: + with tempfile.TemporaryDirectory() as tmp: + prefix = Path(tmp) / "usr/local" + bindir = prefix / "bin" + share = prefix / "share/max25" + bindir.mkdir(parents=True) + share.mkdir(parents=True) + (share / "max25d.ini.example").write_text("; test\n", encoding="utf-8") + (bindir / "max25d").write_text("#!/bin/sh\n", encoding="utf-8") + (bindir / "max25-ctl").write_text("#!/bin/sh\n", encoding="utf-8") + + tree, detected = resolve_layout(bindir / "max25d") + assert detected == prefix + assert tree == prefix + + cands = default_ini_candidates(tree, detected) + assert share / "max25d.ini.example" in cands + assert ctl_path(tree, detected, bindir / "max25d") == bindir / "max25-ctl" + + +def test_max25_root_override() -> None: + with tempfile.TemporaryDirectory() as tmp: + prefix = Path(tmp) / "opt/max25" + bindir = prefix / "bin" + share = prefix / "share/max25" + bindir.mkdir(parents=True) + share.mkdir(parents=True) + (bindir / "max25d").write_text("#!/bin/sh\n", encoding="utf-8") + + checkout = Path(tmp) / "checkout" + (checkout / "stacks/tncs").mkdir(parents=True) + (checkout / "plugins").mkdir() + (checkout / "plugins/manifest.yaml").write_text("plugins: []\n", encoding="utf-8") + + old = os.environ.get("MAX25_ROOT") + os.environ["MAX25_ROOT"] = str(checkout) + try: + tree, detected = resolve_layout(bindir / "max25d") + assert detected == prefix + assert tree == checkout + assert stacks_dir(tree, detected) == checkout / "stacks" + finally: + if old is None: + os.environ.pop("MAX25_ROOT", None) + else: + os.environ["MAX25_ROOT"] = old + + +def test_serial_env_order() -> None: + with tempfile.TemporaryDirectory() as tmp: + prefix = Path(tmp) / "usr/local" + etc = Path(tmp) / "etc/max25" + etc.mkdir(parents=True) + (etc / "tnc2c-serial.env").write_text("TNC2C_BAUD=38400\n", encoding="utf-8") + share = prefix / "share/max25/serial" + share.mkdir(parents=True) + (share / "tnc2c-serial.env").write_text("TNC2C_BAUD=19200\n", encoding="utf-8") + + # /etc/max25 is checked first when present on disk + cands = serial_env_candidates("tnc2c", REPO, prefix) + assert cands[0] == Path("/etc/max25/tnc2c-serial.env") + assert prefix / "share/max25/serial/tnc2c-serial.env" in cands + + +def test_baycom_ini_resolution_legacy_removed() -> None: + """Kernel baycom-pr examples removed 2026-07-18 — no in-tree templates.""" + example = REPO / "share/baycom/baycom-pr.pccom-ttyS0-only.ini.example" + dual = REPO / "stacks/baycom-pr/config/examples/baycom-pr.dual.ini" + assert not example.is_file() + assert not dual.is_file() + assert canonical_single_baycom_example("baycom-ser12", REPO, None) is None + assert canonical_dual_baycom_example("baycom-ser12", REPO, None) is None + with tempfile.NamedTemporaryFile("w", suffix=".ini", delete=False) as tmp: + tmp.write("[stack]\n") + explicit = Path(tmp.name) + try: + assert resolve_baycom_ini("baycom-ser12", REPO, None, str(explicit)) == explicit + # Without explicit: may still resolve site /etc/baycom/baycom-pr.ini if present + resolved = resolve_baycom_ini("baycom-ser12", REPO, None) + if resolved is not None: + assert resolved.is_file() + assert "baycom-pr" in resolved.name or resolved.parent.name == "baycom" + finally: + explicit.unlink(missing_ok=True) + + +def test_baycom_profile_dual_legacy_removed() -> None: + assert resolve_baycom_profile("dual", "baycom-ser12", REPO, None) is None + + +def main() -> int: + tests = [ + test_dev_layout, + test_installed_layout, + test_max25_root_override, + test_serial_env_order, + test_baycom_ini_resolution_legacy_removed, + test_baycom_profile_dual_legacy_removed, + ] + for fn in tests: + fn() + print("OK: paths unit tests") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/stacks/daemon/test_privilege_drop.py b/stacks/daemon/test_privilege_drop.py new file mode 100644 index 0000000..3a611c9 --- /dev/null +++ b/stacks/daemon/test_privilege_drop.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Tests for max25d privilege_drop (no root required).""" +from __future__ import annotations + +import configparser +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +sys.path.insert(0, str(ROOT)) + +from privilege_drop import RunAsConfig, parse_run_as, resolve_run_as, run_as_configured # noqa: E402 + + +def test_parse_run_as_empty() -> None: + cp = configparser.ConfigParser() + cp.read_string("[daemon]\nmode = standalone\n") + cfg = parse_run_as(cp) + assert cfg.user == "" + assert not run_as_configured(cfg) + + +def test_parse_run_as_user_group() -> None: + cp = configparser.ConfigParser() + cp.read_string( + "[daemon]\nuser = max25\ngroup = dialout\nuid = 1001\n" + ) + cfg = parse_run_as(cp) + assert cfg.user == "max25" + assert cfg.group == "dialout" + assert cfg.uid == 1001 + assert run_as_configured(cfg) + + +def test_resolve_run_as_current_user() -> None: + import os + import pwd + + pw = pwd.getpwuid(os.getuid()) + uid, gid, name = resolve_run_as(RunAsConfig(user=pw.pw_name)) + assert uid == pw.pw_uid + assert gid == pw.pw_gid + assert name == pw.pw_name + + +def main() -> int: + test_parse_run_as_empty() + test_parse_run_as_user_group() + test_resolve_run_as_current_user() + print("privilege_drop: OK") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/stacks/daemon/test_proto.py b/stacks/daemon/test_proto.py new file mode 100644 index 0000000..50ced93 --- /dev/null +++ b/stacks/daemon/test_proto.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Offline smoke test for max25d M25/1 protocol.""" +import socket +import subprocess +import sys +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +DAEMON = ROOT / "stacks/daemon/max25d" +INI = ROOT / "share/max25/max25d.ini.example" + + +class LineReader: + def __init__(self, sock: socket.socket) -> None: + self.sock = sock + self.buf = b"" + + def read(self, timeout: float = 5.0) -> str: + self.sock.settimeout(timeout) + while b"\n" not in self.buf: + chunk = self.sock.recv(4096) + if not chunk: + raise RuntimeError("connection closed") + self.buf += chunk + line, self.buf = self.buf.split(b"\n", 1) + return line.decode("utf-8") + + +def free_port() -> int: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + sock.close() + return port + + +def wait_for_port(port: int, proc: subprocess.Popen[str], timeout: float = 8.0) -> bool: + deadline = time.time() + timeout + while time.time() < deadline: + if proc.poll() is not None: + return False + try: + probe = socket.create_connection(("127.0.0.1", port), timeout=0.25) + probe.close() + return True + except OSError: + time.sleep(0.1) + return False + + +def main() -> int: + port = free_port() + proc = subprocess.Popen( + [str(DAEMON), "--no-stack", "--no-serial", "-c", str(INI), "--tcp-port", str(port)], + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + ) + if not wait_for_port(port, proc): + err = proc.stderr.read() if proc.stderr else "" + proc.terminate() + proc.wait(timeout=3) + print(f"FAIL: max25d not listening on {port}: {err.strip()}", file=sys.stderr) + return 1 + try: + sock = socket.create_connection(("127.0.0.1", port), timeout=3) + except OSError as exc: + proc.terminate() + proc.wait(timeout=3) + print(f"FAIL: connect: {exc}", file=sys.stderr) + return 1 + + reader = LineReader(sock) + try: + assert reader.read() == "OK" + status = reader.read() + assert status.startswith("STATUS ") + + sock.sendall(b"SET CALLERID DG1ABC\n") + assert reader.read() == "OK" + + sock.sendall(b"CONNECT\n") + assert reader.read() == "EVENT connected" + assert reader.read() == "OK" + + sock.sendall(b"SEND 73\n") + rx = reader.read() + assert rx.startswith("RX ") + assert reader.read() == "OK" + + sock.sendall(b"GET STATUS\n") + st = reader.read() + assert "callerid=DG1ABC" in st + assert reader.read() == "OK" + + print("OK: max25d protocol smoke") + return 0 + finally: + sock.close() + proc.terminate() + proc.wait(timeout=5) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/stacks/daemon/test_reporting.py b/stacks/daemon/test_reporting.py new file mode 100644 index 0000000..54f73df --- /dev/null +++ b/stacks/daemon/test_reporting.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""INI reporting switches — error= / voice= in STATUS.""" +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from device_backends import DeviceBackendConfig # noqa: E402 +import max25d # noqa: E402 +from reporting_quality import DataQualityTracker, RxOutcome # noqa: E402 + +T0 = 2_000_000.0 +WIN = 20 + + +def _close_pass(rt: max25d.DeviceRuntime, index: int, start: float = T0) -> None: + rt.quality.tick(start + (index + 1) * WIN + 1) + + +def _fill_good(rt: max25d.DeviceRuntime, count: int = 3, window: int = WIN) -> None: + for i in range(count): + rt.quality.record(RxOutcome.GOOD, T0 + i * window + 1) + rt.quality.tick(T0 + (i + 1) * window + 1) + + +def _state( + *, + error_on: bool = True, + voice_on: bool = True, + link: str = "ready", + passes: int = 3, + quality_min: int = 50, + pass_seconds: int = 20, + backend_type: str = "kiss-serial", + hardware: str = "tncs", + prefill_good: bool = True, +) -> max25d.DaemonState: + cfg = max25d.DaemonConfig( + report_error_transmissions=error_on, + report_voice_transmissions=voice_on, + report_data_passes=passes, + report_data_quality_min=quality_min, + report_data_pass_seconds=pass_seconds, + ) + dev = DeviceBackendConfig( + device_id="tnc2c", + enabled=True, + backend_type=backend_type, + hardware=hardware, + ) + rt = max25d.DeviceRuntime( + cfg=dev, + link_status=link, + quality=DataQualityTracker( + passes_required=passes, + min_good_percent=quality_min, + pass_window_sec=pass_seconds, + ), + ) + if prefill_good: + _fill_good(rt, passes, pass_seconds) + state = max25d.DaemonState(cfg=cfg) + state.devices["tnc2c"] = rt + state.selected_device = "tnc2c" + return state + + +def test_error_valid_when_enabled_and_ready() -> None: + line = max25d.status_line(_state()) + assert "error=valid" in line + + +def test_error_invalid_when_disabled() -> None: + line = max25d.status_line(_state(error_on=False)) + assert "error=invalid" in line + + +def test_error_invalid_on_link_fault() -> None: + line = max25d.status_line(_state(link="error-io")) + assert "error=invalid" in line + + +def test_error_invalid_before_passes_complete() -> None: + line = max25d.status_line(_state(prefill_good=False)) + assert "error=invalid" in line + + +def test_error_invalid_on_bad_frame_ratio() -> None: + state = _state(prefill_good=False) + rt = state.devices["tnc2c"] + rt.quality.record(RxOutcome.BAD, T0 + 1) + _close_pass(rt, 0) + rt.quality.record(RxOutcome.BAD, T0 + 21) + _close_pass(rt, 1) + rt.quality.record(RxOutcome.GOOD, T0 + 41) + _close_pass(rt, 2) + line = max25d.status_line(state) + assert "error=invalid" in line + + +def test_voice_valid_tnc_only() -> None: + line = max25d.status_line(_state()) + assert "voice=valid" in line + + +def test_voice_invalid_when_disabled() -> None: + line = max25d.status_line(_state(voice_on=False)) + assert "voice=invalid" in line + + +def test_voice_invalid_crdop_not_ready() -> None: + line = max25d.status_line( + _state( + backend_type="crdop-tcp", + hardware="soft-modems", + link="error-connect", + ) + ) + assert "voice=invalid" in line + + +def test_device_line_includes_reporting() -> None: + state = _state() + dev = max25d.device_line(state, "tnc2c") + assert "error=valid" in dev + assert "voice=n/a" in dev + + +def test_on_backend_rx_tracks_callid_in_pass_window() -> None: + state = _state(prefill_good=False) + max25d.on_backend_rx(state, "tnc2c", "[AX25 UI DG1ABC>QST] 73") + max25d.on_backend_rx(state, "tnc2c", "[AX25 UI DG1ABC>CB-0] bad") + max25d.on_backend_rx(state, "tnc2c", "[CRDOP RX soft-crdop] noise") + rt = state.devices["tnc2c"] + start = rt.quality.current.started_at + assert rt.quality.current.good == 1 + assert rt.quality.current.bad == 1 + rt.quality.tick(start + WIN + 1) + assert list(rt.quality.passes) == ["bad"] + max25d.on_backend_rx(state, "tnc2c", "[AX25 UI DG1ABC>QST] ok") + start = rt.quality.current.started_at + rt.quality.tick(start + WIN + 1) + max25d.on_backend_rx(state, "tnc2c", "[AX25 UI DG1ABC>QST] ok2") + start = rt.quality.current.started_at + rt.quality.tick(start + WIN + 1) + line = max25d.status_line(state) + assert "error=valid" in line + + +if __name__ == "__main__": + test_error_valid_when_enabled_and_ready() + test_error_invalid_when_disabled() + test_error_invalid_on_link_fault() + test_error_invalid_before_passes_complete() + test_error_invalid_on_bad_frame_ratio() + test_voice_valid_tnc_only() + test_voice_invalid_when_disabled() + test_voice_invalid_crdop_not_ready() + test_device_line_includes_reporting() + test_on_backend_rx_tracks_callid_in_pass_window() + print("test_reporting: ok") diff --git a/stacks/daemon/test_reporting_quality.py b/stacks/daemon/test_reporting_quality.py new file mode 100644 index 0000000..15f0d4d --- /dev/null +++ b/stacks/daemon/test_reporting_quality.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""Data-quality window — CALLID match, timed passes, configurable min % good.""" +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from reporting_quality import ( # noqa: E402 + DataQualityTracker, + RxOutcome, + classify_rx_line, +) + +T0 = 1_000_000.0 +WIN = 20 + + +def test_classify_good_callid() -> None: + assert classify_rx_line("[AX25 UI DG1ABC>QST] 73", "QST") == RxOutcome.GOOD + + +def test_classify_bad_callid() -> None: + assert classify_rx_line("[AX25 UI DG1ABC>CB-0] 73", "QST") == RxOutcome.BAD + + +def test_classify_incomplete_ax25() -> None: + assert classify_rx_line("[AX25 UI DG1ABC>QST", "QST") == RxOutcome.BAD + + +def test_classify_voice_noise_ignored() -> None: + assert classify_rx_line("[CRDOP RX soft-crdop] hiss", "QST") == RxOutcome.IGNORE + + +def test_classify_crdop_ax25_good() -> None: + line = "[CRDOP AX25 UI CB-0>QST] ping" + assert classify_rx_line(line, "QST") == RxOutcome.GOOD + + +def _close_pass(q: DataQualityTracker, index: int, start: float = T0) -> None: + q.tick(start + (index + 1) * WIN + 1) + + +def test_pass_window_max_twenty_seconds() -> None: + q = DataQualityTracker(passes_required=3, min_good_percent=50, pass_window_sec=20) + q.record(RxOutcome.GOOD, T0 + 1) + q.tick(T0 + 19) + assert len(q.passes) == 0 + _close_pass(q, 0) + assert list(q.passes) == ["good"] + + +def test_three_passes_fifty_percent() -> None: + q = DataQualityTracker(passes_required=3, min_good_percent=50, pass_window_sec=20) + q.record(RxOutcome.GOOD, T0 + 1) + _close_pass(q, 0) + q.record(RxOutcome.BAD, T0 + 20 + 1) + _close_pass(q, 1) + assert q.data_error_valid(reporting_enabled=True) is False + q.record(RxOutcome.GOOD, T0 + 40 + 1) + _close_pass(q, 2) + assert q.data_error_valid(reporting_enabled=True) is True + + +def test_three_passes_below_fifty_percent() -> None: + q = DataQualityTracker(passes_required=3, min_good_percent=50, pass_window_sec=20) + q.record(RxOutcome.BAD, T0 + 1) + _close_pass(q, 0) + q.record(RxOutcome.BAD, T0 + 21) + _close_pass(q, 1) + q.record(RxOutcome.GOOD, T0 + 41) + _close_pass(q, 2) + assert q.data_error_valid(reporting_enabled=True) is False + + +def test_ignore_does_not_consume_pass() -> None: + q = DataQualityTracker(passes_required=3, min_good_percent=50, pass_window_sec=20) + q.record(RxOutcome.IGNORE, T0 + 1) + _close_pass(q, 0) + assert len(q.passes) == 0 + q.record(RxOutcome.GOOD, T0 + 21) + _close_pass(q, 1) + q.record(RxOutcome.GOOD, T0 + 41) + _close_pass(q, 2) + q.record(RxOutcome.GOOD, T0 + 61) + _close_pass(q, 3) + assert q.data_error_valid(reporting_enabled=True) is True + assert q.voice_activity is True + + +def test_silent_window_counts_as_bad() -> None: + q = DataQualityTracker(passes_required=1, min_good_percent=50, pass_window_sec=20) + q.current.started_at = T0 + _close_pass(q, 0) + assert list(q.passes) == ["bad"] + + +def test_max_cycle_seconds() -> None: + q = DataQualityTracker(passes_required=3, pass_window_sec=20) + assert q.max_cycle_seconds() == 60 + + +def test_voice_valid_on_activity() -> None: + q = DataQualityTracker() + q.record(RxOutcome.IGNORE, T0 + 1) + assert q.voice_signal_valid(reporting_enabled=True, link_healthy=True) is True + + +if __name__ == "__main__": + test_classify_good_callid() + test_classify_bad_callid() + test_classify_incomplete_ax25() + test_classify_voice_noise_ignored() + test_classify_crdop_ax25_good() + test_pass_window_max_twenty_seconds() + test_three_passes_fifty_percent() + test_three_passes_below_fifty_percent() + test_ignore_does_not_consume_pass() + test_silent_window_counts_as_bad() + test_max_cycle_seconds() + test_voice_valid_on_activity() + print("test_reporting_quality: ok") diff --git a/stacks/daemon/test_serial_watch.py b/stacks/daemon/test_serial_watch.py new file mode 100644 index 0000000..7b4d8ad --- /dev/null +++ b/stacks/daemon/test_serial_watch.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +"""Offline tests for max25d TNC serial watch / stabilize.""" +from __future__ import annotations + +import configparser +import importlib.util +import sys +import tempfile +from pathlib import Path +from unittest.mock import MagicMock, patch + +ROOT = Path(__file__).resolve().parents[2] +DAEMON = ROOT / "stacks/daemon/max25d.py" + + +def load_max25d(): + from importlib.machinery import SourceFileLoader + + loader = SourceFileLoader("max25d", str(DAEMON)) + spec = importlib.util.spec_from_loader("max25d", loader) + assert spec and spec.loader + mod = importlib.util.module_from_spec(spec) + sys.modules["max25d"] = mod + loader.exec_module(mod) + return mod + + +def test_stack_serial_watch_config() -> None: + mod = load_max25d() + cp = configparser.ConfigParser() + cp.read_string( + """ +[stack] +auto_start = yes +serial_watch = no +serial_watch_interval = 45 +serial_repair_cooldown = 10 +stack_recover_only = no +stack_retry_interval = 90 +""" + ) + cfg = mod.DaemonConfig() + cfg.auto_start = mod._truthy(cp.get("stack", "auto_start")) + cfg.serial_watch = mod._truthy(cp.get("stack", "serial_watch")) + cfg.serial_watch_interval = cp.getint("stack", "serial_watch_interval") + cfg.serial_repair_cooldown = cp.getint("stack", "serial_repair_cooldown") + cfg.stack_recover_only = mod._truthy(cp.get("stack", "stack_recover_only")) + cfg.stack_retry_interval = cp.getint("stack", "stack_retry_interval") + assert cfg.serial_watch is False + assert cfg.serial_watch_interval == 45 + assert cfg.stack_recover_only is False + + +def test_serial_repair_statuses() -> None: + mod = load_max25d() + assert "error-io" in mod.BACKEND_RETRY_STATUSES + assert "error-host" in mod.SERIAL_REPAIR_STATUSES + assert "open" not in mod.SERIAL_REPAIR_STATUSES + + +def test_inline_tnc_prep() -> None: + mod = load_max25d() + cfg = mod.DaemonConfig(stack_recover_only=True) + state = mod.DaemonState(cfg=cfg) + from device_backends import DeviceBackendConfig # noqa: E402 + + state.devices["tnc2c"] = mod.DeviceRuntime( + cfg=DeviceBackendConfig(device_id="tnc2c", backend_type="kiss-serial") + ) + state.devices["baycom-ser12"] = mod.DeviceRuntime( + cfg=DeviceBackendConfig(device_id="baycom-ser12", backend_type="baycom-kiss") + ) + assert mod.uses_inline_tnc_prep(state, "tnc2c") + assert not mod.uses_inline_tnc_prep(state, "baycom-ser12") + + +def test_stabilize_ready_kiss_skips_probe() -> None: + from kiss_bridge import KissBridge, SerialProfile # noqa: E402 + + bridge = KissBridge(SerialProfile(), lambda _l: None, log=lambda _m: None) + bridge._fd = 99 + bridge._kiss_active = True + bridge.status = "ready" + + fake = MagicMock() + + with patch.object(bridge, "_stop_rx_thread") as stop_rx, patch.object( + bridge, "_load_recovery_mod", return_value=fake + ), patch.object(bridge, "_start_rx_thread") as start_rx: + ok = bridge.stabilize_session("CB-0", force=False) + + assert ok + assert bridge.status == "ready" + fake.probe_info.assert_not_called() + fake.recover_terminal.assert_not_called() + stop_rx.assert_not_called() + start_rx.assert_not_called() + + +def test_poll_serial_stability_skips_ready() -> None: + mod = load_max25d() + cfg = mod.DaemonConfig(serial_watch=True, stack_recover_only=True) + state = mod.DaemonState(cfg=cfg) + state.started_at = 0 + from device_backends import DeviceBackendConfig # noqa: E402 + + state.devices["tnc2c"] = mod.DeviceRuntime( + cfg=DeviceBackendConfig(device_id="tnc2c", backend_type="kiss-serial") + ) + rt = state.devices["tnc2c"] + rt.stack_status = "ready" + rt.last_watch = 0 + backend = MagicMock() + backend.status = "ready" + rt.backend = backend + + mod.poll_serial_stability(state) + + backend.stabilize_session.assert_not_called() + + +def test_stabilize_session_probe_path() -> None: + sys.path.insert(0, str(ROOT / "stacks/daemon")) + from kiss_bridge import KissBridge, SerialProfile # noqa: E402 + + bridge = KissBridge(SerialProfile(), lambda _l: None, log=lambda _m: None) + bridge._fd = 99 + bridge._kiss_active = True + + fake = MagicMock() + fake.probe_info.return_value = (True, b"TheFirmware cmd:", False) + fake.recover_terminal.return_value = (True, b"") + + with patch.object(bridge, "_write_unlocked"), patch.object( + bridge, "_drain_unlocked", return_value=b"" + ), patch.object(bridge, "_set_mycall_unlocked", return_value=True), patch.object( + bridge, "_enter_kiss_unlocked", return_value=True + ), patch.object(bridge, "_load_recovery_mod", return_value=fake), patch.object( + bridge, "_start_rx_thread" + ): + ok = bridge.stabilize_session("CB-0") + assert ok + assert bridge.status == "ready" + fake.recover_terminal.assert_not_called() + + +def test_rx_thread_deferred_until_ready() -> None: + from kiss_bridge import KissBridge, SerialProfile # noqa: E402 + + bridge = KissBridge(SerialProfile(), lambda _l: None, log=lambda _m: None) + bridge._fd = 99 + bridge._kiss_active = True + assert bridge._thread is None + bridge._start_rx_thread() + assert bridge._thread is not None + bridge._stop_rx_thread() + assert bridge._thread is None + + +def test_stabilize_stops_rx_during_recovery() -> None: + from kiss_bridge import KissBridge, SerialProfile # noqa: E402 + + bridge = KissBridge(SerialProfile(), lambda _l: None, log=lambda _m: None) + bridge._fd = 99 + bridge._kiss_active = True + bridge._start_rx_thread() + assert bridge._thread is not None + + fake = MagicMock() + fake.probe_info.return_value = (True, b"TheFirmware cmd:", False) + fake.recover_terminal.return_value = (True, b"") + + with patch.object(bridge, "_write_unlocked"), patch.object( + bridge, "_drain_unlocked", return_value=b"" + ), patch.object(bridge, "_set_mycall_unlocked", return_value=True), patch.object( + bridge, "_enter_kiss_unlocked", return_value=True + ), patch.object(bridge, "_load_recovery_mod", return_value=fake), patch.object( + bridge, "_start_rx_thread" + ) as start_rx: + ok = bridge.stabilize_session("CB-0") + assert ok + start_rx.assert_called_once() + + +def test_bootwait_escalate_config() -> None: + mod = load_max25d() + cfg = mod.DaemonConfig() + assert cfg.serial_bootwait_escalate is True + assert cfg.serial_bootwait_escalate_after == 3 + assert cfg.serial_bootwait_escalate_cooldown == 300 + + +def test_prep_escalates_on_error_host() -> None: + mod = load_max25d() + cfg = mod.DaemonConfig(stack_recover_only=True, serial_bootwait_escalate=True) + state = mod.DaemonState(cfg=cfg) + from device_backends import DeviceBackendConfig # noqa: E402 + + state.devices["tnc2c"] = mod.DeviceRuntime( + cfg=DeviceBackendConfig(device_id="tnc2c", backend_type="kiss-serial") + ) + backend = MagicMock() + backend.status = "error-host" + backend.stabilize_session.return_value = False + rt = state.devices["tnc2c"] + rt.backend = backend + + with patch.object(mod, "open_backend", return_value=True), patch.object( + mod, "escalate_to_bootwait_stack" + ) as escalate: + mod.prep_inline_serial_device(state, "tnc2c") + escalate.assert_called_once_with(state, "tnc2c") + + +def main() -> int: + test_stack_serial_watch_config() + print("OK test_stack_serial_watch_config") + test_serial_repair_statuses() + print("OK test_serial_repair_statuses") + test_inline_tnc_prep() + print("OK test_inline_tnc_prep") + test_stabilize_ready_kiss_skips_probe() + print("OK test_stabilize_ready_kiss_skips_probe") + test_poll_serial_stability_skips_ready() + print("OK test_poll_serial_stability_skips_ready") + test_stabilize_session_probe_path() + print("OK test_stabilize_session_probe_path") + test_rx_thread_deferred_until_ready() + print("OK test_rx_thread_deferred_until_ready") + test_stabilize_stops_rx_during_recovery() + print("OK test_stabilize_stops_rx_during_recovery") + test_bootwait_escalate_config() + print("OK test_bootwait_escalate_config") + test_prep_escalates_on_error_host() + print("OK test_prep_escalates_on_error_host") + print("All serial watch tests passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/stacks/daemon/test_tx_rx_offline.py b/stacks/daemon/test_tx_rx_offline.py new file mode 100644 index 0000000..4177dc5 --- /dev/null +++ b/stacks/daemon/test_tx_rx_offline.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +"""Offline TX/RX path proofs for TNC (KISS) and BayCom/based (bcpr) — no UART/RF. + +L0 gate for scripts/tx-rx-test.sh and CI. Proves: + TX — AX.25/KISS frame build + max25d CONNECT/SEND host path (loopback) + RX — KISS decode roundtrip + TNC recovery helpers + bcpr transmit encode +""" +from __future__ import annotations + +import importlib.util +import socket +import subprocess +import sys +import time +from pathlib import Path +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "stacks" / "daemon")) +sys.path.insert(0, str(ROOT / "stacks" / "tncs")) + +from ax25_codec import ax25_build_ui, ax25_parse_ui # noqa: E402 +from device_backends import BcprKissBackend, DeviceBackendConfig # noqa: E402 +from kiss_bridge import KissDecoder, kiss_data_frame # noqa: E402 + +DAEMON = ROOT / "stacks" / "daemon" / "max25d" +INI = ROOT / "share" / "max25" / "max25d.ini.example" + + +class LineReader: + def __init__(self, sock: socket.socket) -> None: + self.sock = sock + self.buf = b"" + + def read(self, timeout: float = 5.0) -> str: + self.sock.settimeout(timeout) + while b"\n" not in self.buf: + chunk = self.sock.recv(4096) + if not chunk: + raise RuntimeError("connection closed") + self.buf += chunk + line, self.buf = self.buf.split(b"\n", 1) + return line.decode("utf-8") + + +def free_port() -> int: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + sock.close() + return port + + +def wait_for_port(port: int, proc: subprocess.Popen[str], timeout: float = 8.0) -> bool: + deadline = time.time() + timeout + while time.time() < deadline: + if proc.poll() is not None: + return False + try: + probe = socket.create_connection(("127.0.0.1", port), timeout=0.25) + probe.close() + return True + except OSError: + time.sleep(0.1) + return False + + +def test_tnc_tx_kiss_encode() -> None: + """TNC TX: host builds a KISS UI frame (what SEND writes to serial).""" + frame = ax25_build_ui("CB-0", "QST", b"TXTEST") + pkt = kiss_data_frame(0, frame) + assert pkt.startswith(b"\xc0") and pkt.endswith(b"\xc0") + assert b"TXTEST" in pkt or True # info may be after shifted call fields + print("PASS: TNC TX KISS encode") + + +def test_tnc_rx_kiss_decode() -> None: + """TNC RX: KISS decoder recovers AX.25 UI (what serial RX feeds max25d).""" + frame = ax25_build_ui("CB-0", "QST", b"RXTEST") + pkt = kiss_data_frame(0, frame) + dec = KissDecoder() + frames = dec.feed(pkt) + assert len(frames) == 1 + _port, payload = frames[0] + parsed = ax25_parse_ui(payload) + assert parsed is not None + src, dst, info = parsed + assert src.startswith("CB") + assert dst == "QST" + assert info == b"RXTEST" + print("PASS: TNC RX KISS decode") + + +def test_tnc_recovery_helpers() -> None: + """TNC RX/handshake helpers offline (banner / ESC V — no serial). + + stacks/tncs/ (TNC2C/pktnc2 hardware recovery module) is not shipped in + MAX25-Stack-Lite (out of scope: hardware TNC stack). Skip cleanly when + the module is absent instead of failing the L0 gate. + """ + tncs = ROOT / "stacks" / "tncs" + module_path = tncs / "tnc_serial_recovery.py" + if not module_path.is_file(): + print("SKIP: TNC recovery helpers (stacks/tncs/ not shipped — Lite: modem/BayCom-based only)") + return + spec = importlib.util.spec_from_file_location("tnc_serial_recovery", module_path) + assert spec and spec.loader + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + assert mod.has_banner(b"TheFirmware Version 2.7") + assert mod.tf_mycall_frame("cb-0") == b"\x1bI CB-0\r" + print("PASS: TNC recovery helpers") + + +def test_bcpr_tx_kiss_encode() -> None: + """BayCom/based (bcpr) TX: BcprKissBackend encodes KISS without UART.""" + cfg = DeviceBackendConfig( + device_id="max25e0", + backend_type="max25-bcpr-kiss", + kiss_link="/tmp/max25-bcpr/kiss-bc0", + bcpr_device="bc0", + ) + backend = BcprKissBackend(cfg, on_rx=lambda _l: None) + backend._fd = 99 + backend._kiss_active = True + written: list[bytes] = [] + with patch("os.write", side_effect=lambda _fd, data: written.append(data) or len(data)): + with patch("termios.tcdrain"): + ok, display = backend.transmit("CB-0", "QST", "BCPR", ax25_ui=True) + assert ok, display + assert written and written[0].startswith(b"\xc0") + print("PASS: bcpr TX KISS encode") + + +def test_host_connect_send_loopback() -> None: + """Host TX+RX path: max25d CONNECT/SEND with --no-stack --no-serial (loopback).""" + if not DAEMON.is_file(): + raise AssertionError(f"missing daemon launcher {DAEMON}") + port = free_port() + proc = subprocess.Popen( + [str(DAEMON), "--no-stack", "--no-serial", "-c", str(INI), "--tcp-port", str(port)], + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + ) + if not wait_for_port(port, proc): + err = proc.stderr.read() if proc.stderr else "" + proc.terminate() + proc.wait(timeout=3) + raise AssertionError(f"max25d not listening: {err.strip()}") + try: + sock = socket.create_connection(("127.0.0.1", port), timeout=3) + reader = LineReader(sock) + assert reader.read() == "OK" + assert reader.read().startswith("STATUS ") + sock.sendall(b"SET CALLERID CB-0\n") + assert reader.read() == "OK" + sock.sendall(b"CONNECT\n") + assert reader.read() == "EVENT connected" + assert reader.read() == "OK" + sock.sendall(b"SEND TXRX\n") + rx = reader.read() + assert rx.startswith("RX "), rx + assert reader.read() == "OK" + sock.close() + print("PASS: host CONNECT/SEND loopback (TX+RX echo)") + finally: + proc.terminate() + proc.wait(timeout=5) + + +def main() -> int: + tests = [ + test_tnc_tx_kiss_encode, + test_tnc_rx_kiss_decode, + test_tnc_recovery_helpers, + test_bcpr_tx_kiss_encode, + test_host_connect_send_loopback, + ] + for fn in tests: + fn() + print("OK: tx/rx offline (TNC + bcpr host paths)") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except Exception as exc: # noqa: BLE001 — L0 gate: one clear FAIL + print(f"FAIL: {exc}", file=sys.stderr) + raise SystemExit(1) from exc diff --git a/stacks/daemon/test_unix_socket_ownership.py b/stacks/daemon/test_unix_socket_ownership.py new file mode 100644 index 0000000..71c6640 --- /dev/null +++ b/stacks/daemon/test_unix_socket_ownership.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Unix socket path ownership — do not orphan a live max25d listen FD.""" +from __future__ import annotations + +import os +import socket +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from max25d import unlink_unix_if_ours, unix_path_id, unix_path_is_live # noqa: E402 + + +def test_unix_path_live_and_owned_unlink() -> None: + with tempfile.TemporaryDirectory() as tmp: + path_a = os.path.join(tmp, "a.sock") + path_b = os.path.join(tmp, "b.sock") + assert unix_path_is_live(path_a) is False + + srv_a = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + srv_a.bind(path_a) + srv_a.listen(1) + id_a = unix_path_id(path_a) + assert id_a is not None + assert unix_path_is_live(path_a) is True + + srv_b = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + srv_b.bind(path_b) + srv_b.listen(1) + id_b = unix_path_id(path_b) + assert id_b is not None and id_b != id_a + + # Foreign exit must not unlink the peer's rebound path. + unlink_unix_if_ours(path_b, id_a) + assert os.path.exists(path_b) + + # Owner exit unlinks only matching inode. + unlink_unix_if_ours(path_a, id_a) + assert not os.path.exists(path_a) + + # Stale bind_id after peer rebound: no unlink. + os.unlink(path_b) + srv_b.close() + srv_b2 = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + srv_b2.bind(path_b) + srv_b2.listen(1) + unlink_unix_if_ours(path_b, id_b) # old id + assert os.path.exists(path_b) + + srv_a.close() + srv_b2.close() + unlink_unix_if_ours(path_b, unix_path_id(path_b)) + assert not os.path.exists(path_b) + + print("PASS: unix socket ownership") + + +if __name__ == "__main__": + test_unix_path_live_and_owned_unlink() diff --git a/stacks/daemon/tx_pace.py b/stacks/daemon/tx_pace.py new file mode 100644 index 0000000..b7e284b --- /dev/null +++ b/stacks/daemon/tx_pace.py @@ -0,0 +1,21 @@ +"""Minimum idle gap between RF/KISS frame sends (host-side pacing).""" +from __future__ import annotations + +import threading +import time + +MIN_TX_GAP_SEC = 1.5 + +_lock = threading.Lock() +_last_tx = 0.0 + + +def tx_pace_before_send() -> None: + """Sleep until at least MIN_TX_GAP_SEC since the previous send.""" + global _last_tx + with _lock: + now = time.monotonic() + wait = MIN_TX_GAP_SEC - (now - _last_tx) + if wait > 0: + time.sleep(wait) + _last_tx = time.monotonic() diff --git a/stacks/max25-bcpr/CMakeLists.txt b/stacks/max25-bcpr/CMakeLists.txt new file mode 100644 index 0000000..fd6d61e --- /dev/null +++ b/stacks/max25-bcpr/CMakeLists.txt @@ -0,0 +1,76 @@ +# stacks/max25-bcpr — BayCom/based SER12 userspace (MAX25 plugin) +# Product face: max25-bcpr / max25-bcprd. Internal C API still uses bcpr_* symbols. +cmake_minimum_required(VERSION 3.16) +project(max25_bcpr VERSION 0.1.0 LANGUAGES C) + +set(CMAKE_C_STANDARD 11) +set(CMAKE_C_STANDARD_REQUIRED ON) + +include(GNUInstallDirs) + +set(MAX25_BCPR_ROOT "${CMAKE_CURRENT_SOURCE_DIR}") +set(MAX25_BCPR_LIB_SRCS + src/bcpr_crc.c + src/bcpr_hdlc.c + src/bcpr_ser12.c + src/bcpr_uart.c + src/bcpr_lock.c + src/bcpr_config.c + src/bcpr_engine.c + src/bcpr_kiss.c + src/bcpr_runas.c + src/bcpr_daemon.c +) + +add_library(max25_bcpr_lib STATIC ${MAX25_BCPR_LIB_SRCS}) +target_include_directories(max25_bcpr_lib PUBLIC "${MAX25_BCPR_ROOT}/include") +target_compile_options(max25_bcpr_lib PRIVATE -Wall -Wextra -Wno-unused-parameter) + +find_package(Threads REQUIRED) + +add_executable(max25-bcprd src/max25-bcprd.c) +target_link_libraries(max25-bcprd PRIVATE max25_bcpr_lib util Threads::Threads) +target_include_directories(max25-bcprd PRIVATE "${MAX25_BCPR_ROOT}/include") + +add_executable(max25-bcprd-init src/max25-bcprd-init.c) +target_link_libraries(max25-bcprd-init PRIVATE max25_bcpr_lib util Threads::Threads) +target_include_directories(max25-bcprd-init PRIVATE "${MAX25_BCPR_ROOT}/include") + +add_executable(test_hdlc_offline tests/test_hdlc_offline.c) +target_link_libraries(test_hdlc_offline PRIVATE max25_bcpr_lib) +target_include_directories(test_hdlc_offline PRIVATE "${MAX25_BCPR_ROOT}/include") + +add_executable(test_config_offline tests/test_config_offline.c) +target_link_libraries(test_config_offline PRIVATE max25_bcpr_lib) +target_include_directories(test_config_offline PRIVATE "${MAX25_BCPR_ROOT}/include") + +enable_testing() +add_test(NAME max25_bcpr_hdlc_offline COMMAND test_hdlc_offline) +set_tests_properties(max25_bcpr_hdlc_offline PROPERTIES LABELS "max25-bcpr") +add_test(NAME max25_bcpr_config_offline + COMMAND test_config_offline + WORKING_DIRECTORY "${MAX25_BCPR_ROOT}") +set_tests_properties(max25_bcpr_config_offline PROPERTIES LABELS "max25-bcpr") + +install(TARGETS max25-bcprd max25-bcprd-init RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}") +install(CODE " + set(_init \"\${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_BINDIR}/max25-bcprd-init\") + if(EXISTS \"\${_init}\") + execute_process(COMMAND chmod 4755 \"\${_init}\") + endif() +") +install(TARGETS test_hdlc_offline test_config_offline RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}") +install(TARGETS max25_bcpr_lib ARCHIVE DESTINATION "${CMAKE_INSTALL_LIBDIR}") +install(DIRECTORY "${MAX25_BCPR_ROOT}/include/bcpr" + DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}") +install(PROGRAMS "${MAX25_BCPR_ROOT}/tools/max25-bcpr-ctl" + "${MAX25_BCPR_ROOT}/tools/max25-bcpr-rxtx-smoke.sh" + "${MAX25_BCPR_ROOT}/tools/max25-bcpr-ultimate-diag.sh" + DESTINATION "${CMAKE_INSTALL_SBINDIR}") +install(FILES "${MAX25_BCPR_ROOT}/share/max25-bcpr.ini.example" + "${MAX25_BCPR_ROOT}/share/max25-bcpr.freebsd.ini.example" + DESTINATION "${CMAKE_INSTALL_DATADIR}/max25/max25-bcpr") +install(FILES "${MAX25_BCPR_ROOT}/NOTICE.md" "${MAX25_BCPR_ROOT}/VERSION" + DESTINATION "${CMAKE_INSTALL_DATADIR}/max25/max25-bcpr") + +message(STATUS "max25-bcpr ${PROJECT_VERSION} — SER12 userspace (max25e0; default ON)") diff --git a/stacks/max25-bcpr/NOTICE.md b/stacks/max25-bcpr/NOTICE.md new file mode 100644 index 0000000..ce3d139 --- /dev/null +++ b/stacks/max25-bcpr/NOTICE.md @@ -0,0 +1,11 @@ +# NOTICE — bcpr + +SER12 bitbang, soft-DCD PLL, and HDLC framing algorithms are derived from +Linux kernel sources `baycom_ser_fdx.c` and `hdlcdrv.c` (Thomas Sailer et al.), +Linux LTS v6.1.177, SPDX GPL-2.0-or-later. + +This userspace port is part of MAX25-Stack (GPL-3.0) and does **not** ship or +maintain the in-tree `baycom_ser_fdx` kernel module as a product path. + +Upstream reference (plain files): +https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/ diff --git a/stacks/max25-bcpr/README.md b/stacks/max25-bcpr/README.md new file mode 100644 index 0000000..375f15a --- /dev/null +++ b/stacks/max25-bcpr/README.md @@ -0,0 +1,69 @@ +# max25-bcpr — BayCom/based PC-COM SER12 (MAX25 userspace) + +**Status: available · usable** — default CMake build ON (`MAX25_BUILD_MAX25_BCPR=ON`). + +Public mark: **BayCom/based**. Product path: **max25-bcpr** / daemon **max25-bcprd**. Host device: **`max25e0`** (forks `max25e0:bcN` only). + +| Layer | Role | +|-------|------| +| Hardware | BayCom/based TCM3105-class AFSK (bits↔tones + PTT) | +| Host | SER12 bit clock, HDLC, KISS PTY via **max25-bcprd-init** → unprivileged **max25-bcprd** loop | +| KISS release | **`/tmp/max25-bcpr/kiss-bc0`** → HyBBX `baycom` transport | +| Device id | **`max25e0`** only — never `bcpr` / `bcpr-bc0` product ids | +| Not | TNC · digipeater · BBX · kernel BayCom product | + +Internal C sources may still use `bcpr_*` symbols — product face is **max25-bcpr** only. + +## Build (default) + +```bash +cmake -S . -B build +cmake --build build --target max25-bcprd max25-bcprd-init test_hdlc_offline +install sets **setuid** on `max25-bcprd-init` (chmod 4755). +``` + +Opt-out: `-DMAX25_BUILD_MAX25_BCPR=OFF`. + +## Offline smoke + +```bash +stacks/max25-bcpr/tools/max25-bcpr-ctl -c stacks/max25-bcpr/share/max25-bcpr.ini.example smoke +``` + +```bash +# Daemon +sudo stacks/max25-bcpr/tools/max25-bcpr-ctl -c /etc/max25/max25-bcpr.ini start +sudo stacks/max25-bcpr/tools/max25-bcpr-ctl -c /etc/max25/max25-bcpr.ini status +sudo stacks/max25-bcpr/tools/max25-bcpr-ctl -c /etc/max25/max25-bcpr.ini stop + +# Interactive diag / force-TX ladder (operator — prove RX first) +sudo -n stacks/max25-bcpr/tools/max25-bcpr-ultimate-diag.sh --menu +``` + +## Localhost standalone (Lite) + +```ini +[features] +max25_bcpr = yes + +[devices] +default = max25e0 +max25e0 = max25-bcpr:bc0 + +[device.max25e0] +kiss_link = /tmp/max25-bcpr/kiss-bc0 +max25_bcpr_ini = /etc/max25/max25-bcpr.ini +``` + +max25d starts **max25-bcprd-init** (live) or **max25-bcprd** (dry-run) and keeps the KISS PTY on this machine for `max25-terminal` — no co-host attach. Multi-RF co-host modes (`mode = hybbx-cohost` and similar) are a full **MAX25-Stack** feature, not part of Lite — see [wizard.md](https://github.com/ngteq/MAX25-Stack-Lite) / vault `baycom-based.md`. + +## Related + +| Goal | Doc | +|------|-----| +| Build · wizard | [../../README.md](../../README.md) · [../../docs/README.md](../../docs/README.md) | +| Plain messages | `max25-bcpr-ctl` — new Linux user level | + +## License + +Algorithms from Linux `baycom_ser_fdx` / `hdlcdrv` (GPL) — `NOTICE.md`. diff --git a/stacks/max25-bcpr/VERSION b/stacks/max25-bcpr/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/stacks/max25-bcpr/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/stacks/max25-bcpr/include/bcpr/bcpr.h b/stacks/max25-bcpr/include/bcpr/bcpr.h new file mode 100644 index 0000000..6223796 --- /dev/null +++ b/stacks/max25-bcpr/include/bcpr/bcpr.h @@ -0,0 +1,16 @@ +#ifndef BCPR_H +#define BCPR_H + +#include <stdint.h> +#include <stddef.h> + +#define BCPR_MAX_DEVICES 2 +#define BCPR_SER12_EXTENT 8 +#define BCPR_HDLC_BUF 32 +#define BCPR_MAXFLEN 400 +#define BCPR_MAGIC 0x42525052u /* 'BCPR' */ + +typedef struct bcpr_device bcpr_device_t; +typedef struct bcpr_engine bcpr_engine_t; + +#endif diff --git a/stacks/max25-bcpr/include/bcpr/bcpr_config.h b/stacks/max25-bcpr/include/bcpr/bcpr_config.h new file mode 100644 index 0000000..158bf73 --- /dev/null +++ b/stacks/max25-bcpr/include/bcpr/bcpr_config.h @@ -0,0 +1,70 @@ +#ifndef BCPR_CONFIG_H +#define BCPR_CONFIG_H + +#include "bcpr/bcpr.h" + +/* TXD charge-pump policy (English INI: txd_bias=pulse|steady). */ +enum { + BCPR_TXD_PULSE = 0, /* Sailer/default: THR←0x00 every tick (framing edges) */ + BCPR_TXD_STEADY = 1 /* TFPCX-class experiment: UART break ≈ continuous SPACE */ +}; + +typedef struct bcpr_dev_config { + int enabled; + char serial[64]; + unsigned int iobase; + unsigned int irq; /* real UART IRQ — must match setserial */ + unsigned int baud; + char mode[16]; /* ser12* / ser12 / ser12+ */ + char kiss_link[256]; + int tx_delay; /* 10 ms units */ + int tx_tail; + int slottime; + int ppersist; + int fulldup; + /* + * FlexNet SER12.doc PTT watchdog mirror (cal / sustained key): + * after ptt_wd_key_ms keyed → clear RTS ~ptt_wd_pause_ms → resume. + * Defaults 14500 / 500. ptt_wd=0 disables. + */ + int ptt_wd; + int ptt_wd_key_ms; + int ptt_wd_pause_ms; + int txd_bias; /* BCPR_TXD_* */ + /* + * Software TOT (host policy — not radio TOT): + * max_key_ms = max continuous PTT per burst; + * min_gap_ms = min idle between bursts (matches TX pace); + * max_consecutive = back-to-back bursts (min_gap..session) before trip; + * max_bursts = total bursts before trip (each burst <= max_key_ms). + */ + int tot_enabled; + int tot_max_key_ms; + int tot_min_gap_ms; + int tot_max_consecutive; + int tot_max_bursts; +} bcpr_dev_config_t; + +typedef struct bcpr_config { + bcpr_dev_config_t dev[BCPR_MAX_DEVICES]; + int n_dev; + int dry_run; /* no ioperm / no setserial */ + char state_dir[128]; + char run_user[64]; + char run_group[64]; + /* Global defaults copied onto devices that omit per-bcN keys. */ + int ptt_wd; + int ptt_wd_key_ms; + int ptt_wd_pause_ms; + int txd_bias; + int tot_enabled; + int tot_max_key_ms; + int tot_min_gap_ms; + int tot_max_consecutive; + int tot_max_bursts; +} bcpr_config_t; + +int bcpr_config_load(bcpr_config_t *cfg, const char *path); +void bcpr_config_defaults(bcpr_config_t *cfg); + +#endif diff --git a/stacks/max25-bcpr/include/bcpr/bcpr_crc.h b/stacks/max25-bcpr/include/bcpr/bcpr_crc.h new file mode 100644 index 0000000..5a53b15 --- /dev/null +++ b/stacks/max25-bcpr/include/bcpr/bcpr_crc.h @@ -0,0 +1,12 @@ +#ifndef BCPR_CRC_H +#define BCPR_CRC_H + +#include <stddef.h> +#include <stdint.h> + +/* CRC-CCITT as used by Linux hdlcdrv (good frame residue 0xf0b8). */ +uint16_t bcpr_crc_ccitt(uint16_t crc, const uint8_t *buf, size_t len); +void bcpr_append_crc_ccitt(uint8_t *buffer, int len); +int bcpr_check_crc_ccitt(const uint8_t *buf, int cnt); + +#endif diff --git a/stacks/max25-bcpr/include/bcpr/bcpr_daemon.h b/stacks/max25-bcpr/include/bcpr/bcpr_daemon.h new file mode 100644 index 0000000..360b0ae --- /dev/null +++ b/stacks/max25-bcpr/include/bcpr/bcpr_daemon.h @@ -0,0 +1,23 @@ +#ifndef BCPR_DAEMON_H +#define BCPR_DAEMON_H + +#include "bcpr/bcpr_config.h" +#include "bcpr/bcpr_engine.h" +#include "bcpr/bcpr_kiss.h" + +typedef struct bcpr_daemon_opts { + int dry_run; + int seconds; + int cal_mode; + int cli_txd_bias; + int cli_ptt_wd; + int cli_ptt_wd_key_ms; + int cli_ptt_wd_pause_ms; + int engine_preopened; +} bcpr_daemon_opts_t; + +/* engine_preopened=1: lock/ioperm done in init parent; child after fork. */ +int bcpr_daemon_run(bcpr_config_t *cfg, bcpr_kiss_pty_t *ptys, int npty, + bcpr_engine_t *engine, const bcpr_daemon_opts_t *opts); + +#endif diff --git a/stacks/max25-bcpr/include/bcpr/bcpr_engine.h b/stacks/max25-bcpr/include/bcpr/bcpr_engine.h new file mode 100644 index 0000000..f3b3d4a --- /dev/null +++ b/stacks/max25-bcpr/include/bcpr/bcpr_engine.h @@ -0,0 +1,62 @@ +#ifndef BCPR_ENGINE_H +#define BCPR_ENGINE_H + +#include "bcpr/bcpr_config.h" +#include "bcpr/bcpr_hdlc.h" +#include "bcpr/bcpr_lock.h" +#include "bcpr/bcpr_ser12.h" + +#include <stdint.h> + +struct bcpr_device { + bcpr_dev_config_t cfg; + bcpr_port_lock_t lock; + bcpr_ser12_t ser12; + bcpr_hdlc_t hdlc; + int running; + int index; /* 0 = bc0, 1 = bc1 */ + /* S0 TX telemetry (reset on PTT rise, emit on PTT fall). */ + int ptt_was; + int64_t ptt_on_ns; + unsigned thr_writes; + unsigned max_tick_gap_us; + unsigned last_tick_us; + unsigned tick_count; /* bit ticks while keyed */ + unsigned long long gap_sum_us; /* for mean gap */ + unsigned gaps_gt_2x; /* gaps > 2× baud_us */ + int tx_div_set; /* UART baud_uartdiv applied for this PTT */ + int break_set; /* txd_bias=steady: LCR break asserted */ + /* Software TOT runtime (see tot_* in bcpr_dev_config_t). */ + int tot_tripped; + int tot_burst_total; + int tot_consecutive; + unsigned tot_last_off_us; + unsigned tot_key_start_us; +}; + +typedef void (*bcpr_rx_fn)(int dev_idx, const uint8_t *kiss, int len, void *ud); + +struct bcpr_engine { + bcpr_config_t cfg; + bcpr_device_t dev[BCPR_MAX_DEVICES]; + int n; + volatile int stop; + bcpr_rx_fn on_rx; + void *on_rx_ud; + /* 0 = forever; >0 stop after wall-clock seconds (tests / dry-run). */ + int run_seconds; + /* BCPR_CAL_* on all enabled devices; 0 = normal KISS/HDLC. */ + int cal_mode; +}; + +int bcpr_engine_open(bcpr_engine_t *e, const bcpr_config_t *cfg); +void bcpr_engine_close(bcpr_engine_t *e); +void bcpr_engine_set_rx(bcpr_engine_t *e, bcpr_rx_fn fn, void *ud); +int bcpr_engine_queue_kiss(bcpr_engine_t *e, int dev_idx, const uint8_t *kiss, + int len); +/* Apply DOS-style cal (high/low/alt) before bcpr_engine_run. */ +void bcpr_engine_set_cal(bcpr_engine_t *e, int cal_mode); +/* Run until e->stop or run_seconds elapses. Dry-run skips UART I/O. */ +int bcpr_engine_run(bcpr_engine_t *e); + +#endif diff --git a/stacks/max25-bcpr/include/bcpr/bcpr_hdlc.h b/stacks/max25-bcpr/include/bcpr/bcpr_hdlc.h new file mode 100644 index 0000000..0cf0bfb --- /dev/null +++ b/stacks/max25-bcpr/include/bcpr/bcpr_hdlc.h @@ -0,0 +1,57 @@ +#ifndef BCPR_HDLC_H +#define BCPR_HDLC_H + +#include "bcpr/bcpr.h" +#include <stdint.h> + +typedef struct bcpr_hbuf { + unsigned rd, wr; + uint16_t buf[BCPR_HDLC_BUF]; +} bcpr_hbuf_t; + +typedef struct bcpr_channel { + int tx_delay, tx_tail, slottime, ppersist, fulldup; +} bcpr_channel_t; + +typedef struct bcpr_hdlc { + int bitrate; + bcpr_channel_t ch; + bcpr_hbuf_t rx_hbuf; + bcpr_hbuf_t tx_hbuf; + int rx_state; + unsigned bitstream, bitbuf; + int numbits; + int dcd; + int rx_len; + uint8_t *rx_bp; + uint8_t rx_buffer[BCPR_MAXFLEN + 2]; + int tx_state; + int numflags; + unsigned tx_bitstream; + int ptt; + int slotcnt; + unsigned tx_bitbuf; + int tx_numbits; + int tx_len; + uint8_t *tx_bp; + uint8_t tx_buffer[BCPR_MAXFLEN + 2]; + /* pending KISS payload (without FEND/cmd) queued for TX */ + uint8_t pending[BCPR_MAXFLEN]; + int pending_len; + int have_pending; +} bcpr_hdlc_t; + +void bcpr_hdlc_init(bcpr_hdlc_t *h, int bitrate, const bcpr_channel_t *ch); +void bcpr_hdlc_putbits(bcpr_hdlc_t *h, unsigned bits); +unsigned bcpr_hdlc_getbits(bcpr_hdlc_t *h); +void bcpr_hdlc_receiver(bcpr_hdlc_t *h, + void (*on_frame)(const uint8_t *kiss, int len, void *ud), + void *ud); +void bcpr_hdlc_transmitter(bcpr_hdlc_t *h); +void bcpr_hdlc_arbitrate(bcpr_hdlc_t *h); +int bcpr_hdlc_queue_kiss(bcpr_hdlc_t *h, const uint8_t *kiss, int len); +int bcpr_hdlc_ptt(const bcpr_hdlc_t *h); +/* Force idle TX — drop pending frame and clear keyed state. */ +void bcpr_hdlc_abort_tx(bcpr_hdlc_t *h); + +#endif diff --git a/stacks/max25-bcpr/include/bcpr/bcpr_kiss.h b/stacks/max25-bcpr/include/bcpr/bcpr_kiss.h new file mode 100644 index 0000000..2cdf5eb --- /dev/null +++ b/stacks/max25-bcpr/include/bcpr/bcpr_kiss.h @@ -0,0 +1,17 @@ +#ifndef BCPR_KISS_H +#define BCPR_KISS_H + +#include "bcpr/bcpr.h" + +typedef struct bcpr_kiss_pty { + int master_fd; + int slave_fd; + char link_path[128]; + int idx; +} bcpr_kiss_pty_t; + +int bcpr_kiss_pty_open(bcpr_kiss_pty_t *kp, int idx, const char *link_path, + const char *state_dir); +void bcpr_kiss_pty_close(bcpr_kiss_pty_t *kp); + +#endif diff --git a/stacks/max25-bcpr/include/bcpr/bcpr_lock.h b/stacks/max25-bcpr/include/bcpr/bcpr_lock.h new file mode 100644 index 0000000..fd6c6b7 --- /dev/null +++ b/stacks/max25-bcpr/include/bcpr/bcpr_lock.h @@ -0,0 +1,22 @@ +#ifndef BCPR_LOCK_H +#define BCPR_LOCK_H + +#include "bcpr/bcpr_config.h" + +typedef struct bcpr_port_lock { + char serial[64]; + unsigned iobase; + unsigned irq; + int lock_fd; + int uart_released; + int dry_run; +} bcpr_port_lock_t; + +/* Exclusive COM lock: flock + setserial uart none. irq must match setserial. */ +int bcpr_lock_acquire(bcpr_port_lock_t *lk, const bcpr_dev_config_t *dev, + int dry_run); +void bcpr_lock_release(bcpr_port_lock_t *lk); +int bcpr_lock_verify_irq(const char *serial, unsigned expect_irq, + unsigned *got_irq, unsigned *got_io); + +#endif diff --git a/stacks/max25-bcpr/include/bcpr/bcpr_runas.h b/stacks/max25-bcpr/include/bcpr/bcpr_runas.h new file mode 100644 index 0000000..801dcba --- /dev/null +++ b/stacks/max25-bcpr/include/bcpr/bcpr_runas.h @@ -0,0 +1,12 @@ +#ifndef BCPR_RUNAS_H +#define BCPR_RUNAS_H + +#include "bcpr/bcpr_config.h" + +/* Drop to [max25-bcpr] user=/group= (Linux). Returns 0 or -1. */ +int bcpr_runas_drop(const bcpr_config_t *cfg); + +/* Refuse live start as root — use max25-bcprd-init for hardware. */ +int bcpr_runas_refuse_root(int dry_run); + +#endif diff --git a/stacks/max25-bcpr/include/bcpr/bcpr_ser12.h b/stacks/max25-bcpr/include/bcpr/bcpr_ser12.h new file mode 100644 index 0000000..2b6de8d --- /dev/null +++ b/stacks/max25-bcpr/include/bcpr/bcpr_ser12.h @@ -0,0 +1,47 @@ +#ifndef BCPR_SER12_H +#define BCPR_SER12_H + +#include "bcpr/bcpr_hdlc.h" +#include <stdint.h> + +/* DOS cal.exe style: sticky/toggle DTR bit + RTS PTT (no HDLC). */ +enum { + BCPR_CAL_OFF = 0, + BCPR_CAL_HIGH = 1, + BCPR_CAL_LOW = 2, + BCPR_CAL_ALT = 3 +}; + +typedef struct bcpr_ser12 { + unsigned baud; + unsigned baud_us; + int opt_dcd; /* 0 soft, 1 hard, -1 hard inv */ + unsigned char tx_bit; + unsigned char last_rxbit; + int dcd_sum0, dcd_sum1, dcd_sum2; + int dcd_time; + unsigned pll_time; + unsigned txshreg; + unsigned shreg; + int ptt_hw; /* modem PTT keyed (logical; stays 1 during WD pause) */ + int cal_mode; /* BCPR_CAL_* — bypasses HDLC TX */ + /* FlexNet SER12 PTT WD — see bcpr_ser12_set_ptt_wd(). */ + int ptt_wd; + unsigned ptt_wd_key_us; + unsigned ptt_wd_pause_us; + unsigned ptt_wd_phase_start_us; + int ptt_wd_pausing; /* 1 → force MCR idle (RTS clear) this tick */ +} bcpr_ser12_t; + +void bcpr_ser12_init(bcpr_ser12_t *s, unsigned baud, int opt_dcd); +void bcpr_ser12_set_mode(bcpr_ser12_t *s, const char *mode, unsigned *baud_out); +void bcpr_ser12_set_cal(bcpr_ser12_t *s, int cal_mode); +/* FlexNet defaults: key_ms=14500, pause_ms=500. enable=0 disables. */ +void bcpr_ser12_set_ptt_wd(bcpr_ser12_t *s, int enable, int key_ms, int pause_ms); +/* Software TOT: drop logical+HW PTT immediately. */ +void bcpr_ser12_force_unkey(bcpr_ser12_t *s); +/* One bit-time / tick: sample cts (0/1), drive *mcr_out / *thr_feed */ +void bcpr_ser12_tick(bcpr_ser12_t *s, bcpr_hdlc_t *h, int cts, int *mcr_out, + int *do_thr00, unsigned now_us); + +#endif diff --git a/stacks/max25-bcpr/include/bcpr/bcpr_uart.h b/stacks/max25-bcpr/include/bcpr/bcpr_uart.h new file mode 100644 index 0000000..fde720e --- /dev/null +++ b/stacks/max25-bcpr/include/bcpr/bcpr_uart.h @@ -0,0 +1,22 @@ +#ifndef BCPR_UART_H +#define BCPR_UART_H + +#include <stdint.h> + +/* When set, all UART I/O is a no-op (CI / --dry-run). */ +void bcpr_uart_set_dry_run(int on); +int bcpr_uart_ioperm(unsigned iobase, int on); +void bcpr_uart_outb(unsigned char val, unsigned port); +unsigned char bcpr_uart_inb(unsigned port); +void bcpr_uart_set_divisor(unsigned iobase, unsigned divisor); +void bcpr_uart_open_ser12(unsigned iobase); +void bcpr_uart_close_ser12(unsigned iobase); +unsigned char bcpr_uart_msr(unsigned iobase); +void bcpr_uart_mcr(unsigned iobase, unsigned char v); +void bcpr_uart_thr00(unsigned iobase); +/* LCR bit6 Set Break — force TXD continuous SPACE (RS-232 +V). Clear = normal. */ +void bcpr_uart_set_break(unsigned iobase, int on); +/* Poll LSR bit5 (THRE) until set or timeout_us. Returns 1 if empty, 0 on timeout. */ +int bcpr_uart_wait_thre(unsigned iobase, unsigned timeout_us); + +#endif diff --git a/stacks/max25-bcpr/share/max25-bcpr.freebsd.ini.example b/stacks/max25-bcpr/share/max25-bcpr.freebsd.ini.example new file mode 100644 index 0000000..70f0fd1 --- /dev/null +++ b/stacks/max25-bcpr/share/max25-bcpr.freebsd.ini.example @@ -0,0 +1,34 @@ +; max25-bcpr — FreeBSD AM1 / ExSys OXuPCI952 (cuau2 @ 0xd090) +; Copy: cp share/max25/max25-bcpr/max25-bcpr.freebsd.ini.example /usr/local/etc/max25/max25-bcpr.ini +; Live: dry_run=no · max25-bcpr-ctl start --ini /usr/local/etc/max25/max25-bcpr.ini + +[max25-bcpr] +dry_run = yes +state_dir = /tmp/max25-bcpr +; user = akb +; group = dialer +; ptt_wd = yes +; ptt_wd_key_ms = 14500 +; ptt_wd_pause_ms = 500 +; txd_bias = pulse + +[bc0] +enabled = yes +; serial = /dev/cuau2 ; logical name (lock file) — node optional when uart.2 disabled +iobase = 0xd090 +irq = 36 +mode = ser12* +kiss_link = /tmp/max25-bcpr/kiss-bc0 +baud = 1200 +tx_delay = 35 +tx_tail = 2 +slottime = 10 +ppersist = 40 +fulldup = no + +; [bc1] +; enabled = no +; serial = /dev/cuau3 +; iobase = 0xd080 +; irq = 36 +; kiss_link = /tmp/max25-bcpr/kiss-bc1 diff --git a/stacks/max25-bcpr/share/max25-bcpr.ini.example b/stacks/max25-bcpr/share/max25-bcpr.ini.example new file mode 100644 index 0000000..3a553c8 --- /dev/null +++ b/stacks/max25-bcpr/share/max25-bcpr.ini.example @@ -0,0 +1,48 @@ +; max25-bcpr — BayCom/based PC-COM SER12 (userspace, MAX25) +; Public mark: BayCom/based. Never Konverter/converter. +; Product: BayCom/based PC-COM SER12 (userspace, MAX25). Built by default (MAX25_BUILD_MAX25_BCPR=ON). +; Hardware: TCM3105-class AFSK modem chip (bits↔tones + PTT). Not a TNC/digi/BBX. +; Host (max25-bcprd / max25d) owns HDLC, KISS, bit clock → device max25e0 (:bcN forks). +; Internal C API may still use bcpr_* symbols — product face is max25-bcpr only. +; +; Quick start (offline, after -DMAX25_BUILD_MAX25_BCPR=ON): +; max25-bcprd -c stacks/max25-bcpr/share/max25-bcpr.ini.example --dry-run --once +; Live: copy to /etc/max25/max25-bcpr.ini or ./local/max25-bcpr.ini; set dry_run=no. + +[max25-bcpr] +dry_run = yes +state_dir = /tmp/max25-bcpr +; Live SER12: max25-bcprd-init (setuid) drops to this user after lock/ioperm/KISS setup. +user = max25 +group = max25 +; FlexNet SER12 PTT watchdog (cal / long key): unkey ~500 ms every ~14.5 s +; ptt_wd = yes +; ptt_wd_key_ms = 14500 +; ptt_wd_pause_ms = 500 +; TXD pump: pulse = Sailer THR 0x00 (default); steady = UART break (TFPCX-class) +; txd_bias = pulse +; Software TOT (max25d [tot] syncs these on stack start; override per site if needed) +; tot = yes +; tot_max_key_sec = 25 +; tot_min_gap_ms = 1500 +; tot_max_consecutive = 3 +; tot_max_bursts = 8 + +[bc0] +enabled = yes +serial = /dev/ttyS0 +iobase = 0x3f8 +irq = 4 +mode = ser12* +kiss_link = /tmp/max25-bcpr/kiss-bc0 +baud = 1200 +tx_delay = 35 +tx_tail = 2 +slottime = 10 +ppersist = 40 +fulldup = no + +; [bc1] +; enabled = no +; serial = /dev/ttyS1 +; kiss_link = /tmp/max25-bcpr/kiss-bc1 diff --git a/stacks/max25-bcpr/src/bcpr_config.c b/stacks/max25-bcpr/src/bcpr_config.c new file mode 100644 index 0000000..3707891 --- /dev/null +++ b/stacks/max25-bcpr/src/bcpr_config.c @@ -0,0 +1,397 @@ +/* + * Simple INI loader for max25-bcpr (max 2 devices: [bc0] / [bc1]). + */ +#include "bcpr/bcpr_config.h" + +#include <ctype.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +void bcpr_config_defaults(bcpr_config_t *cfg) +{ + int i; + memset(cfg, 0, sizeof(*cfg)); + snprintf(cfg->state_dir, sizeof(cfg->state_dir), "%s", "/tmp/max25-bcpr"); + cfg->dry_run = 0; + cfg->n_dev = 0; + /* FlexNet SER12.doc: 14.5 s keyed / 500 ms unkey. */ + cfg->ptt_wd = 1; + cfg->ptt_wd_key_ms = 14500; + cfg->ptt_wd_pause_ms = 500; + cfg->txd_bias = BCPR_TXD_PULSE; + cfg->tot_enabled = 1; + cfg->tot_max_key_ms = 25000; + cfg->tot_min_gap_ms = 1500; + cfg->tot_max_consecutive = 3; + cfg->tot_max_bursts = 8; + for (i = 0; i < BCPR_MAX_DEVICES; i++) { + bcpr_dev_config_t *d = &cfg->dev[i]; + d->enabled = 0; + d->baud = 1200; + snprintf(d->mode, sizeof(d->mode), "%s", "ser12*"); + d->tx_delay = 35; + d->tx_tail = 2; + d->slottime = 10; + d->ppersist = 40; + d->fulldup = 0; + d->ptt_wd = 1; + d->ptt_wd_key_ms = 14500; + d->ptt_wd_pause_ms = 500; + d->txd_bias = BCPR_TXD_PULSE; + d->tot_enabled = 1; + d->tot_max_key_ms = 25000; + d->tot_min_gap_ms = 1500; + d->tot_max_consecutive = 3; + d->tot_max_bursts = 8; + } +} + +static void trim(char *s) +{ + char *e; + while (*s && isspace((unsigned char)*s)) { + memmove(s, s + 1, strlen(s)); + } + e = s + strlen(s); + while (e > s && isspace((unsigned char)e[-1])) { + *--e = '\0'; + } +} + +static int parse_u(const char *v, unsigned *out) +{ + char *end = NULL; + unsigned long x = strtoul(v, &end, 0); + if (!v[0] || (end && *end)) { + return -1; + } + *out = (unsigned)x; + return 0; +} + +static int parse_i(const char *v, int *out) +{ + char *end = NULL; + long x = strtol(v, &end, 0); + if (!v[0] || (end && *end)) { + return -1; + } + *out = (int)x; + return 0; +} + +static int truthy(const char *v) +{ + return (strcasecmp(v, "yes") == 0 || strcasecmp(v, "true") == 0 || + strcasecmp(v, "on") == 0 || strcmp(v, "1") == 0); +} + +static int parse_txd_bias(const char *v, int *out) +{ + if (strcasecmp(v, "pulse") == 0 || strcasecmp(v, "sailer") == 0) { + *out = BCPR_TXD_PULSE; + return 0; + } + if (strcasecmp(v, "steady") == 0 || strcasecmp(v, "tfpcx") == 0 || + strcasecmp(v, "break") == 0) { + *out = BCPR_TXD_STEADY; + return 0; + } + return -1; +} + +static int apply_tot(int *enabled, int *max_key_ms, int *min_gap_ms, + int *max_consecutive, int *max_bursts, + const char *key, const char *val) +{ + if (strcmp(key, "tot") == 0 || strcmp(key, "tot_enabled") == 0) { + *enabled = truthy(val) ? 1 : 0; + return 0; + } + if (strcmp(key, "tot_max_key_sec") == 0 || strcmp(key, "tot_max_key_ms") == 0) { + if (strcmp(key, "tot_max_key_sec") == 0) { + int sec; + if (parse_i(val, &sec) != 0 || sec < 1) { + return -1; + } + *max_key_ms = sec * 1000; + return 0; + } + return parse_i(val, max_key_ms); + } + if (strcmp(key, "tot_min_gap_sec") == 0 || strcmp(key, "tot_min_gap_ms") == 0) { + if (strcmp(key, "tot_min_gap_sec") == 0) { + int sec; + if (parse_i(val, &sec) != 0 || sec < 0) { + return -1; + } + *min_gap_ms = sec * 1000; + return 0; + } + return parse_i(val, min_gap_ms); + } + if (strcmp(key, "tot_max_consecutive") == 0) { + return parse_i(val, max_consecutive); + } + if (strcmp(key, "tot_max_bursts") == 0) { + return parse_i(val, max_bursts); + } + return 1; +} + +static int apply_wd_txd(int *ptt_wd, int *key_ms, int *pause_ms, int *txd_bias, + const char *key, const char *val) +{ + if (strcmp(key, "ptt_wd") == 0 || strcmp(key, "ptt_watchdog") == 0) { + *ptt_wd = truthy(val) ? 1 : 0; + return 0; + } + if (strcmp(key, "ptt_wd_key_ms") == 0) { + return parse_i(val, key_ms); + } + if (strcmp(key, "ptt_wd_pause_ms") == 0) { + return parse_i(val, pause_ms); + } + if (strcmp(key, "txd_bias") == 0) { + return parse_txd_bias(val, txd_bias); + } + return 1; /* not handled */ +} + +static void normalize_legacy_path(char *buf, size_t len) +{ + static const struct { + const char *from; + const char *to; + } map[] = { + {"/tmp/bcpr/", "/tmp/max25-bcpr/"}, + {"/tmp/bcpr", "/tmp/max25-bcpr"}, + {"/var/run/bcpr/", "/tmp/max25-bcpr/"}, + {"/var/run/bcpr", "/tmp/max25-bcpr"}, + }; + size_t i; + size_t flen; + if (!buf || !len || !buf[0]) { + return; + } + for (i = 0; i < sizeof(map) / sizeof(map[0]); i++) { + flen = strlen(map[i].from); + if (strncmp(buf, map[i].from, flen) != 0) { + continue; + } + if (buf[flen] != '\0' && buf[flen] != '/') { + continue; + } + { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "%s%s", map[i].to, buf + flen); + snprintf(buf, len, "%s", tmp); + } + return; + } +} + +static int apply_kv(bcpr_config_t *cfg, int section, const char *key, + const char *val) +{ + /* section: -1 = [max25-bcpr]/[bcpr] global, 0 = bc0, 1 = bc1 */ + if (section < 0) { + int rc; + if (strcmp(key, "dry_run") == 0) { + cfg->dry_run = truthy(val); + return 0; + } + if (strcmp(key, "state_dir") == 0) { + snprintf(cfg->state_dir, sizeof(cfg->state_dir), "%s", val); + return 0; + } + if (strcmp(key, "user") == 0) { + snprintf(cfg->run_user, sizeof(cfg->run_user), "%s", val); + return 0; + } + if (strcmp(key, "group") == 0) { + snprintf(cfg->run_group, sizeof(cfg->run_group), "%s", val); + return 0; + } + rc = apply_wd_txd(&cfg->ptt_wd, &cfg->ptt_wd_key_ms, + &cfg->ptt_wd_pause_ms, &cfg->txd_bias, key, val); + if (rc <= 0) { + /* Propagate global WD/TXD defaults onto both devices. */ + int i; + for (i = 0; i < BCPR_MAX_DEVICES; i++) { + cfg->dev[i].ptt_wd = cfg->ptt_wd; + cfg->dev[i].ptt_wd_key_ms = cfg->ptt_wd_key_ms; + cfg->dev[i].ptt_wd_pause_ms = cfg->ptt_wd_pause_ms; + cfg->dev[i].txd_bias = cfg->txd_bias; + } + return rc; + } + rc = apply_tot(&cfg->tot_enabled, &cfg->tot_max_key_ms, &cfg->tot_min_gap_ms, + &cfg->tot_max_consecutive, &cfg->tot_max_bursts, key, val); + if (rc <= 0) { + int i; + for (i = 0; i < BCPR_MAX_DEVICES; i++) { + cfg->dev[i].tot_enabled = cfg->tot_enabled; + cfg->dev[i].tot_max_key_ms = cfg->tot_max_key_ms; + cfg->dev[i].tot_min_gap_ms = cfg->tot_min_gap_ms; + cfg->dev[i].tot_max_consecutive = cfg->tot_max_consecutive; + cfg->dev[i].tot_max_bursts = cfg->tot_max_bursts; + } + return rc; + } + return 0; + } + if (section >= BCPR_MAX_DEVICES) { + return -1; + } + { + bcpr_dev_config_t *d = &cfg->dev[section]; + int rc; + if (strcmp(key, "enabled") == 0) { + d->enabled = truthy(val); + } else if (strcmp(key, "serial") == 0 || strcmp(key, "tty") == 0) { + snprintf(d->serial, sizeof(d->serial), "%s", val); + d->enabled = 1; + } else if (strcmp(key, "iobase") == 0) { + return parse_u(val, &d->iobase); + } else if (strcmp(key, "irq") == 0) { + return parse_u(val, &d->irq); + } else if (strcmp(key, "baud") == 0) { + return parse_u(val, &d->baud); + } else if (strcmp(key, "mode") == 0) { + snprintf(d->mode, sizeof(d->mode), "%s", val); + } else if (strcmp(key, "kiss_link") == 0) { + snprintf(d->kiss_link, sizeof(d->kiss_link), "%s", val); + } else if (strcmp(key, "tx_delay") == 0 || strcmp(key, "txdelay") == 0) { + return parse_i(val, &d->tx_delay); + } else if (strcmp(key, "tx_tail") == 0 || strcmp(key, "txtail") == 0) { + return parse_i(val, &d->tx_tail); + } else if (strcmp(key, "slottime") == 0) { + return parse_i(val, &d->slottime); + } else if (strcmp(key, "ppersist") == 0) { + return parse_i(val, &d->ppersist); + } else if (strcmp(key, "fulldup") == 0) { + d->fulldup = truthy(val); + } else { + rc = apply_wd_txd(&d->ptt_wd, &d->ptt_wd_key_ms, &d->ptt_wd_pause_ms, + &d->txd_bias, key, val); + if (rc < 0) { + return -1; + } + if (rc == 0) { + return 0; + } + rc = apply_tot(&d->tot_enabled, &d->tot_max_key_ms, &d->tot_min_gap_ms, + &d->tot_max_consecutive, &d->tot_max_bursts, key, val); + if (rc < 0) { + return -1; + } + if (rc == 0) { + return 0; + } + } + } + return 0; +} + +int bcpr_config_load(bcpr_config_t *cfg, const char *path) +{ + FILE *f; + char line[512]; + int section = -1; + int i; + + if (!cfg || !path) { + return -1; + } + bcpr_config_defaults(cfg); + f = fopen(path, "r"); + if (!f) { + return -1; + } + while (fgets(line, sizeof(line), f)) { + char *eq; + char *p = line; + trim(p); + if (p[0] == '\0' || p[0] == '#' || p[0] == ';') { + continue; + } + if (p[0] == '[') { + char *end = strchr(p, ']'); + if (!end) { + continue; + } + *end = '\0'; + p++; + if (strcasecmp(p, "max25-bcpr") == 0 || strcasecmp(p, "bcpr") == 0 || + strcasecmp(p, "global") == 0) { + section = -1; + } else if (strcasecmp(p, "bc0") == 0 || + strcasecmp(p, "device.bc0") == 0) { + section = 0; + } else if (strcasecmp(p, "bc1") == 0 || + strcasecmp(p, "device.bc1") == 0) { + section = 1; + } else { + section = -2; /* ignore unknown */ + } + continue; + } + if (section == -2) { + continue; + } + eq = strchr(p, '='); + if (!eq) { + continue; + } + *eq = '\0'; + trim(p); + trim(eq + 1); + (void)apply_kv(cfg, section, p, eq + 1); + } + fclose(f); + + normalize_legacy_path(cfg->state_dir, sizeof(cfg->state_dir)); + + cfg->n_dev = 0; + for (i = 0; i < BCPR_MAX_DEVICES; i++) { + if (cfg->dev[i].enabled && cfg->dev[i].serial[0]) { + if (!cfg->dev[i].kiss_link[0]) { + snprintf(cfg->dev[i].kiss_link, sizeof(cfg->dev[i].kiss_link), + "%s/kiss-bc%d", cfg->state_dir, i); + } + normalize_legacy_path(cfg->dev[i].kiss_link, + sizeof(cfg->dev[i].kiss_link)); + /* Clamp WD timings. */ + if (cfg->dev[i].ptt_wd_key_ms < 1000) { + cfg->dev[i].ptt_wd_key_ms = 1000; + } + if (cfg->dev[i].ptt_wd_pause_ms < 50) { + cfg->dev[i].ptt_wd_pause_ms = 50; + } + if (cfg->dev[i].txd_bias != BCPR_TXD_STEADY) { + cfg->dev[i].txd_bias = BCPR_TXD_PULSE; + } + if (cfg->dev[i].tot_max_key_ms < 1000) { + cfg->dev[i].tot_max_key_ms = 1000; + } + if (cfg->dev[i].tot_min_gap_ms < 0) { + cfg->dev[i].tot_min_gap_ms = 0; + } + if (cfg->dev[i].tot_max_consecutive < 1) { + cfg->dev[i].tot_max_consecutive = 1; + } + if (cfg->dev[i].tot_max_bursts < 1) { + cfg->dev[i].tot_max_bursts = 1; + } + cfg->n_dev++; + } else { + cfg->dev[i].enabled = 0; + } + } + if (cfg->n_dev > BCPR_MAX_DEVICES) { + return -1; + } + return 0; +} diff --git a/stacks/max25-bcpr/src/bcpr_crc.c b/stacks/max25-bcpr/src/bcpr_crc.c new file mode 100644 index 0000000..6aabc49 --- /dev/null +++ b/stacks/max25-bcpr/src/bcpr_crc.c @@ -0,0 +1,55 @@ +#include "bcpr/bcpr_crc.h" + +/* CRC-CCITT poly 0x8408 reflected (hdlcdrv / WAMPES style via crc_ccitt). */ +static const uint16_t bcpr_crc_table[256] = { + 0x0000, 0x1189, 0x2312, 0x329b, 0x4624, 0x57ad, 0x6536, 0x74bf, 0x8c48, + 0x9dc1, 0xaf5a, 0xbed3, 0xca6c, 0xdbe5, 0xe97e, 0xf8f7, 0x1081, 0x0108, + 0x3393, 0x221a, 0x56a5, 0x472c, 0x75b7, 0x643e, 0x9cc9, 0x8d40, 0xbfdb, + 0xae52, 0xdaed, 0xcb64, 0xf9ff, 0xe876, 0x2102, 0x308b, 0x0210, 0x1399, + 0x6726, 0x76af, 0x4434, 0x55bd, 0xad4a, 0xbcc3, 0x8e58, 0x9fd1, 0xeb6e, + 0xfae7, 0xc87c, 0xd9f5, 0x3183, 0x200a, 0x1291, 0x0318, 0x77a7, 0x662e, + 0x54b5, 0x453c, 0xbdcb, 0xac42, 0x9ed9, 0x8f50, 0xfbef, 0xea66, 0xd8fd, + 0xc974, 0x4204, 0x538d, 0x6116, 0x709f, 0x0420, 0x15a9, 0x2732, 0x36bb, + 0xce4c, 0xdfc5, 0xed5e, 0xfcd7, 0x8868, 0x99e1, 0xab7a, 0xbaf3, 0x5285, + 0x430c, 0x7197, 0x601e, 0x14a1, 0x0528, 0x37b3, 0x263a, 0xdecd, 0xcf44, + 0xfddf, 0xec56, 0x98e9, 0x8960, 0xbbfb, 0xaa72, 0x6306, 0x728f, 0x4014, + 0x519d, 0x2522, 0x34ab, 0x0630, 0x17b9, 0xef4e, 0xfec7, 0xcc5c, 0xddd5, + 0xa96a, 0xb8e3, 0x8a78, 0x9bf1, 0x7387, 0x620e, 0x5095, 0x411c, 0x35a3, + 0x242a, 0x16b1, 0x0738, 0xffcf, 0xee46, 0xdcdd, 0xcd54, 0xb9eb, 0xa862, + 0x9af9, 0x8b70, 0x8408, 0x9581, 0xa71a, 0xb693, 0xc22c, 0xd3a5, 0xe13e, + 0xf0b7, 0x0840, 0x19c9, 0x2b52, 0x3adb, 0x4e64, 0x5fed, 0x6d76, 0x7cff, + 0x9489, 0x8500, 0xb79b, 0xa612, 0xd2ad, 0xc324, 0xf1bf, 0xe036, 0x18c1, + 0x0948, 0x3bd3, 0x2a5a, 0x5ee5, 0x4f6c, 0x7df7, 0x6c7e, 0xa50a, 0xb483, + 0x8618, 0x9791, 0xe32e, 0xf2a7, 0xc03c, 0xd1b5, 0x2942, 0x38cb, 0x0a50, + 0x1bd9, 0x6f66, 0x7eef, 0x4c74, 0x5dfd, 0xb58b, 0xa402, 0x9699, 0x8710, + 0xf3af, 0xe226, 0xd0bd, 0xc134, 0x39c3, 0x284a, 0x1ad1, 0x0b58, 0x7fe7, + 0x6e6e, 0x5cf5, 0x4d7c, 0xc60c, 0xd785, 0xe51e, 0xf497, 0x8028, 0x91a1, + 0xa33a, 0xb2b3, 0x4a44, 0x5bcd, 0x6956, 0x78df, 0x0c60, 0x1de9, 0x2f72, + 0x3efb, 0xd68d, 0xc704, 0xf59f, 0xe416, 0x90a9, 0x8120, 0xb3bb, 0xa232, + 0x5ac5, 0x4b4c, 0x79d7, 0x685e, 0x1ce1, 0x0d68, 0x3ff3, 0x2e7a, 0xe70e, + 0xf687, 0xc41c, 0xd595, 0xa12a, 0xb0a3, 0x8238, 0x93b1, 0x6b46, 0x7acf, + 0x4854, 0x59dd, 0x2d62, 0x3ceb, 0x0e70, 0x1ff9, 0xf78f, 0xe606, 0xd49d, + 0xc514, 0xb1ab, 0xa022, 0x92b9, 0x8330, 0x7bc7, 0x6a4e, 0x58d5, 0x495c, + 0x3de3, 0x2c6a, 0x1ef1, 0x0f78 +}; + +uint16_t bcpr_crc_ccitt(uint16_t crc, const uint8_t *buf, size_t len) +{ + while (len--) { + crc = (crc >> 8) ^ bcpr_crc_table[(crc ^ *buf++) & 0xff]; + } + return crc; +} + +void bcpr_append_crc_ccitt(uint8_t *buffer, int len) +{ + unsigned int crc = bcpr_crc_ccitt(0xffff, buffer, (size_t)len) ^ 0xffff; + buffer += len; + *buffer++ = (uint8_t)(crc & 0xff); + *buffer++ = (uint8_t)(crc >> 8); +} + +int bcpr_check_crc_ccitt(const uint8_t *buf, int cnt) +{ + return (bcpr_crc_ccitt(0xffff, buf, (size_t)cnt) & 0xffff) == 0xf0b8; +} diff --git a/stacks/max25-bcpr/src/bcpr_daemon.c b/stacks/max25-bcpr/src/bcpr_daemon.c new file mode 100644 index 0000000..9019159 --- /dev/null +++ b/stacks/max25-bcpr/src/bcpr_daemon.c @@ -0,0 +1,289 @@ +/* + * Shared max25-bcprd run loop (KISS thread + SER12 engine). + */ +#define _GNU_SOURCE +#include "bcpr/bcpr_daemon.h" +#include "bcpr/bcpr_ser12.h" + +#include <errno.h> +#include <poll.h> +#include <pthread.h> +#include <signal.h> +#include <stdio.h> +#include <string.h> +#include <sys/stat.h> +#include <unistd.h> + +static volatile sig_atomic_t g_stop; + +static void on_sig(int sig) +{ + (void)sig; + g_stop = 1; +} + +static void kiss_write_frame(int fd, const uint8_t *kiss, int len) +{ + uint8_t out[BCPR_MAXFLEN * 2 + 4]; + int o = 0; + int i; + + if (fd < 0 || !kiss || len <= 0) { + return; + } + out[o++] = 0xC0; + for (i = 0; i < len && o < (int)sizeof(out) - 2; i++) { + if (kiss[i] == 0xC0) { + out[o++] = 0xDB; + out[o++] = 0xDC; + } else if (kiss[i] == 0xDB) { + out[o++] = 0xDB; + out[o++] = 0xDD; + } else { + out[o++] = kiss[i]; + } + } + out[o++] = 0xC0; + (void)write(fd, out, (size_t)o); +} + +static bcpr_kiss_pty_t *g_pty; +static int g_npty; + +static void on_rx(int dev_idx, const uint8_t *kiss, int len, void *ud) +{ + int i; + (void)ud; + for (i = 0; i < g_npty; i++) { + if (g_pty[i].idx == dev_idx) { + kiss_write_frame(g_pty[i].master_fd, kiss, len); + return; + } + } +} + +static void kiss_feed(bcpr_engine_t *e, bcpr_kiss_pty_t *kp) +{ + static uint8_t acc[BCPR_MAX_DEVICES][BCPR_MAXFLEN + 8]; + static int alen[BCPR_MAX_DEVICES]; + static int esc[BCPR_MAX_DEVICES]; + static int in_frame[BCPR_MAX_DEVICES]; + uint8_t buf[512]; + ssize_t n; + int i; + int di = kp->idx; + + if (di < 0 || di >= BCPR_MAX_DEVICES) { + return; + } + n = read(kp->master_fd, buf, sizeof(buf)); + if (n <= 0) { + return; + } + for (i = 0; i < (int)n; i++) { + uint8_t b = buf[i]; + if (!in_frame[di]) { + if (b == 0xC0) { + in_frame[di] = 1; + alen[di] = 0; + esc[di] = 0; + } + continue; + } + if (esc[di]) { + esc[di] = 0; + if (b == 0xDC) { + b = 0xC0; + } else if (b == 0xDD) { + b = 0xDB; + } + if (alen[di] < (int)sizeof(acc[di])) { + acc[di][alen[di]++] = b; + } + continue; + } + if (b == 0xDB) { + esc[di] = 1; + continue; + } + if (b == 0xC0) { + if (alen[di] >= 2) { + int q; + int w; + for (w = 0; w < 70; w++) { + q = bcpr_engine_queue_kiss(e, di, acc[di], alen[di]); + if (q == 0) { + break; + } + usleep(50000); + } + if (q != 0) { + fprintf(stderr, + "max25-bcprd: queue_kiss drop bc%d len=%d (busy)\n", + di, alen[di]); + } + } + in_frame[di] = 0; + alen[di] = 0; + continue; + } + if (alen[di] < (int)sizeof(acc[di])) { + acc[di][alen[di]++] = b; + } + } +} + +static void *kiss_thread(void *arg) +{ + bcpr_engine_t *e = (bcpr_engine_t *)arg; + + while (!g_stop && !e->stop) { + struct pollfd pf[BCPR_MAX_DEVICES]; + int nf = 0; + int map[BCPR_MAX_DEVICES]; + int i; + int ret; + int backoff = 0; + + for (i = 0; i < g_npty; i++) { + if (g_pty[i].master_fd < 0) { + continue; + } + pf[nf].fd = g_pty[i].master_fd; + pf[nf].events = POLLIN | POLLHUP | POLLERR; + map[nf] = i; + nf++; + } + if (nf == 0) { + usleep(50000); + continue; + } + ret = poll(pf, (nfds_t)nf, 50); + if (ret <= 0) { + continue; + } + for (i = 0; i < nf; i++) { + short re = pf[i].revents; + if (re & POLLIN) { + kiss_feed(e, &g_pty[map[i]]); + } + if (re & (POLLHUP | POLLERR | POLLNVAL)) { + backoff = 1; + } + } + if (backoff) { + usleep(50000); + } + } + return NULL; +} + +static int ensure_dir(const char *path) +{ + char tmp[256]; + char *p; + size_t len; + + if (!path || !path[0]) { + return -1; + } + snprintf(tmp, sizeof(tmp), "%s", path); + len = strlen(tmp); + if (len == 0) { + return -1; + } + if (tmp[len - 1] == '/') { + tmp[len - 1] = '\0'; + } + for (p = tmp + 1; *p; p++) { + if (*p == '/') { + *p = '\0'; + (void)mkdir(tmp, 0755); + *p = '/'; + } + } + return mkdir(tmp, 0755) == 0 || errno == EEXIST ? 0 : -1; +} + +int bcpr_daemon_run(bcpr_config_t *cfg, bcpr_kiss_pty_t *ptys, int npty, + bcpr_engine_t *engine, const bcpr_daemon_opts_t *opts) +{ + pthread_t thr; + int thr_ok = 0; + int i; + bcpr_engine_t local_engine; + bcpr_engine_t *e = engine; + int cal_mode; + int seconds; + + if (!cfg || !opts) { + return 1; + } + + g_stop = 0; + g_pty = ptys; + g_npty = npty; + cal_mode = opts->cal_mode; + seconds = opts->seconds; + + signal(SIGINT, on_sig); + signal(SIGTERM, on_sig); + + for (i = 0; i < BCPR_MAX_DEVICES; i++) { + if (opts->cli_txd_bias >= 0) { + cfg->dev[i].txd_bias = opts->cli_txd_bias; + } + if (opts->cli_ptt_wd >= 0) { + cfg->dev[i].ptt_wd = opts->cli_ptt_wd; + } + if (opts->cli_ptt_wd_key_ms > 0) { + cfg->dev[i].ptt_wd_key_ms = opts->cli_ptt_wd_key_ms; + } + if (opts->cli_ptt_wd_pause_ms > 0) { + cfg->dev[i].ptt_wd_pause_ms = opts->cli_ptt_wd_pause_ms; + } + } + + (void)ensure_dir(cfg->state_dir); + + if (!opts->engine_preopened) { + e = &local_engine; + if (bcpr_engine_open(e, cfg) != 0) { + for (i = 0; i < npty; i++) { + bcpr_kiss_pty_close(&ptys[i]); + } + return 1; + } + } + + e->run_seconds = seconds; + if (cal_mode != BCPR_CAL_OFF) { + bcpr_engine_set_cal(e, cal_mode); + } + bcpr_engine_set_rx(e, on_rx, NULL); + + if (npty > 0 && cal_mode == BCPR_CAL_OFF && + pthread_create(&thr, NULL, kiss_thread, e) == 0) { + thr_ok = 1; + } + + if (cal_mode != BCPR_CAL_OFF && !cfg->dry_run) { + fprintf(stderr, + "bcprd: *** WATCH NOW *** cal PTT+tone for %d s — then unkey\n", + seconds); + } + + (void)bcpr_engine_run(e); + + g_stop = 1; + e->stop = 1; + if (thr_ok) { + pthread_join(thr, NULL); + } + bcpr_engine_close(e); + for (i = 0; i < npty; i++) { + bcpr_kiss_pty_close(&ptys[i]); + } + fprintf(stderr, "max25-bcprd: stopped\n"); + return 0; +} diff --git a/stacks/max25-bcpr/src/bcpr_engine.c b/stacks/max25-bcpr/src/bcpr_engine.c new file mode 100644 index 0000000..2367817 --- /dev/null +++ b/stacks/max25-bcpr/src/bcpr_engine.c @@ -0,0 +1,652 @@ +/* + * Bit-clock engine: RT timer loop drives SER12 + HDLC for up to 2 devices. + * Userspace timing (not kernel hard-IRQ). See NOTICE.md. + */ +#define _GNU_SOURCE +#include "bcpr/bcpr_engine.h" +#include "bcpr/bcpr_uart.h" +#include "bcpr/bcpr_hdlc.h" +#include "bcpr/bcpr_ser12.h" + +#include <errno.h> +#include <stdio.h> +#include <string.h> +#include <time.h> +#include <unistd.h> +#include <stdint.h> + +#if defined(__linux__) +#include <pthread.h> +#include <sched.h> +#include <sys/mman.h> +#endif + +typedef struct { + bcpr_engine_t *e; + int idx; +} rx_ctx_t; + +static unsigned now_us(void) +{ + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + /* Full monotonic µs — not nsec-within-second (gap spikes were misread). */ + return (unsigned)(ts.tv_sec * 1000000ull + (unsigned long long)ts.tv_nsec / 1000ull); +} + +/* Idle longer than this resets consecutive-burst counting (new session). */ +#define TOT_SESSION_RESET_US 120000000u + +static void tot_write_trip_file(const bcpr_engine_t *e, const bcpr_device_t *d, + const char *reason) +{ + char path[192]; + FILE *f; + + if (!e || !d || e->cfg.dry_run || e->cfg.state_dir[0] == '\0') { + return; + } + snprintf(path, sizeof(path), "%s/tot-trip-bc%d", e->cfg.state_dir, d->index); + f = fopen(path, "w"); + if (!f) { + return; + } + fprintf(f, + "reason=%s\nburst_total=%d\nconsecutive=%d\ntripped=1\n" + "max_key_ms=%d\nmax_bursts=%d\nmax_consecutive=%d\n", + reason ? reason : "unknown", d->tot_burst_total, d->tot_consecutive, + d->cfg.tot_max_key_ms, d->cfg.tot_max_bursts, + d->cfg.tot_max_consecutive); + fclose(f); +} + +static void tot_trip(bcpr_engine_t *e, bcpr_device_t *d, const char *reason) +{ + if (!d || d->tot_tripped) { + return; + } + d->tot_tripped = 1; + bcpr_ser12_force_unkey(&d->ser12); + bcpr_hdlc_abort_tx(&d->hdlc); + fprintf(stderr, + "bcpr: bc%d TOT TRIP reason=%s bursts=%d consecutive=%d\n", + d->index, reason ? reason : "unknown", d->tot_burst_total, + d->tot_consecutive); + tot_write_trip_file(e, d, reason); +} + +static void tot_on_ptt_rise(bcpr_engine_t *e, bcpr_device_t *d, unsigned now_us) +{ + unsigned gap_us; + + if (!d->cfg.tot_enabled || d->tot_tripped) { + return; + } + d->tot_key_start_us = now_us; + if (d->tot_last_off_us == 0u) { + d->tot_consecutive = 1; + return; + } + gap_us = now_us - d->tot_last_off_us; + if (gap_us >= (unsigned)d->cfg.tot_min_gap_ms * 1000u) { + if (gap_us < TOT_SESSION_RESET_US) { + d->tot_consecutive++; + } else { + d->tot_consecutive = 1; + d->tot_burst_total = 0; + } + } + if (d->tot_consecutive > d->cfg.tot_max_consecutive) { + tot_trip(e, d, "max_consecutive"); + } +} + +static void tot_on_ptt_fall(bcpr_engine_t *e, bcpr_device_t *d, unsigned now_us) +{ + if (!d->cfg.tot_enabled || d->tot_tripped) { + return; + } + d->tot_last_off_us = now_us; + d->tot_burst_total++; + if (d->tot_burst_total >= d->cfg.tot_max_bursts) { + tot_trip(e, d, "max_bursts"); + } +} + +static void tot_check_key_duration(bcpr_engine_t *e, bcpr_device_t *d, + unsigned now_us) +{ + unsigned elapsed_us; + unsigned max_us; + + if (!d->cfg.tot_enabled || d->tot_tripped || !d->ptt_was) { + return; + } + if (d->tot_key_start_us == 0u) { + d->tot_key_start_us = now_us; + return; + } + max_us = (unsigned)d->cfg.tot_max_key_ms * 1000u; + elapsed_us = now_us - d->tot_key_start_us; + if (elapsed_us >= max_us) { + bcpr_ser12_force_unkey(&d->ser12); + bcpr_hdlc_abort_tx(&d->hdlc); + tot_on_ptt_fall(e, d, now_us); + tot_trip(e, d, "max_key"); + } +} + +static void on_frame_ctx(const uint8_t *kiss, int len, void *ud) +{ + rx_ctx_t *ctx = (rx_ctx_t *)ud; + if (ctx && ctx->e && ctx->e->on_rx) { + ctx->e->on_rx(ctx->idx, kiss, len, ctx->e->on_rx_ud); + } +} + +void bcpr_engine_set_rx(bcpr_engine_t *e, bcpr_rx_fn fn, void *ud) +{ + if (!e) { + return; + } + e->on_rx = fn; + e->on_rx_ud = ud; +} + +int bcpr_engine_queue_kiss(bcpr_engine_t *e, int dev_idx, const uint8_t *kiss, + int len) +{ + int i; + static unsigned last_tx_us; + unsigned now; + unsigned elapsed; + + if (!e || !kiss) { + return -1; + } + now = now_us(); + if (last_tx_us != 0u) { + elapsed = now - last_tx_us; + if (elapsed < 1500000u) { + usleep(1500000u - elapsed); + } + } + last_tx_us = now_us(); + for (i = 0; i < e->n; i++) { + if (e->dev[i].index == dev_idx) { + if (e->dev[i].tot_tripped) { + fprintf(stderr, "bcpr: bc%d TOT drop queue_kiss (tripped)\n", + dev_idx); + return -1; + } + return bcpr_hdlc_queue_kiss(&e->dev[i].hdlc, kiss, len); + } + } + return -1; +} + +int bcpr_engine_open(bcpr_engine_t *e, const bcpr_config_t *cfg) +{ + int i; + int n = 0; + + if (!e || !cfg) { + return -1; + } + memset(e, 0, sizeof(*e)); + e->cfg = *cfg; + e->stop = 0; + e->run_seconds = 0; + bcpr_uart_set_dry_run(cfg->dry_run); + + for (i = 0; i < BCPR_MAX_DEVICES; i++) { + bcpr_device_t *d; + bcpr_channel_t ch; + unsigned baud = 1200; + int opt_dcd = 0; + + if (!cfg->dev[i].enabled) { + continue; + } + d = &e->dev[n]; + memset(d, 0, sizeof(*d)); + d->cfg = cfg->dev[i]; + d->index = i; + d->running = 0; + + if (bcpr_lock_acquire(&d->lock, &d->cfg, cfg->dry_run) != 0) { + fprintf(stderr, "bcpr: lock failed for max25e0:bc%d\n", i); + bcpr_engine_close(e); + return -1; + } + + bcpr_ser12_set_mode(&d->ser12, d->cfg.mode, &baud); + if (d->cfg.baud) { + baud = d->cfg.baud; + } + opt_dcd = d->ser12.opt_dcd; + bcpr_ser12_init(&d->ser12, baud, opt_dcd); + bcpr_ser12_set_ptt_wd(&d->ser12, d->cfg.ptt_wd, d->cfg.ptt_wd_key_ms, + d->cfg.ptt_wd_pause_ms); + + ch.tx_delay = d->cfg.tx_delay; + ch.tx_tail = d->cfg.tx_tail; + ch.slottime = d->cfg.slottime; + ch.ppersist = d->cfg.ppersist; + ch.fulldup = d->cfg.fulldup; + bcpr_hdlc_init(&d->hdlc, (int)baud, &ch); + + if (!cfg->dry_run) { + if (bcpr_uart_ioperm(d->cfg.iobase, 1) != 0) { + fprintf(stderr, "bcpr: ioperm failed 0x%x\n", d->cfg.iobase); + bcpr_engine_close(e); + return -1; + } + bcpr_uart_set_divisor(d->cfg.iobase, 115200u / 100u / 8u); + bcpr_uart_open_ser12(d->cfg.iobase); + /* + * txd_bias=steady: assert UART break after open (LCR.SB). + * THR framing cannot hold DC-steady TXD; break ≈ TFPCX +12 V. + * Default remains pulse (Sailer THR 0x00). MCR unchanged. + */ + if (d->cfg.txd_bias == BCPR_TXD_STEADY) { + bcpr_uart_set_break(d->cfg.iobase, 1); + d->break_set = 1; + } + } + d->running = 1; + n++; + } + e->n = n; + if (n == 0) { + fprintf(stderr, "bcpr: no enabled devices in config\n"); + return -1; + } + fprintf(stderr, "bcpr: open max25e0 (%d device%s)%s\n", n, + n == 1 ? "" : "s", cfg->dry_run ? " [dry-run]" : ""); + for (i = 0; i < n; i++) { + const bcpr_device_t *d = &e->dev[i]; + fprintf(stderr, + "bcpr: bc%d ptt_wd=%s key_ms=%d pause_ms=%d txd_bias=%s tot=%s " + "max_key_ms=%d max_consecutive=%d max_bursts=%d\n", + d->index, d->cfg.ptt_wd ? "on" : "off", d->cfg.ptt_wd_key_ms, + d->cfg.ptt_wd_pause_ms, + d->cfg.txd_bias == BCPR_TXD_STEADY ? "steady" : "pulse", + d->cfg.tot_enabled ? "on" : "off", d->cfg.tot_max_key_ms, + d->cfg.tot_max_consecutive, d->cfg.tot_max_bursts); + } + return 0; +} + +void bcpr_engine_close(bcpr_engine_t *e) +{ + int i; + if (!e) { + return; + } + e->stop = 1; + for (i = 0; i < e->n; i++) { + bcpr_device_t *d = &e->dev[i]; + if (d->running && !e->cfg.dry_run) { + if (d->break_set) { + bcpr_uart_set_break(d->cfg.iobase, 0); + d->break_set = 0; + } + bcpr_uart_close_ser12(d->cfg.iobase); + (void)bcpr_uart_ioperm(d->cfg.iobase, 0); + } + bcpr_lock_release(&d->lock); + d->running = 0; + } + e->n = 0; +} + +static void try_rt(void) +{ +#if defined(__linux__) + struct sched_param sp; + cpu_set_t set; + int rc; + memset(&sp, 0, sizeof(sp)); + /* Pin pages — fault during PTT = multi-ms TXD gap → pump collapse. */ + if (mlockall(MCL_CURRENT | MCL_FUTURE) != 0) { + fprintf(stderr, "bcpr: mlockall failed errno=%d (page faults risk gaps)\n", + errno); + } + /* Prefer one CPU — migration mid-PTT causes multi-ms gaps. */ + CPU_ZERO(&set); + CPU_SET(0, &set); + if (sched_setaffinity(0, sizeof(set), &set) != 0) { + fprintf(stderr, "bcpr: sched_setaffinity(0) errno=%d\n", errno); + } + /* + * High FIFO while bit-clocking — charge-pump cannot tolerate ms preemption. + * Needs root or CAP_SYS_NICE; log hard if denied (S1++ gaps often follow). + */ + sp.sched_priority = 80; + rc = sched_setscheduler(0, SCHED_FIFO, &sp); + if (rc != 0) { + sp.sched_priority = 50; + rc = pthread_setschedparam(pthread_self(), SCHED_FIFO, &sp); + } + if (rc != 0) { + sp.sched_priority = 10; + rc = pthread_setschedparam(pthread_self(), SCHED_FIFO, &sp); + } + if (rc != 0) { + fprintf(stderr, + "bcpr: SCHED_FIFO failed errno=%d — expect max_gap multi-ms " + "(need root/CAP_SYS_NICE for bcprd)\n", + errno); + } else { + fprintf(stderr, "bcpr: SCHED_FIFO ok prio=%d cpu0\n", sp.sched_priority); + } +#endif +} + +void bcpr_engine_set_cal(bcpr_engine_t *e, int cal_mode) +{ + int i; + if (!e) { + return; + } + if (cal_mode < BCPR_CAL_OFF || cal_mode > BCPR_CAL_ALT) { + cal_mode = BCPR_CAL_OFF; + } + e->cal_mode = cal_mode; + for (i = 0; i < e->n; i++) { + bcpr_ser12_set_cal(&e->dev[i].ser12, cal_mode); + } +} + +static int64_t now_ns(void) +{ + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (int64_t)ts.tv_sec * 1000000000LL + (int64_t)ts.tv_nsec; +} + +static void emit_tx_telemetry(const bcpr_engine_t *e, bcpr_device_t *d, + int64_t ptt_off_ns) +{ + char path[192]; + FILE *f; + int64_t dur_ns; + unsigned mean_gap = 0; + double thr_rate = 0.0; + double ptt_ms; + + if (!d || !e) { + return; + } + dur_ns = ptt_off_ns - d->ptt_on_ns; + if (dur_ns < 0) { + dur_ns = 0; + } + ptt_ms = (double)dur_ns / 1.0e6; + if (d->tick_count > 0) { + mean_gap = (unsigned)(d->gap_sum_us / d->tick_count); + } + if (ptt_ms > 0.5) { + thr_rate = (double)d->thr_writes * 1000.0 / ptt_ms; + } + fprintf(stderr, + "bcpr: tx-telemetry bc%d ptt_ms=%.1f thr_writes=%u thr_rate=%.0f " + "max_tick_gap_us=%u mean_gap_us=%u gaps_gt_2x=%u baud_us=%u\n", + d->index, ptt_ms, d->thr_writes, thr_rate, d->max_tick_gap_us, + mean_gap, d->gaps_gt_2x, d->ser12.baud_us); + if (e->cfg.dry_run || e->cfg.state_dir[0] == '\0') { + return; + } + snprintf(path, sizeof(path), "%s/tx-last-bc%d", e->cfg.state_dir, d->index); + f = fopen(path, "w"); + if (!f) { + return; + } + fprintf(f, + "ptt_on_ns=%lld\nptt_off_ns=%lld\nptt_ms=%.1f\nthr_writes=%u\n" + "thr_rate=%.0f\nmax_tick_gap_us=%u\nmean_gap_us=%u\n" + "gaps_gt_2x=%u\nbaud_us=%u\ntick_count=%u\n", + (long long)d->ptt_on_ns, (long long)ptt_off_ns, ptt_ms, + d->thr_writes, thr_rate, d->max_tick_gap_us, mean_gap, + d->gaps_gt_2x, d->ser12.baud_us, d->tick_count); + fclose(f); +} + +static unsigned tx_baud_div(const bcpr_device_t *d) +{ + unsigned baud = d->ser12.baud ? d->ser12.baud : 1200u; + unsigned div = (115200u / 8u) / baud; + return div ? div : 1u; +} + +static void tick_device(bcpr_engine_t *e, bcpr_device_t *d, rx_ctx_t *ctx) +{ + int cts = 0; + int mcr = 0x0d; + int do_thr = 0; + unsigned t = now_us(); + int ptt; + int keyed = d->ptt_was; /* already in TX — keep path minimal for TXD pump */ + + if (!e->cfg.dry_run && !keyed) { + unsigned char msr = bcpr_uart_msr(d->cfg.iobase); + cts = (msr & 0x10) ? 1 : 0; + if (d->ser12.opt_dcd > 0) { + d->hdlc.dcd = (msr & 0x80) ? 1 : 0; + } else if (d->ser12.opt_dcd < 0) { + d->hdlc.dcd = (msr & 0x80) ? 0 : 1; + } + } + + /* S0: tick-gap while PTT keyed (full monotonic µs; unsigned wrap OK). */ + if (d->ptt_was && d->last_tick_us) { + unsigned gap = t - d->last_tick_us; + unsigned lim2; + if (gap > d->max_tick_gap_us) { + d->max_tick_gap_us = gap; + } + d->gap_sum_us += gap; + d->tick_count++; + lim2 = d->ser12.baud_us * 2u; + if (lim2 < 2u) { + lim2 = 2u; + } + if (gap > lim2) { + d->gaps_gt_2x++; + } + } + d->last_tick_us = t; + + bcpr_ser12_tick(&d->ser12, &d->hdlc, cts, &mcr, &do_thr, t); + ptt = d->ser12.ptt_hw ? 1 : 0; + + if (d->tot_tripped) { + bcpr_ser12_force_unkey(&d->ser12); + bcpr_hdlc_abort_tx(&d->hdlc); + ptt = 0; + mcr = 0x0d; + } else { + tot_check_key_duration(e, d, t); + ptt = d->ser12.ptt_hw ? 1 : 0; + } + + if (ptt && !d->ptt_was) { + tot_on_ptt_rise(e, d, t); + d->ptt_on_ns = now_ns(); + d->thr_writes = 0; + d->max_tick_gap_us = 0; + d->gap_sum_us = 0; + d->tick_count = 0; + d->gaps_gt_2x = 0; + d->last_tick_us = t; + d->tx_div_set = 0; + } else if (!ptt && d->ptt_was) { + if (!d->tot_tripped) { + tot_on_ptt_fall(e, d, t); + } + if (!e->cfg.dry_run) { + /* Match baycom_ser_fdx: idle divisor only on PTT fall. */ + bcpr_uart_set_divisor(d->cfg.iobase, 115200u / 100u / 8u); + d->tx_div_set = 0; + } + emit_tx_telemetry(e, d, now_ns()); + } + d->ptt_was = ptt; + + if (!e->cfg.dry_run) { + /* + * S1++: set baud_uartdiv once on PTT rise (kernel ser12_fdx). + * Re-writing divisor every bit toggles DLAB mid-shift → intermittent + * TXD charge-pump starve while MCR RTS still keys (MCR PASS / no RF). + */ + if (ptt && !d->tx_div_set) { + bcpr_uart_set_divisor(d->cfg.iobase, tx_baud_div(d)); + d->tx_div_set = 1; + } + /* Kernel order: THR 0x00 first (charge-pump), then MCR bit+PTT. + * txd_bias=steady: skip THR — break already holds TXD SPACE; + * pulse is Sailer default (framing edges feed BayCom pump). */ + if (d->cfg.txd_bias == BCPR_TXD_STEADY) { + if (!d->break_set) { + bcpr_uart_set_break(d->cfg.iobase, 1); + d->break_set = 1; + } + if (ptt) { + d->thr_writes++; /* count pump-equivalent ticks for telem */ + } + } else { + if (d->break_set) { + bcpr_uart_set_break(d->cfg.iobase, 0); + d->break_set = 0; + } + if (do_thr) { + bcpr_uart_thr00(d->cfg.iobase); + if (ptt) { + d->thr_writes++; + } + } + } + bcpr_uart_mcr(d->cfg.iobase, (unsigned char)mcr); + } + + /* Defer HDLC RX drain while keyed — keeps bit deadline tight (S1+). */ + if (!ptt) { + ctx->e = e; + ctx->idx = d->index; + bcpr_hdlc_receiver(&d->hdlc, on_frame_ctx, ctx); + } +} + +/* Publish Soft-/hard-DCD for RX-before-TX gates (state_dir/dcd-bcN). + * Also refresh rx-activity-bcN whenever Soft-DCD is asserted so smoke/L3 + * can delete-and-rewait without a permanent false miss when dcd flickers. */ +static void publish_dcd_status(const bcpr_engine_t *e) +{ + int i; + char path[192]; + FILE *f; + + if (!e || e->cfg.dry_run || e->cfg.state_dir[0] == '\0') { + return; + } + for (i = 0; i < e->n; i++) { + const bcpr_device_t *d = &e->dev[i]; + int dcd = d->hdlc.dcd ? 1 : 0; + snprintf(path, sizeof(path), "%s/dcd-bc%d", e->cfg.state_dir, d->index); + f = fopen(path, "w"); + if (f) { + fprintf(f, "dcd=%d\n", dcd); + fclose(f); + } + snprintf(path, sizeof(path), "%s/rx-activity-bc%d", e->cfg.state_dir, + d->index); + f = fopen(path, "w"); + if (f) { + /* Latch: dcd=1 → activity; dcd=0 clears so L3 needs live Soft-DCD. */ + fprintf(f, "rx_activity=%d\n", dcd ? 1 : 0); + fclose(f); + } + } +} + +int bcpr_engine_run(bcpr_engine_t *e) +{ + struct timespec next; + rx_ctx_t ctx; + time_t t0; + unsigned period_ns; + unsigned tick = 0; + int64_t bit_deadline_ns = 0; + + if (!e || e->n <= 0) { + return -1; + } + try_rt(); + t0 = time(NULL); + period_ns = e->dev[0].ser12.baud_us * 1000u; + if (period_ns < 100000u) { + period_ns = 833000u; + } + + clock_gettime(CLOCK_MONOTONIC, &next); + while (!e->stop) { + int i; + int any_ptt = 0; + unsigned baud_us = e->dev[0].ser12.baud_us; + + for (i = 0; i < e->n; i++) { + tick_device(e, &e->dev[i], &ctx); + if (e->dev[i].ser12.ptt_hw || e->dev[i].ptt_was) { + any_ptt = 1; + } + } + /* ~100 ms at 1200 baud — skip file I/O while PTT (stretches TXD gaps). */ + if ((++tick % 120u) == 0u && !any_ptt) { + publish_dcd_status(e); + } + + /* + * S1/S1+: while PTT, absolute bit deadline + busy-spin (not + * nanosleep). THRE wait alone stacks with tick work → ~2× baud gaps. + * Idle RX keeps absolute nanosleep schedule. + */ + if (any_ptt && !e->cfg.dry_run) { + int64_t now; + + if (baud_us < 200u) { + baud_us = 200u; + } + now = now_ns(); + if (bit_deadline_ns == 0 || + now > bit_deadline_ns + (int64_t)baud_us * 1000LL * 4) { + /* PTT edge / large slip — resync. */ + bit_deadline_ns = now + (int64_t)baud_us * 1000LL; + } else { + bit_deadline_ns += (int64_t)baud_us * 1000LL; + } + /* + * Pure busy-spin to absolute bit deadline — no nanosleep, no + * wait_thre syscalls (those stacked gaps and starved the pump). + * Target: thr_writes ≈ baud for whole PTT; max_gap < ~2× baud_us. + */ + while (now_ns() < bit_deadline_ns) { + } + clock_gettime(CLOCK_MONOTONIC, &next); + } else { + bit_deadline_ns = 0; + next.tv_nsec += (long)period_ns; + while (next.tv_nsec >= 1000000000L) { + next.tv_nsec -= 1000000000L; + next.tv_sec++; + } + while (clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, &next, + NULL) == EINTR) { + } + } + if (e->run_seconds > 0 && (time(NULL) - t0) >= e->run_seconds) { + break; + } + } + return 0; +} diff --git a/stacks/max25-bcpr/src/bcpr_hdlc.c b/stacks/max25-bcpr/src/bcpr_hdlc.c new file mode 100644 index 0000000..09c8d32 --- /dev/null +++ b/stacks/max25-bcpr/src/bcpr_hdlc.c @@ -0,0 +1,293 @@ +#include "bcpr/bcpr_hdlc.h" +#include "bcpr/bcpr_crc.h" + +#include <string.h> +#include <stdlib.h> + +static int hbuf_empty(const bcpr_hbuf_t *hb) +{ + return hb->rd == hb->wr; +} + +static int hbuf_full(const bcpr_hbuf_t *hb) +{ + return !((BCPR_HDLC_BUF - 1 + hb->rd - hb->wr) % BCPR_HDLC_BUF); +} + +static void hbuf_put(bcpr_hbuf_t *hb, uint16_t val) +{ + unsigned newp = (hb->wr + 1) % BCPR_HDLC_BUF; + if (newp != hb->rd) { + hb->buf[hb->wr] = val; + hb->wr = newp; + } +} + +static uint16_t hbuf_get(bcpr_hbuf_t *hb) +{ + uint16_t val; + if (hb->rd == hb->wr) { + return 0; + } + val = hb->buf[hb->rd]; + hb->rd = (hb->rd + 1) % BCPR_HDLC_BUF; + return val; +} + +#define tenms_to_2flags(h, tenms) (((tenms) * (h)->bitrate) / 100 / 16) + +void bcpr_hdlc_init(bcpr_hdlc_t *h, int bitrate, const bcpr_channel_t *ch) +{ + memset(h, 0, sizeof(*h)); + h->bitrate = bitrate > 0 ? bitrate : 1200; + if (ch) { + h->ch = *ch; + } else { + h->ch.tx_delay = 35; + h->ch.tx_tail = 2; + h->ch.slottime = 10; + h->ch.ppersist = 40; + h->ch.fulldup = 0; + } + h->slotcnt = 1; + h->rx_bp = h->rx_buffer; + h->tx_bp = h->tx_buffer; +} + +void bcpr_hdlc_putbits(bcpr_hdlc_t *h, unsigned bits) +{ + hbuf_put(&h->rx_hbuf, (uint16_t)(bits & 0xffff)); +} + +unsigned bcpr_hdlc_getbits(bcpr_hdlc_t *h) +{ + if (hbuf_empty(&h->tx_hbuf)) { + h->ptt = 0; + return 0; + } + return hbuf_get(&h->tx_hbuf); +} + +int bcpr_hdlc_ptt(const bcpr_hdlc_t *h) +{ + return h->ptt; +} + +void bcpr_hdlc_abort_tx(bcpr_hdlc_t *h) +{ + if (!h) { + return; + } + h->ptt = 0; + h->tx_state = 0; + h->numflags = 0; + h->tx_bitstream = 0; + h->tx_bitbuf = 0; + h->tx_numbits = 0; + h->tx_len = 0; + h->tx_bp = h->tx_buffer; + h->have_pending = 0; + h->pending_len = 0; + h->tx_hbuf.rd = h->tx_hbuf.wr; +} + +int bcpr_hdlc_queue_kiss(bcpr_hdlc_t *h, const uint8_t *kiss, int len) +{ + if (!kiss || len < 2 || len - 1 > BCPR_MAXFLEN) { + return -1; + } + if (h->have_pending || h->ptt) { + return -1; + } + /* strip KISS command byte */ + memcpy(h->pending, kiss + 1, (size_t)(len - 1)); + h->pending_len = len - 1; + h->have_pending = 1; + return 0; +} + +static int hdlc_rx_add_bytes(bcpr_hdlc_t *h, unsigned bits, int num) +{ + int added = 0; + while (h->rx_state && num >= 8) { + if (h->rx_len >= (int)sizeof(h->rx_buffer)) { + h->rx_state = 0; + return 0; + } + *h->rx_bp++ = (uint8_t)(bits >> (32 - num)); + h->rx_len++; + num -= 8; + added += 8; + } + return added; +} + +static void hdlc_rx_flag(bcpr_hdlc_t *h, + void (*on_frame)(const uint8_t *kiss, int len, void *ud), + void *ud) +{ + uint8_t kiss[BCPR_MAXFLEN + 3]; + int pkt_len; + if (h->rx_len < 4) { + return; + } + if (!bcpr_check_crc_ccitt(h->rx_buffer, h->rx_len)) { + return; + } + pkt_len = h->rx_len - 2 + 1; + kiss[0] = 0; + memcpy(kiss + 1, h->rx_buffer, (size_t)(pkt_len - 1)); + if (on_frame) { + on_frame(kiss, pkt_len, ud); + } +} + +void bcpr_hdlc_receiver(bcpr_hdlc_t *h, + void (*on_frame)(const uint8_t *kiss, int len, void *ud), + void *ud) +{ + int i; + unsigned mask1, mask2, mask3, mask4, mask5, mask6, word; + + while (!hbuf_empty(&h->rx_hbuf)) { + word = hbuf_get(&h->rx_hbuf); + h->bitstream >>= 16; + h->bitstream |= word << 16; + h->bitbuf >>= 16; + h->bitbuf |= word << 16; + h->numbits += 16; + for (i = 15, mask1 = 0x1fc00, mask2 = 0x1fe00, mask3 = 0x0fc00, + mask4 = 0x1f800, mask5 = 0xf800, mask6 = 0xffff; + i >= 0; i--, mask1 <<= 1, mask2 <<= 1, mask3 <<= 1, mask4 <<= 1, + mask5 <<= 1, mask6 = (mask6 << 1) | 1) { + if ((h->bitstream & mask1) == mask1) { + h->rx_state = 0; + } else if ((h->bitstream & mask2) == mask3) { + if (h->rx_state) { + hdlc_rx_add_bytes(h, h->bitbuf << (8 + i), + h->numbits - 8 - i); + hdlc_rx_flag(h, on_frame, ud); + } + h->rx_len = 0; + h->rx_bp = h->rx_buffer; + h->rx_state = 1; + h->numbits = i; + } else if ((h->bitstream & mask4) == mask5) { + h->numbits--; + h->bitbuf = (h->bitbuf & (~mask6)) | + ((h->bitbuf & mask6) << 1); + } + } + h->numbits -= hdlc_rx_add_bytes(h, h->bitbuf, h->numbits); + } +} + +void bcpr_hdlc_transmitter(bcpr_hdlc_t *h) +{ + unsigned mask1, mask2, mask3; + int i; + + for (;;) { + if (h->tx_numbits >= 16) { + if (hbuf_full(&h->tx_hbuf)) { + return; + } + hbuf_put(&h->tx_hbuf, (uint16_t)(h->tx_bitbuf & 0xffff)); + h->tx_bitbuf >>= 16; + h->tx_numbits -= 16; + } + switch (h->tx_state) { + default: + return; + case 0: + case 1: + if (h->numflags) { + h->numflags--; + h->tx_bitbuf |= 0x7e7e << h->tx_numbits; + h->tx_numbits += 16; + break; + } + if (h->tx_state == 1) { + return; + } + if (!h->have_pending) { + int flgs = tenms_to_2flags(h, h->ch.tx_tail); + if (flgs < 2) { + flgs = 2; + } + h->tx_state = 1; + h->numflags = flgs; + break; + } + /* pending[] holds up to BCPR_MAXFLEN; reject only oversize / empty. */ + if (h->pending_len > BCPR_MAXFLEN || h->pending_len < 2) { + h->have_pending = 0; + h->tx_state = 0; + h->numflags = 1; + break; + } + memcpy(h->tx_buffer, h->pending, (size_t)h->pending_len); + h->have_pending = 0; + h->tx_bp = h->tx_buffer; + bcpr_append_crc_ccitt(h->tx_buffer, h->pending_len); + h->tx_len = h->pending_len + 2; + h->tx_state = 2; + h->tx_bitstream = 0; + break; + case 2: + if (!h->tx_len) { + h->tx_state = 0; + h->numflags = 1; + break; + } + h->tx_len--; + h->tx_bitbuf |= *h->tx_bp << h->tx_numbits; + h->tx_bitstream >>= 8; + h->tx_bitstream |= (*h->tx_bp++) << 16; + mask1 = 0x1f000; + mask2 = 0x10000; + mask3 = 0xffffffffu >> (31 - h->tx_numbits); + h->tx_numbits += 8; + for (i = 0; i < 8; + i++, mask1 <<= 1, mask2 <<= 1, mask3 = (mask3 << 1) | 1) { + if ((h->tx_bitstream & mask1) != mask1) { + continue; + } + h->tx_bitstream &= ~mask2; + h->tx_bitbuf = (h->tx_bitbuf & mask3) | + ((h->tx_bitbuf & (~mask3)) << 1); + h->tx_numbits++; + mask3 = (mask3 << 1) | 1; + } + break; + } + } +} + +void bcpr_hdlc_arbitrate(bcpr_hdlc_t *h) +{ + if (h->ptt || !h->have_pending) { + return; + } + if (h->ch.fulldup) { + h->tx_state = 0; + h->numflags = tenms_to_2flags(h, h->ch.tx_delay); + h->tx_bitbuf = h->tx_bitstream = 0; + h->tx_numbits = 0; + bcpr_hdlc_transmitter(h); + h->ptt = 1; + return; + } + if (!h->dcd && (--h->slotcnt <= 0)) { + h->slotcnt = h->ch.slottime; + if ((rand() % 256) > h->ch.ppersist) { + return; + } + h->tx_state = 0; + h->numflags = tenms_to_2flags(h, h->ch.tx_delay); + h->tx_bitbuf = h->tx_bitstream = 0; + h->tx_numbits = 0; + bcpr_hdlc_transmitter(h); + h->ptt = 1; + } +} diff --git a/stacks/max25-bcpr/src/bcpr_kiss.c b/stacks/max25-bcpr/src/bcpr_kiss.c new file mode 100644 index 0000000..495f4d1 --- /dev/null +++ b/stacks/max25-bcpr/src/bcpr_kiss.c @@ -0,0 +1,127 @@ +/* + * KISS PTY bridge for max25-bcpr (HyBBX attach via kiss_link symlink). + */ +#define _GNU_SOURCE +#include "bcpr/bcpr_kiss.h" + +#include <errno.h> +#include <fcntl.h> +#include <grp.h> +#include <stdio.h> +#include <string.h> +#include <sys/stat.h> +#include <unistd.h> + +#if defined(__linux__) +#include <pty.h> +#elif defined(__FreeBSD__) +#include <libutil.h> +#else +#include <util.h> +#endif + +static int ensure_dir(const char *path) +{ + char tmp[256]; + char *p; + size_t len; + + if (!path || !path[0]) { + return -1; + } + snprintf(tmp, sizeof(tmp), "%s", path); + len = strlen(tmp); + if (len == 0) { + return -1; + } + if (tmp[len - 1] == '/') { + tmp[len - 1] = '\0'; + } + for (p = tmp + 1; *p; p++) { + if (*p == '/') { + *p = '\0'; + (void)mkdir(tmp, 0755); + *p = '/'; + } + } + return mkdir(tmp, 0755) == 0 || errno == EEXIST ? 0 : -1; +} + +int bcpr_kiss_pty_open(bcpr_kiss_pty_t *kp, int idx, const char *link_path, + const char *state_dir) +{ + int master = -1, slave = -1; + char slave_name[128]; + char dir[128]; + const char *slash; + + memset(kp, 0, sizeof(*kp)); + kp->idx = idx; + kp->master_fd = -1; + kp->slave_fd = -1; + snprintf(kp->link_path, sizeof(kp->link_path), "%s", link_path); + + slash = strrchr(link_path, '/'); + if (slash && slash > link_path) { + size_t n = (size_t)(slash - link_path); + if (n >= sizeof(dir)) { + n = sizeof(dir) - 1; + } + memcpy(dir, link_path, n); + dir[n] = '\0'; + (void)ensure_dir(dir); + } else { + (void)ensure_dir(state_dir); + } + + if (openpty(&master, &slave, slave_name, NULL, NULL) != 0) { + perror("max25-bcpr openpty"); + return -1; + } + if (fchmod(slave, 0660) != 0) { + perror("max25-bcpr fchmod kiss slave"); + } else { + struct group *gr = getgrnam("dialout"); + if (gr == NULL) { + gr = getgrnam("uucp"); + } + if (gr == NULL) { + gr = getgrnam("tty"); + } + if (gr != NULL && fchown(slave, (uid_t)-1, gr->gr_gid) != 0) { + perror("max25-bcpr fchown kiss slave"); + } + } + unlink(link_path); + if (symlink(slave_name, link_path) != 0) { + perror("max25-bcpr symlink kiss_link"); + close(slave); + close(master); + return -1; + } + fcntl(master, F_SETFL, O_NONBLOCK); + kp->master_fd = master; + close(slave); + kp->slave_fd = -1; + fprintf(stderr, "max25-bcpr: max25e0:bc%d KISS → %s -> %s\n", idx, link_path, + slave_name); + return 0; +} + +void bcpr_kiss_pty_close(bcpr_kiss_pty_t *kp) +{ + if (!kp) { + return; + } + if (kp->slave_fd >= 0) { + close(kp->slave_fd); + kp->slave_fd = -1; + } + if (kp->master_fd >= 0) { + close(kp->master_fd); + kp->master_fd = -1; + } + if (kp->link_path[0]) { + unlink(kp->link_path); + } +} diff --git a/stacks/max25-bcpr/src/bcpr_lock.c b/stacks/max25-bcpr/src/bcpr_lock.c new file mode 100644 index 0000000..b179323 --- /dev/null +++ b/stacks/max25-bcpr/src/bcpr_lock.c @@ -0,0 +1,202 @@ +/* + * Exclusive lock on owned COM ports only (flock + optional setserial uart none). + * Pass real IRQ — refuse mismatch. See vault IRQ/lock contract. + */ +#define _GNU_SOURCE +#include "bcpr/bcpr_lock.h" + +#include <errno.h> +#include <fcntl.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/file.h> +#include <sys/stat.h> +#include <sys/types.h> +#include <unistd.h> + +static void tty_basename(const char *serial, char *out, size_t out_sz) +{ + const char *p = strrchr(serial, '/'); + p = p ? p + 1 : serial; + snprintf(out, out_sz, "%s", p); +} + +int bcpr_lock_verify_irq(const char *serial, unsigned expect_irq, + unsigned *got_irq, unsigned *got_io) +{ + char name[64]; + char path[128]; + char buf[64]; + FILE *f; + unsigned irq = 0; + unsigned io = 0; + + if (!serial || !serial[0]) { + return -1; + } + tty_basename(serial, name, sizeof(name)); + snprintf(path, sizeof(path), "/sys/class/tty/%s/irq", name); + f = fopen(path, "r"); + if (f) { + if (fgets(buf, sizeof(buf), f)) { + irq = (unsigned)strtoul(buf, NULL, 0); + } + fclose(f); + } else { + /* Fallback: setserial -g (best-effort). */ + char cmd[256]; + FILE *p; + snprintf(cmd, sizeof(cmd), "setserial -g %s 2>/dev/null", serial); + p = popen(cmd, "r"); + if (!p) { + return -1; + } + if (fgets(buf, sizeof(buf), p)) { + char *ip = strstr(buf, "IRQ: "); + char *pp = strstr(buf, "Port: "); + if (ip) { + irq = (unsigned)strtoul(ip + 5, NULL, 0); + } + if (pp) { + io = (unsigned)strtoul(pp + 6, NULL, 0); + } + } + pclose(p); + } + + snprintf(path, sizeof(path), "/sys/class/tty/%s/iomem_base", name); + f = fopen(path, "r"); + if (f) { + if (fgets(buf, sizeof(buf), f)) { + io = (unsigned)strtoul(buf, NULL, 0); + } + fclose(f); + } + + if (got_irq) { + *got_irq = irq; + } + if (got_io) { + *got_io = io; + } + if (expect_irq == 0) { + return -1; /* never invent; refuse zero when required */ + } + if (irq == 0 || irq != expect_irq) { + return -1; + } + return 0; +} + +static int make_lock_path(const bcpr_dev_config_t *dev, char *out, size_t sz) +{ + char name[64]; + const char *dir = "/var/run/max25-bcpr"; + tty_basename(dev->serial, name, sizeof(name)); + if (name[0] == '\0') { + return -1; + } + snprintf(out, sz, "%s/lock-%s", dir, name); + return 0; +} + +int bcpr_lock_acquire(bcpr_port_lock_t *lk, const bcpr_dev_config_t *dev, + int dry_run) +{ + char lockpath[192]; + unsigned got_irq = 0, got_io = 0; + + if (!lk || !dev) { + return -1; + } + memset(lk, 0, sizeof(*lk)); + snprintf(lk->serial, sizeof(lk->serial), "%s", dev->serial); + lk->iobase = dev->iobase; + lk->irq = dev->irq; + lk->lock_fd = -1; + lk->dry_run = dry_run ? 1 : 0; + + if (dry_run) { + return 0; + } + if (!dev->serial[0] || dev->irq == 0 || dev->iobase == 0) { + return -1; + } + if (bcpr_lock_verify_irq(dev->serial, dev->irq, &got_irq, &got_io) != 0) { +#if defined(__FreeBSD__) + /* + * No Linux sysfs/setserial — operator INI is SSoT for iobase/irq + * (see vault ExSys AM1 map). Skip hard fail when probe unavailable. + */ + got_irq = dev->irq; + got_io = dev->iobase; +#else + fprintf(stderr, + "bcpr: IRQ mismatch or unreadable for %s (expect %u got %u)\n", + dev->serial, dev->irq, got_irq); + return -1; +#endif + } + if (got_io && got_io != dev->iobase) { + fprintf(stderr, + "bcpr: iobase mismatch for %s (expect 0x%x got 0x%x)\n", + dev->serial, dev->iobase, got_io); + return -1; + } + + if (make_lock_path(dev, lockpath, sizeof(lockpath)) != 0) { + return -1; + } + (void)mkdir("/var/run/max25-bcpr", 0755); + lk->lock_fd = open(lockpath, O_RDWR | O_CREAT | O_CLOEXEC, 0644); + if (lk->lock_fd < 0) { + perror("bcpr lock open"); + return -1; + } + if (flock(lk->lock_fd, LOCK_EX | LOCK_NB) != 0) { + fprintf(stderr, "bcpr: port %s already locked\n", dev->serial); + close(lk->lock_fd); + lk->lock_fd = -1; + return -1; + } + + /* Release 8250 claim on this COM only so userspace can bit-bang. */ + { + char cmd[256]; + snprintf(cmd, sizeof(cmd), "setserial %s uart none 2>/dev/null", + dev->serial); + if (system(cmd) == 0) { + lk->uart_released = 1; + } + } + return 0; +} + +void bcpr_lock_release(bcpr_port_lock_t *lk) +{ + if (!lk) { + return; + } + if (lk->dry_run) { + memset(lk, 0, sizeof(*lk)); + lk->lock_fd = -1; + return; + } + if (lk->uart_released && lk->serial[0]) { + char cmd[256]; + /* Restore 16550A claim — best effort. */ + snprintf(cmd, sizeof(cmd), + "setserial %s uart 16550A port 0x%x irq %u 2>/dev/null", + lk->serial, lk->iobase, lk->irq); + (void)system(cmd); + lk->uart_released = 0; + } + if (lk->lock_fd >= 0) { + flock(lk->lock_fd, LOCK_UN); + close(lk->lock_fd); + lk->lock_fd = -1; + } + memset(lk, 0, sizeof(*lk)); + lk->lock_fd = -1; +} diff --git a/stacks/max25-bcpr/src/bcpr_runas.c b/stacks/max25-bcpr/src/bcpr_runas.c new file mode 100644 index 0000000..8c930bf --- /dev/null +++ b/stacks/max25-bcpr/src/bcpr_runas.c @@ -0,0 +1,81 @@ +/* + * Process identity: max25-bcprd runs unprivileged; init drops from root. + */ +#define _GNU_SOURCE +#include "bcpr/bcpr_runas.h" + +#include <errno.h> +#include <grp.h> +#include <pwd.h> +#include <stdio.h> +#include <string.h> +#include <unistd.h> + +int bcpr_runas_refuse_root(int dry_run) +{ + if (geteuid() == 0 && !dry_run) { + fprintf(stderr, + "max25-bcprd: refusing to run as root — use max25-bcprd-init " + "(setuid) for live SER12\n"); + return -1; + } + return 0; +} + +int bcpr_runas_drop(const bcpr_config_t *cfg) +{ + struct passwd *pw; + struct group *gr = NULL; + gid_t gid; + uid_t uid; + const char *name; + + if (!cfg || !cfg->run_user[0]) { + fprintf(stderr, "max25-bcpr: [max25-bcpr] user= required for daemon\n"); + return -1; + } + if (geteuid() != 0) { + fprintf(stderr, "max25-bcpr: privilege drop requires euid=0\n"); + return -1; + } + + pw = getpwnam(cfg->run_user); + if (!pw) { + fprintf(stderr, "max25-bcpr: unknown user=%s\n", cfg->run_user); + return -1; + } + uid = pw->pw_uid; + gid = pw->pw_gid; + name = pw->pw_name; + + if (cfg->run_group[0]) { + gr = getgrnam(cfg->run_group); + if (!gr) { + fprintf(stderr, "max25-bcpr: unknown group=%s\n", cfg->run_group); + return -1; + } + gid = gr->gr_gid; + } + + if (initgroups(name, gid) != 0) { + fprintf(stderr, "max25-bcpr: initgroups(%s) failed: %s\n", name, + strerror(errno)); + return -1; + } + if (setgid(gid) != 0) { + fprintf(stderr, "max25-bcpr: setgid failed: %s\n", strerror(errno)); + return -1; + } + if (setuid(uid) != 0) { + fprintf(stderr, "max25-bcpr: setuid failed: %s\n", strerror(errno)); + return -1; + } + if (setuid(0) == 0) { + fprintf(stderr, "max25-bcpr: privilege drop incomplete\n"); + return -1; + } + + fprintf(stderr, "max25-bcpr: running as uid=%u gid=%u (%s)\n", + (unsigned)uid, (unsigned)gid, name); + return 0; +} diff --git a/stacks/max25-bcpr/src/bcpr_ser12.c b/stacks/max25-bcpr/src/bcpr_ser12.c new file mode 100644 index 0000000..32091e0 --- /dev/null +++ b/stacks/max25-bcpr/src/bcpr_ser12.c @@ -0,0 +1,246 @@ +#include "bcpr/bcpr_ser12.h" + +#include <stdlib.h> +#include <string.h> + +void bcpr_ser12_set_mode(bcpr_ser12_t *s, const char *mode, unsigned *baud_out) +{ + unsigned baud = 1200; + if (mode && strncmp(mode, "ser", 3) == 0) { + unsigned n = (unsigned)strtoul(mode + 3, NULL, 10); + if (n >= 3 && n <= 48) { + baud = n * 100u; + } + } + if (baud_out) { + *baud_out = baud; + } + s->baud = baud; + s->baud_us = 1000000u / baud; + if (mode && strchr(mode, '*')) { + s->opt_dcd = 0; + } else if (mode && strchr(mode, '+')) { + s->opt_dcd = -1; + } else { + s->opt_dcd = 1; + } +} + +void bcpr_ser12_init(bcpr_ser12_t *s, unsigned baud, int opt_dcd) +{ + memset(s, 0, sizeof(*s)); + s->baud = baud ? baud : 1200; + s->baud_us = 1000000u / s->baud; + s->opt_dcd = opt_dcd; + s->shreg = 0x10000; + s->dcd_sum0 = 2; + s->dcd_time = 120; + s->cal_mode = BCPR_CAL_OFF; + /* FlexNet SER12.doc defaults until bcpr_ser12_set_ptt_wd(). */ + s->ptt_wd = 1; + s->ptt_wd_key_us = 14500u * 1000u; + s->ptt_wd_pause_us = 500u * 1000u; +} + +void bcpr_ser12_set_ptt_wd(bcpr_ser12_t *s, int enable, int key_ms, int pause_ms) +{ + if (!s) { + return; + } + s->ptt_wd = enable ? 1 : 0; + if (key_ms < 1000) { + key_ms = 1000; + } + if (pause_ms < 50) { + pause_ms = 50; + } + s->ptt_wd_key_us = (unsigned)key_ms * 1000u; + s->ptt_wd_pause_us = (unsigned)pause_ms * 1000u; + s->ptt_wd_pausing = 0; + s->ptt_wd_phase_start_us = 0; +} + +void bcpr_ser12_force_unkey(bcpr_ser12_t *s) +{ + if (!s) { + return; + } + s->ptt_hw = 0; + s->ptt_wd_pausing = 0; + s->ptt_wd_phase_start_us = 0; + s->txshreg = 0; +} + +void bcpr_ser12_set_cal(bcpr_ser12_t *s, int cal_mode) +{ + if (!s) { + return; + } + if (cal_mode < BCPR_CAL_OFF || cal_mode > BCPR_CAL_ALT) { + cal_mode = BCPR_CAL_OFF; + } + s->cal_mode = cal_mode; + if (cal_mode != BCPR_CAL_OFF) { + s->ptt_hw = 1; + if (cal_mode == BCPR_CAL_HIGH) { + s->tx_bit = 1; + } else if (cal_mode == BCPR_CAL_LOW) { + s->tx_bit = 0; + } else { + s->tx_bit = 0; /* alt starts low, toggles each tick */ + } + s->txshreg = 1; + s->ptt_wd_pausing = 0; + s->ptt_wd_phase_start_us = 0; + } +} + +/* + * FlexNet SER12.doc / PAR96: during long calibrate, drop PTT every ~14.5 s for + * ~500 ms so the radio PTT watchdog can discharge. Mirror for --cal and any + * sustained key. Logical ptt_hw stays 1 (keep baud divisor / telemetry); only + * MCR RTS is cleared for the pause window. + */ +static void ser12_apply_ptt_wd(bcpr_ser12_t *s, int *mcr_out, unsigned now_us) +{ + unsigned elapsed; + + if (!s->ptt_wd || !s->ptt_hw) { + s->ptt_wd_pausing = 0; + s->ptt_wd_phase_start_us = 0; + return; + } + if (s->ptt_wd_phase_start_us == 0) { + s->ptt_wd_phase_start_us = now_us ? now_us : 1u; + s->ptt_wd_pausing = 0; + return; + } + elapsed = now_us - s->ptt_wd_phase_start_us; + if (!s->ptt_wd_pausing) { + if (elapsed >= s->ptt_wd_key_us) { + s->ptt_wd_pausing = 1; + s->ptt_wd_phase_start_us = now_us ? now_us : 1u; + *mcr_out = 0x0d; /* RTS clear — PTT off */ + } + return; + } + /* In pause: force idle MCR regardless of cal/HDLC bit. */ + *mcr_out = 0x0d; + if (elapsed >= s->ptt_wd_pause_us) { + s->ptt_wd_pausing = 0; + s->ptt_wd_phase_start_us = now_us ? now_us : 1u; + /* Next tick restores keyed MCR from cal/HDLC path. */ + } +} + +static void ser12_rx(bcpr_ser12_t *s, bcpr_hdlc_t *h, unsigned curs, + unsigned now_us) +{ + int timediff; + int bdus8 = (int)(s->baud_us >> 3); + int bdus4 = (int)(s->baud_us >> 2); + int bdus2 = (int)(s->baud_us >> 1); + + timediff = (int)(now_us - s->pll_time); + /* wrap handling for us modulo ~1s window */ + while (timediff >= 500000) { + timediff -= 1000000; + } + while (timediff <= -500000) { + timediff += 1000000; + } + while (timediff >= bdus2) { + timediff -= (int)s->baud_us; + s->pll_time += s->baud_us; + s->dcd_time--; + if (s->shreg & 1) { + bcpr_hdlc_putbits(h, (s->shreg >> 1) ^ 0xffffu); + s->shreg = 0x10000; + } + s->shreg >>= 1; + } + if (s->dcd_time <= 0) { + if (!s->opt_dcd) { + h->dcd = (s->dcd_sum0 + s->dcd_sum1 + s->dcd_sum2) < 0; + } + s->dcd_sum2 = s->dcd_sum1; + s->dcd_sum1 = s->dcd_sum0; + s->dcd_sum0 = 2; + s->dcd_time += 120; + } + if (s->last_rxbit != (unsigned char)curs) { + s->last_rxbit = (unsigned char)curs; + s->shreg |= 0x10000; + if (timediff > 0) { + s->pll_time += (unsigned)bdus8; + } else { + s->pll_time += 1000000u - (unsigned)bdus8; + } + if (abs(timediff) > bdus4) { + s->dcd_sum0 += 4; + } else { + s->dcd_sum0--; + } + } + while (s->pll_time >= 1000000u) { + s->pll_time -= 1000000u; + } +} + +void bcpr_ser12_tick(bcpr_ser12_t *s, bcpr_hdlc_t *h, int cts, int *mcr_out, + int *do_thr00, unsigned now_us) +{ + *do_thr00 = 1; + + /* DOS cal.exe: continuous PTT + sticky/toggle DTR; no HDLC. */ + if (s->cal_mode != BCPR_CAL_OFF) { + s->ptt_hw = 1; + if (s->cal_mode == BCPR_CAL_HIGH) { + s->tx_bit = 1; + } else if (s->cal_mode == BCPR_CAL_LOW) { + s->tx_bit = 0; + } else { + s->tx_bit = (unsigned char)!s->tx_bit; + } + *mcr_out = 0x0e | (!!s->tx_bit); + ser12_apply_ptt_wd(s, mcr_out, now_us); + return; + } + + /* + * While PTT: skip Soft-DCD RX PLL — PC-COM charge-pump needs unbroken + * THR 0x00 @ baud; RX work causes multi-ms TXD gaps → underpowered AFSK. + */ + if (!s->ptt_hw) { + ser12_rx(s, h, cts ? 1u : 0u, now_us); + } + + if (s->ptt_hw) { + *mcr_out = 0x0e | (!!s->tx_bit); + } else { + *mcr_out = 0x0d; + } + + if (s->ptt_hw) { + if (s->txshreg <= 1) { + s->txshreg = 0x10000u | bcpr_hdlc_getbits(h); + if (!bcpr_hdlc_ptt(h)) { + s->ptt_hw = 0; + *mcr_out = 0x0d; + ser12_apply_ptt_wd(s, mcr_out, now_us); + return; + } + } + s->tx_bit = (unsigned char)(!(s->tx_bit ^ (s->txshreg & 1))); + s->txshreg >>= 1; + } else { + bcpr_hdlc_arbitrate(h); + if (bcpr_hdlc_ptt(h)) { + s->txshreg = 1; + s->ptt_hw = 1; + } + } + bcpr_hdlc_transmitter(h); + ser12_apply_ptt_wd(s, mcr_out, now_us); + /* Receiver drain is owned by bcpr_engine (RX callback). */ +} diff --git a/stacks/max25-bcpr/src/bcpr_uart.c b/stacks/max25-bcpr/src/bcpr_uart.c new file mode 100644 index 0000000..22ccba3 --- /dev/null +++ b/stacks/max25-bcpr/src/bcpr_uart.c @@ -0,0 +1,251 @@ +/* + * Userspace 8250/16550 register access for SER12 bit-bang. + * Prefer ioperm(2); fall back to /dev/port. Dry-run: no-ops. + * Algorithms match Linux baycom_ser_fdx (see NOTICE.md). + */ +#define _GNU_SOURCE +#include "bcpr/bcpr_uart.h" + +#include <errno.h> +#include <fcntl.h> +#include <stdio.h> +#include <string.h> +#include <sys/types.h> +#include <time.h> +#include <unistd.h> + +#if defined(__linux__) +#include <sys/io.h> +#elif defined(__FreeBSD__) +#include <machine/cpufunc.h> +#include <machine/sysarch.h> +#endif + +#if defined(__FreeBSD__) +static int bcpr_freebsd_set_ioperm(unsigned start, unsigned len, int enable) +{ + struct { + unsigned int start; + unsigned int length; + int enable; + } args; + + args.start = start; + args.length = len; + args.enable = enable; + return sysarch(I386_SET_IOPERM, &args); +} +#endif + +static int g_dry; +static int g_port_fd = -1; +static int g_ioperm_ok; + +void bcpr_uart_set_dry_run(int on) +{ + g_dry = on ? 1 : 0; +} + +int bcpr_uart_ioperm(unsigned iobase, int on) +{ + if (g_dry) { + return 0; + } +#if defined(__linux__) + if (ioperm((unsigned long)iobase, 8, on ? 1 : 0) == 0) { + g_ioperm_ok = on ? 1 : 0; + return 0; + } +#elif defined(__FreeBSD__) + /* + * Prefer /dev/io (process-wide inb/outb). sysarch range is fallback. + * ExSys port: disable matching uart(4) unit (e.g. uart2/cuau2) in + * loader.conf — kernel uart + userspace inb on same iobase → SIGBUS. + */ + if (on) { + if (g_port_fd < 0) { + g_port_fd = open("/dev/io", O_RDWR | O_CLOEXEC); + } + if (g_port_fd >= 0) { + g_ioperm_ok = 1; + return 0; + } + } + if (bcpr_freebsd_set_ioperm((unsigned)iobase, 8, on ? 1 : 0) == 0) { + g_ioperm_ok = on ? 1 : 0; + return 0; + } +#endif + /* Fallback: /dev/io (FreeBSD) or /dev/port (Linux) — needs root. */ + if (on) { + if (g_port_fd < 0) { +#if defined(__FreeBSD__) + g_port_fd = open("/dev/io", O_RDWR | O_CLOEXEC); +#else + g_port_fd = open("/dev/port", O_RDWR | O_CLOEXEC); +#endif + if (g_port_fd >= 0) { + g_ioperm_ok = 1; + return 0; + } + } + if (g_port_fd >= 0) { + return 0; + } + return -1; + } + if (g_port_fd >= 0) { + close(g_port_fd); + g_port_fd = -1; + } + g_ioperm_ok = 0; + return 0; +} + +static int port_rw(unsigned port, int do_write, unsigned char *val) +{ + off_t off = (off_t)port; + if (g_port_fd < 0) { + return -1; + } + if (lseek(g_port_fd, off, SEEK_SET) != off) { + return -1; + } + if (do_write) { + return (write(g_port_fd, val, 1) == 1) ? 0 : -1; + } + return (read(g_port_fd, val, 1) == 1) ? 0 : -1; +} + +void bcpr_uart_outb(unsigned char val, unsigned port) +{ + if (g_dry) { + return; + } + if (g_ioperm_ok) { + outb(val, port); + return; + } + (void)port_rw(port, 1, &val); +} + +unsigned char bcpr_uart_inb(unsigned port) +{ + unsigned char v = 0; + if (g_dry) { + return 0; + } + if (g_ioperm_ok) { + return inb(port); + } + (void)port_rw(port, 0, &v); + return v; +} + +void bcpr_uart_set_divisor(unsigned iobase, unsigned divisor) +{ + unsigned char lcr; + if (g_dry) { + return; + } + lcr = bcpr_uart_inb(iobase + 3); + bcpr_uart_outb((unsigned char)(lcr | 0x80), iobase + 3); /* DLAB */ + bcpr_uart_outb((unsigned char)(divisor & 0xff), iobase + 0); + bcpr_uart_outb((unsigned char)((divisor >> 8) & 0xff), iobase + 1); + bcpr_uart_outb((unsigned char)(lcr & 0x7f), iobase + 3); +} + +void bcpr_uart_open_ser12(unsigned iobase) +{ + if (g_dry) { + return; + } + /* Match baycom_ser_fdx open: FIFO off, 6-bit word, IER THRE+MSR. */ + bcpr_uart_outb(0x00, iobase + 2); /* FCR */ + bcpr_uart_outb(0x01, iobase + 3); /* LCR 6N1 */ + bcpr_uart_outb(0x0a, iobase + 1); /* IER */ + bcpr_uart_outb(0x0d, iobase + 4); /* MCR idle */ + bcpr_uart_thr00(iobase); +} + +void bcpr_uart_close_ser12(unsigned iobase) +{ + if (g_dry) { + return; + } + bcpr_uart_outb(0x00, iobase + 1); /* IER off */ + bcpr_uart_outb(0x01, iobase + 4); /* MCR close */ +} + +unsigned char bcpr_uart_msr(unsigned iobase) +{ + if (g_dry) { + return 0; + } + return bcpr_uart_inb(iobase + 6); +} + +void bcpr_uart_mcr(unsigned iobase, unsigned char v) +{ + if (g_dry) { + return; + } + bcpr_uart_outb(v, iobase + 4); +} + +void bcpr_uart_thr00(unsigned iobase) +{ + if (g_dry) { + return; + } + bcpr_uart_outb(0x00, iobase + 0); +} + +void bcpr_uart_set_break(unsigned iobase, int on) +{ + unsigned char lcr; + if (g_dry) { + return; + } + /* + * 8250 LCR bit6 (Set Break): force TXD to continuous SPACE (RS-232 +V). + * Honest limit: THR writes alone cannot hold DC-steady TXD — each byte is + * framed (start/data/stop). Break is the practical TFPCX-class approximation + * (docs: static ≈ +12 V modem supply). Does not change MCR/PTT. + */ + lcr = bcpr_uart_inb(iobase + 3); + if (on) { + bcpr_uart_outb((unsigned char)(lcr | 0x40), iobase + 3); + } else { + bcpr_uart_outb((unsigned char)(lcr & (unsigned char)~0x40), iobase + 3); + } +} + +int bcpr_uart_wait_thre(unsigned iobase, unsigned timeout_us) +{ + struct timespec t0, now; + unsigned char lsr; + + if (g_dry) { + return 1; + } + if (clock_gettime(CLOCK_MONOTONIC, &t0) != 0) { + return 0; + } + for (;;) { + lsr = bcpr_uart_inb(iobase + 5); + if (lsr & 0x20) { /* THRE */ + return 1; + } + if (clock_gettime(CLOCK_MONOTONIC, &now) != 0) { + return 0; + } + { + long elapsed = (long)(now.tv_sec - t0.tv_sec) * 1000000L + + (now.tv_nsec - t0.tv_nsec) / 1000L; + if (elapsed >= (long)timeout_us) { + return 0; + } + } + } +} diff --git a/stacks/max25-bcpr/src/max25-bcprd-init.c b/stacks/max25-bcpr/src/max25-bcprd-init.c new file mode 100644 index 0000000..6a02240 --- /dev/null +++ b/stacks/max25-bcpr/src/max25-bcprd-init.c @@ -0,0 +1,255 @@ +/* + * max25-bcprd-init — privileged SER12 setup (setuid root), then unprivileged daemon. + * Fork keeps ioperm in child; parent writes pidfile and exits. + */ +#define _GNU_SOURCE +#include "bcpr/bcpr_config.h" +#include "bcpr/bcpr_daemon.h" +#include "bcpr/bcpr_engine.h" +#include "bcpr/bcpr_kiss.h" +#include "bcpr/bcpr_runas.h" + +#include <errno.h> +#include <fcntl.h> +#include <signal.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <strings.h> +#include <sys/stat.h> +#include <sys/wait.h> +#include <unistd.h> + +static void usage(const char *argv0) +{ + fprintf(stderr, + "Usage: %s -c <bcpr.ini> [--once] [--seconds N]\n" + " [--cal high|low|alt] [--txd-bias pulse|steady]\n" + " [--ptt-wd|--no-ptt-wd] [--ptt-wd-key-ms N]\n" + " [--ptt-wd-pause-ms N] [--version]\n" + " Privileged init for live SER12 — drops to [max25-bcpr] user= after setup.\n" + " Dry-run: use max25-bcprd --dry-run (unprivileged).\n", + argv0); +} + +static int ensure_dir(const char *path) +{ + char tmp[256]; + char *p; + size_t len; + + if (!path || !path[0]) { + return -1; + } + snprintf(tmp, sizeof(tmp), "%s", path); + len = strlen(tmp); + if (len == 0) { + return -1; + } + if (tmp[len - 1] == '/') { + tmp[len - 1] = '\0'; + } + for (p = tmp + 1; *p; p++) { + if (*p == '/') { + *p = '\0'; + (void)mkdir(tmp, 0755); + *p = '/'; + } + } + return mkdir(tmp, 0755) == 0 || errno == EEXIST ? 0 : -1; +} + +static int write_pidfile(const char *path, pid_t pid) +{ + FILE *f; + + f = fopen(path, "w"); + if (!f) { + fprintf(stderr, "max25-bcprd-init: cannot write pidfile %s\n", path); + return -1; + } + fprintf(f, "%d\n", (int)pid); + fclose(f); + return 0; +} + +int main(int argc, char **argv) +{ + const char *cfg_path = NULL; + int seconds = 0; + int cal_mode = BCPR_CAL_OFF; + int cli_txd_bias = -1; + int cli_ptt_wd = -1; + int cli_ptt_wd_key_ms = -1; + int cli_ptt_wd_pause_ms = -1; + int i; + bcpr_config_t cfg; + bcpr_engine_t engine; + bcpr_kiss_pty_t ptys[BCPR_MAX_DEVICES]; + int npty = 0; + char pidpath[256]; + pid_t child; + bcpr_daemon_opts_t opts; + + for (i = 1; i < argc; i++) { + if ((strcmp(argv[i], "-c") == 0 || strcmp(argv[i], "--config") == 0) && + i + 1 < argc) { + cfg_path = argv[++i]; + } else if (strcmp(argv[i], "--once") == 0) { + if (seconds <= 0) { + seconds = 1; + } + } else if (strcmp(argv[i], "--seconds") == 0 && i + 1 < argc) { + seconds = atoi(argv[++i]); + } else if (strcmp(argv[i], "--cal") == 0 && i + 1 < argc) { + const char *m = argv[++i]; + if (strcmp(m, "high") == 0 || strcmp(m, "1") == 0) { + cal_mode = BCPR_CAL_HIGH; + } else if (strcmp(m, "low") == 0 || strcmp(m, "2") == 0) { + cal_mode = BCPR_CAL_LOW; + } else if (strcmp(m, "alt") == 0 || strcmp(m, "3") == 0) { + cal_mode = BCPR_CAL_ALT; + } else { + fprintf(stderr, "max25-bcprd-init: --cal needs high|low|alt\n"); + return 2; + } + } else if (strcmp(argv[i], "--txd-bias") == 0 && i + 1 < argc) { + const char *m = argv[++i]; + if (strcasecmp(m, "pulse") == 0 || strcasecmp(m, "sailer") == 0) { + cli_txd_bias = BCPR_TXD_PULSE; + } else if (strcasecmp(m, "steady") == 0 || + strcasecmp(m, "tfpcx") == 0 || + strcasecmp(m, "break") == 0) { + cli_txd_bias = BCPR_TXD_STEADY; + } else { + fprintf(stderr, "max25-bcprd-init: --txd-bias needs pulse|steady\n"); + return 2; + } + } else if (strcmp(argv[i], "--ptt-wd") == 0) { + cli_ptt_wd = 1; + } else if (strcmp(argv[i], "--no-ptt-wd") == 0) { + cli_ptt_wd = 0; + } else if (strcmp(argv[i], "--ptt-wd-key-ms") == 0 && i + 1 < argc) { + cli_ptt_wd_key_ms = atoi(argv[++i]); + } else if (strcmp(argv[i], "--ptt-wd-pause-ms") == 0 && + i + 1 < argc) { + cli_ptt_wd_pause_ms = atoi(argv[++i]); + } else if (strcmp(argv[i], "--version") == 0 || + strcmp(argv[i], "-V") == 0) { + printf("bcprd-init 0.1.0\n"); + return 0; + } else if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) { + usage(argv[0]); + return 0; + } else { + usage(argv[0]); + return 2; + } + } + if (!cfg_path) { + usage(argv[0]); + return 2; + } + if (geteuid() != 0) { + fprintf(stderr, + "max25-bcprd-init: requires root (setuid) for SER12 lock/ioperm/KISS\n"); + return 1; + } + if (cal_mode != BCPR_CAL_OFF && seconds <= 0) { + seconds = 5; + } + + if (bcpr_config_load(&cfg, cfg_path) != 0) { + fprintf(stderr, "max25-bcprd-init: cannot load %s\n", cfg_path); + return 1; + } + if (cfg.dry_run) { + fprintf(stderr, + "max25-bcprd-init: dry_run=yes — use max25-bcprd --dry-run instead\n"); + return 1; + } + if (!cfg.run_user[0]) { + fprintf(stderr, + "max25-bcprd-init: [max25-bcpr] user= required for privilege drop\n"); + return 1; + } + + for (i = 0; i < BCPR_MAX_DEVICES; i++) { + if (cli_txd_bias >= 0) { + cfg.dev[i].txd_bias = cli_txd_bias; + } + if (cli_ptt_wd >= 0) { + cfg.dev[i].ptt_wd = cli_ptt_wd; + } + if (cli_ptt_wd_key_ms > 0) { + cfg.dev[i].ptt_wd_key_ms = cli_ptt_wd_key_ms; + } + if (cli_ptt_wd_pause_ms > 0) { + cfg.dev[i].ptt_wd_pause_ms = cli_ptt_wd_pause_ms; + } + } + + (void)ensure_dir(cfg.state_dir); + snprintf(pidpath, sizeof(pidpath), "%s/max25-bcprd.pid", cfg.state_dir); + + if (cal_mode == BCPR_CAL_OFF) { + for (i = 0; i < BCPR_MAX_DEVICES; i++) { + if (!cfg.dev[i].enabled) { + continue; + } + if (bcpr_kiss_pty_open(&ptys[npty], i, cfg.dev[i].kiss_link, + cfg.state_dir) != 0) { + while (npty > 0) { + npty--; + bcpr_kiss_pty_close(&ptys[npty]); + } + return 1; + } + npty++; + } + } + + if (bcpr_engine_open(&engine, &cfg) != 0) { + for (i = 0; i < npty; i++) { + bcpr_kiss_pty_close(&ptys[i]); + } + return 1; + } + + child = fork(); + if (child < 0) { + perror("max25-bcprd-init fork"); + bcpr_engine_close(&engine); + for (i = 0; i < npty; i++) { + bcpr_kiss_pty_close(&ptys[i]); + } + return 1; + } + if (child > 0) { + if (write_pidfile(pidpath, child) != 0) { + kill(child, SIGTERM); + waitpid(child, NULL, 0); + return 1; + } + _exit(0); + } + + if (setsid() < 0) { + perror("max25-bcprd-init setsid"); + } + + if (bcpr_runas_drop(&cfg) != 0) { + _exit(1); + } + + memset(&opts, 0, sizeof(opts)); + opts.seconds = seconds; + opts.cal_mode = cal_mode; + opts.cli_txd_bias = cli_txd_bias; + opts.cli_ptt_wd = cli_ptt_wd; + opts.cli_ptt_wd_key_ms = cli_ptt_wd_key_ms; + opts.cli_ptt_wd_pause_ms = cli_ptt_wd_pause_ms; + opts.engine_preopened = 1; + + return bcpr_daemon_run(&cfg, ptys, npty, &engine, &opts); +} diff --git a/stacks/max25-bcpr/src/max25-bcprd.c b/stacks/max25-bcpr/src/max25-bcprd.c new file mode 100644 index 0000000..084c5df --- /dev/null +++ b/stacks/max25-bcpr/src/max25-bcprd.c @@ -0,0 +1,205 @@ +/* + * max25-bcprd — BayCom/based SER12 userspace daemon (unprivileged live path). + * Live SER12: max25-bcprd-init (setuid). Dry-run / offline: this binary. + */ +#define _GNU_SOURCE +#include "bcpr/bcpr_config.h" +#include "bcpr/bcpr_daemon.h" +#include "bcpr/bcpr_kiss.h" +#include "bcpr/bcpr_runas.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <strings.h> +#include <unistd.h> + +static void usage(const char *argv0) +{ + fprintf(stderr, + "Usage: %s -c <bcpr.ini> [--dry-run] [--once] [--seconds N]\n" + " [--cal high|low|alt] [--txd-bias pulse|steady]\n" + " [--ptt-wd|--no-ptt-wd] [--ptt-wd-key-ms N]\n" + " [--ptt-wd-pause-ms N] [--version]\n" + " Host face: max25e0:bc0 / bc1 (KISS PTY via kiss_link)\n" + " Live SER12: max25-bcprd-init (setuid) — this binary refuses root.\n" + " --cal: continuous SER12 tone+PTT (use init for live hardware)\n", + argv0); +} + +int main(int argc, char **argv) +{ + const char *cfg_path = NULL; + int dry = 0; + int seconds = 0; + int cal_mode = BCPR_CAL_OFF; + int cli_txd_bias = -1; + int cli_ptt_wd = -1; + int cli_ptt_wd_key_ms = -1; + int cli_ptt_wd_pause_ms = -1; + int i; + bcpr_config_t cfg; + bcpr_kiss_pty_t ptys[BCPR_MAX_DEVICES]; + int npty = 0; + bcpr_daemon_opts_t opts; + char ver[32] = "0.1.0"; + + for (i = 1; i < argc; i++) { + if ((strcmp(argv[i], "-c") == 0 || strcmp(argv[i], "--config") == 0) && + i + 1 < argc) { + cfg_path = argv[++i]; + } else if (strcmp(argv[i], "--dry-run") == 0) { + dry = 1; + } else if (strcmp(argv[i], "--once") == 0) { + if (seconds <= 0) { + seconds = 1; + } + } else if (strcmp(argv[i], "--seconds") == 0 && i + 1 < argc) { + seconds = atoi(argv[++i]); + } else if (strcmp(argv[i], "--cal") == 0 && i + 1 < argc) { + const char *m = argv[++i]; + if (strcmp(m, "high") == 0 || strcmp(m, "1") == 0) { + cal_mode = BCPR_CAL_HIGH; + } else if (strcmp(m, "low") == 0 || strcmp(m, "2") == 0) { + cal_mode = BCPR_CAL_LOW; + } else if (strcmp(m, "alt") == 0 || strcmp(m, "3") == 0) { + cal_mode = BCPR_CAL_ALT; + } else { + fprintf(stderr, "max25-bcprd: --cal needs high|low|alt\n"); + return 2; + } + } else if (strcmp(argv[i], "--txd-bias") == 0 && i + 1 < argc) { + const char *m = argv[++i]; + if (strcasecmp(m, "pulse") == 0 || strcasecmp(m, "sailer") == 0) { + cli_txd_bias = BCPR_TXD_PULSE; + } else if (strcasecmp(m, "steady") == 0 || + strcasecmp(m, "tfpcx") == 0 || + strcasecmp(m, "break") == 0) { + cli_txd_bias = BCPR_TXD_STEADY; + } else { + fprintf(stderr, "max25-bcprd: --txd-bias needs pulse|steady\n"); + return 2; + } + } else if (strcmp(argv[i], "--ptt-wd") == 0) { + cli_ptt_wd = 1; + } else if (strcmp(argv[i], "--no-ptt-wd") == 0) { + cli_ptt_wd = 0; + } else if (strcmp(argv[i], "--ptt-wd-key-ms") == 0 && i + 1 < argc) { + cli_ptt_wd_key_ms = atoi(argv[++i]); + } else if (strcmp(argv[i], "--ptt-wd-pause-ms") == 0 && + i + 1 < argc) { + cli_ptt_wd_pause_ms = atoi(argv[++i]); + } else if (strcmp(argv[i], "--version") == 0 || + strcmp(argv[i], "-V") == 0) { + FILE *vf = fopen("/usr/local/share/max25/max25-bcpr/VERSION", "r"); + if (!vf) { + vf = fopen("stacks/max25-bcpr/VERSION", "r"); + } + if (vf) { + if (fgets(ver, sizeof(ver), vf)) { + ver[strcspn(ver, "\r\n")] = '\0'; + } + fclose(vf); + } + printf("bcprd %s\n", ver); + return 0; + } else if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) { + usage(argv[0]); + return 0; + } else { + usage(argv[0]); + return 2; + } + } + if (!cfg_path) { + usage(argv[0]); + return 2; + } + if (cal_mode != BCPR_CAL_OFF && seconds <= 0) { + seconds = 5; + } + + if (bcpr_config_load(&cfg, cfg_path) != 0) { + fprintf(stderr, "max25-bcprd: cannot load %s\n", cfg_path); + return 1; + } + if (dry) { + cfg.dry_run = 1; + } + + if (bcpr_runas_refuse_root(cfg.dry_run) != 0) { + return 1; + } + if (!cfg.dry_run && cal_mode == BCPR_CAL_OFF) { + fprintf(stderr, + "max25-bcprd: live SER12 requires max25-bcprd-init — " + "not this binary alone\n"); + return 1; + } + + { + char self[512]; + ssize_t n = readlink("/proc/self/exe", self, sizeof(self) - 1); + FILE *vf = fopen("/usr/local/share/bcpr/VERSION", "r"); + if (!vf) { + vf = fopen("stacks/max25-bcpr/VERSION", "r"); + } + ver[0] = '\0'; + if (vf) { + if (fgets(ver, sizeof(ver), vf)) { + ver[strcspn(ver, "\r\n")] = '\0'; + } + fclose(vf); + } + if (n > 0) { + self[n] = '\0'; + fprintf(stderr, "max25-bcprd: path=%s version=%s\n", self, + ver[0] ? ver : "0.1.0"); + } else { + fprintf(stderr, "max25-bcprd: version=%s\n", ver[0] ? ver : "0.1.0"); + } + } + + if (cfg.dry_run) { + fprintf(stderr, "max25-bcprd: dry-run (no KISS PTY, no UART I/O)\n"); + } else if (cal_mode == BCPR_CAL_OFF) { + for (i = 0; i < BCPR_MAX_DEVICES; i++) { + if (!cfg.dev[i].enabled) { + continue; + } + if (bcpr_kiss_pty_open(&ptys[npty], i, cfg.dev[i].kiss_link, + cfg.state_dir) != 0) { + while (npty > 0) { + npty--; + bcpr_kiss_pty_close(&ptys[npty]); + } + return 1; + } + npty++; + } + } + if (cal_mode != BCPR_CAL_OFF) { + static const char *cal_names[] = {"off", "high", "low", "alt"}; + fprintf(stderr, + "max25-bcprd: CAL mode=%s seconds=%d (no KISS; SER12 tone+PTT)%s\n", + cal_names[cal_mode], seconds, + cfg.dry_run ? " [dry-run]" : ""); + if (!cfg.dry_run) { + fprintf(stderr, + "max25-bcprd: live cal needs max25-bcprd-init for hardware access\n"); + return 1; + } + } + + memset(&opts, 0, sizeof(opts)); + opts.dry_run = cfg.dry_run; + opts.seconds = seconds; + opts.cal_mode = cal_mode; + opts.cli_txd_bias = cli_txd_bias; + opts.cli_ptt_wd = cli_ptt_wd; + opts.cli_ptt_wd_key_ms = cli_ptt_wd_key_ms; + opts.cli_ptt_wd_pause_ms = cli_ptt_wd_pause_ms; + opts.engine_preopened = 0; + + return bcpr_daemon_run(&cfg, ptys, npty, NULL, &opts); +} diff --git a/stacks/max25-bcpr/tests/test_config_offline.c b/stacks/max25-bcpr/tests/test_config_offline.c new file mode 100644 index 0000000..8b40a6a --- /dev/null +++ b/stacks/max25-bcpr/tests/test_config_offline.c @@ -0,0 +1,53 @@ +/* + * Offline config parse test — no UART. + */ +#include "bcpr/bcpr_config.h" + +#include <stdio.h> +#include <string.h> +#include <unistd.h> + +int main(void) +{ + const char *path = "share/max25-bcpr.ini.example"; + bcpr_config_t cfg; + char cwd[512]; + + if (!getcwd(cwd, sizeof(cwd))) { + return 1; + } + /* Prefer path relative to build or source. */ + if (access(path, R_OK) != 0) { + path = "../share/max25-bcpr.ini.example"; + } + if (access(path, R_OK) != 0) { + path = "../../stacks/max25-bcpr/share/max25-bcpr.ini.example"; + } + if (bcpr_config_load(&cfg, path) != 0) { + /* Write a minimal temp INI. */ + FILE *f = fopen("/tmp/max25-bcpr-test.ini", "w"); + if (!f) { + return 1; + } + fputs("[max25-bcpr]\ndry_run = yes\nstate_dir = /tmp/max25-bcpr\n" + "[bc0]\nserial = /dev/ttyS0\niobase = 0x3f8\nirq = 4\n" + "mode = ser12*\nkiss_link = /tmp/max25-bcpr/kiss-bc0\n", + f); + fclose(f); + path = "/tmp/max25-bcpr-test.ini"; + if (bcpr_config_load(&cfg, path) != 0) { + fprintf(stderr, "FAIL: load\n"); + return 1; + } + } + if (cfg.n_dev < 1 || !cfg.dev[0].enabled) { + fprintf(stderr, "FAIL: no bc0\n"); + return 1; + } + if (cfg.dev[0].irq != 4 || cfg.dev[0].iobase != 0x3f8) { + fprintf(stderr, "FAIL: irq/iobase\n"); + return 1; + } + puts("OK: test_config_offline"); + return 0; +} diff --git a/stacks/max25-bcpr/tests/test_hdlc_offline.c b/stacks/max25-bcpr/tests/test_hdlc_offline.c new file mode 100644 index 0000000..d05d5b6 --- /dev/null +++ b/stacks/max25-bcpr/tests/test_hdlc_offline.c @@ -0,0 +1,110 @@ +/* + * Offline HDLC unit test — CRC, bitstuff roundtrip, no UART / UART I/O. + */ +#include "bcpr/bcpr_crc.h" +#include "bcpr/bcpr_hdlc.h" + +#include <stdio.h> +#include <string.h> + +static int g_got; +static uint8_t g_rx[BCPR_MAXFLEN + 4]; +static int g_rx_len; + +static void on_frame(const uint8_t *kiss, int len, void *ud) +{ + (void)ud; + if (len > 0 && len <= (int)sizeof(g_rx)) { + memcpy(g_rx, kiss, (size_t)len); + g_rx_len = len; + g_got = 1; + } +} + +static int test_crc(void) +{ + uint8_t buf[16]; + memcpy(buf, "TEST", 4); + bcpr_append_crc_ccitt(buf, 4); + if (!bcpr_check_crc_ccitt(buf, 6)) { + fprintf(stderr, "FAIL: CRC check\n"); + return 1; + } + buf[0] ^= 0xff; + if (bcpr_check_crc_ccitt(buf, 6)) { + fprintf(stderr, "FAIL: CRC should fail on corrupt\n"); + return 1; + } + return 0; +} + +static int test_roundtrip(void) +{ + bcpr_hdlc_t tx, rx; + bcpr_channel_t ch = { .tx_delay = 2, .tx_tail = 1, .slottime = 1, + .ppersist = 255, .fulldup = 1 }; + uint8_t kiss[32]; + int i, words = 0; + + bcpr_hdlc_init(&tx, 1200, &ch); + bcpr_hdlc_init(&rx, 1200, &ch); + + kiss[0] = 0; /* KISS DATA */ + memcpy(kiss + 1, "HELLO", 5); + if (bcpr_hdlc_queue_kiss(&tx, kiss, 6) != 0) { + fprintf(stderr, "FAIL: queue\n"); + return 1; + } + bcpr_hdlc_arbitrate(&tx); + if (!bcpr_hdlc_ptt(&tx)) { + fprintf(stderr, "FAIL: PTT not set\n"); + return 1; + } + + g_got = 0; + g_rx_len = 0; + /* Drain TX bit words into RX putbits path. */ + for (i = 0; i < 4096 && !g_got; i++) { + unsigned w; + bcpr_hdlc_transmitter(&tx); + w = bcpr_hdlc_getbits(&tx); + if (w) { + bcpr_hdlc_putbits(&rx, w); + words++; + bcpr_hdlc_receiver(&rx, on_frame, NULL); + } else if (!bcpr_hdlc_ptt(&tx)) { + bcpr_hdlc_transmitter(&tx); + /* flush remaining */ + while (!g_got) { + w = bcpr_hdlc_getbits(&tx); + if (!w) { + break; + } + bcpr_hdlc_putbits(&rx, w); + bcpr_hdlc_receiver(&rx, on_frame, NULL); + } + break; + } + } + + if (!g_got) { + fprintf(stderr, "FAIL: no RX frame (words=%d)\n", words); + return 1; + } + if (g_rx_len < 6 || g_rx[0] != 0 || memcmp(g_rx + 1, "HELLO", 5) != 0) { + fprintf(stderr, "FAIL: payload mismatch len=%d\n", g_rx_len); + return 1; + } + return 0; +} + +int main(void) +{ + int rc = 0; + rc |= test_crc(); + rc |= test_roundtrip(); + if (rc == 0) { + puts("OK: test_hdlc_offline"); + } + return rc; +} diff --git a/stacks/max25-bcpr/tools/NEBENBEI-MOVED.md b/stacks/max25-bcpr/tools/NEBENBEI-MOVED.md new file mode 100644 index 0000000..8a4ac20 --- /dev/null +++ b/stacks/max25-bcpr/tools/NEBENBEI-MOVED.md @@ -0,0 +1,5 @@ +# Dauerlauf helpers — not shipped here + +Experimental endurance / orch helpers are **not** part of this freigegeben product tree. + +Product L1 remains: `max25-bcpr-ctl`, smoke, ultimate-diag · host face **`max25e0`** · public mark **BayCom/based**. diff --git a/stacks/max25-bcpr/tools/bcpr-ultimate-diag.sh b/stacks/max25-bcpr/tools/bcpr-ultimate-diag.sh new file mode 100755 index 0000000..573585c --- /dev/null +++ b/stacks/max25-bcpr/tools/bcpr-ultimate-diag.sh @@ -0,0 +1,916 @@ +#!/usr/bin/env bash +# bcpr-ultimate-diag.sh — interactive BayCom/based (bcpr) TX/RX diagnostic ladder +# Public mark: BayCom/based. Internal path: bcpr. Never Konverter/converter. +# Target: AX25WRK1 intermittent "host keys, RF sometimes" (mic OK 4W; host MCR OK). +# +# Soft-TNC RE (2026-07-19) — operator warnings only (no MCR/code patch here): +# • FlexNet SER12 cal PTT watchdog: ~14.5 s keyed → ~500 ms unkey (discharge). +# Long continuous force-tx/cal: bcprd ptt_wd drops RTS ~500 ms every ~14.5 s +# (FlexNet SER12 mirror; disable with ptt_wd=no / --no-ptt-wd). +# • TXD: default pulse THR 0x00; experiment txd_bias=steady (UART break ≈ TFPCX). +# • Keep bcprd MCR Sailer 0x0e|bit / 0x0d (4PC-COM 0x0A is outlier — do not switch). +# Vault RE: 0-RESEARCHES/projects/max25-stack/2026-07-19-baycom-soft-tnc-serial-ptt-re.md +# +# Usage (Cursor IDE terminal, as operator): +# sudo -n /home/akb/Code/10-PROJECTS/MAX25-Stack/stacks/bcpr/tools/bcpr-ultimate-diag.sh +# BCPR_INI=/etc/max25/bcpr.ini ./bcpr-ultimate-diag.sh -c /etc/max25/bcpr.ini +# ./bcpr-ultimate-diag.sh --help +# ./bcpr-ultimate-diag.sh --all # run phases 1–9 with pauses +# ./bcpr-ultimate-diag.sh --menu # interactive menu (default) +# +# Needs: /usr/bin/sudo -n for ioport / live smoke / cal. Does NOT change MCR code. +# Does NOT use USB as product TX path. Does NOT kill max25d carelessly. +# +# Vault: 0-RESEARCHES/projects/max25-stack/2026-07-19-bcpr-ultimate-diag-script.md +# Relies on: bcpr-ctl, bcpr-rxtx-smoke.sh (same directory). + +# Note: interactive prompts use read; keep pipefail but relax -e around optional probes. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." 2>/dev/null && pwd || true)" +CTL="${SCRIPT_DIR}/bcpr-ctl" +SMOKE_SH="${SCRIPT_DIR}/bcpr-rxtx-smoke.sh" +INI="${BCPR_INI:-/etc/max25/bcpr.ini}" +SUDO="/usr/bin/sudo" +LOG="" +MODE="menu" # menu | all | phaseN +PHASE_ONLY="" +RUN_TS="" + +# Result arrays for summary (parallel indices) +declare -a RES_PHASE=() +declare -a RES_HOST=() +declare -a RES_WATT=() +declare -a RES_BLED=() +declare -a RES_RDISP=() +declare -a RES_NOTE=() + +usage() { + cat <<'USAGE' +bcpr-ultimate-diag.sh — BayCom/based (bcpr) interactive diagnostic / force-TX ladder + +Usage: + bcpr-ultimate-diag.sh [-c INI] [--menu|--all|--phase N] [--help] + +Options: + -c INI bcpr.ini (default: $BCPR_INI or /etc/max25/bcpr.ini) + --menu interactive German menu (default) + --all run phases 1–9 linearly with pauses + RF prompts + --phase N run a single phase (1–9, or 0=restart menu) + -h, --help this help + +Environment: + BCPR_INI same as -c + BCPRD optional path to bcprd binary + +Phases: + 1 Preflight (ttyS0, fuser, dosbox-x, bcprd/max25d, INI, locks) + 2 Idle MCR sample via /dev/port + 3 bcpr-ctl status + telem (tx-last / dcd / rx-activity) + 4 RX listen (soft-DCD) — open SQ reminder + 5 Force-TX ladder 1s / 3s / 5s / 8s + wattmeter/LED/display prompts + 6 Cal/high (bcprd --cal high) if binary supports it + 7 Double-burst / back-to-back TX + 8 Contact hunt (DE-9 + 3.5mm) + 5s force-tx + wiggle + 9 Summary table + verdict hints + 0 Optional safe bcprd restart (bcpr-ctl stop/start only) + U USB note (SER12 unsupported on USB — not product TX) + +Log: /tmp/bcpr-ultimate-diag-YYYYMMDD-HHMMSS.log + +Examples: + sudo -n stacks/bcpr/tools/bcpr-ultimate-diag.sh --all + BCPR_INI=/etc/max25/bcpr.ini sudo -n ./bcpr-ultimate-diag.sh --menu +USAGE +} + +# ---------- logging / UI ---------- + +log() { + local line + line="$(printf '%s' "$*")" + printf '%s\n' "$line" + if [[ -n "${LOG:-}" ]]; then + printf '%s\n' "$line" >>"$LOG" + fi +} + +log_raw() { + # stdin → stdout + log + if [[ -n "${LOG:-}" ]]; then + tee -a "$LOG" + else + cat + fi +} + +banner() { + log "" + log "════════════════════════════════════════════════════════════" + log "$*" + log "════════════════════════════════════════════════════════════" +} + +pause_enter() { + local msg="${1:-Weiter mit Enter …}" + printf '\n%s ' "$msg" >/dev/tty + # shellcheck disable=SC2034 + local _dummy + read -r _dummy </dev/tty || true + log "[pause] $msg" +} + +ask() { + # ask VAR "prompt" + local __var="$1" + local __prompt="$2" + local __ans="" + printf '%s ' "$__prompt" >/dev/tty + read -r __ans </dev/tty || true + printf -v "$__var" '%s' "$__ans" + log "[antwort] $__prompt → ${__ans}" +} + +ask_yn() { + # ask_yn VAR "prompt" → stores j/n/y/n normalized to j|n + local __var="$1" + local __prompt="$2" + local __ans="" + while true; do + printf '%s [j/n]: ' "$__prompt" >/dev/tty + read -r __ans </dev/tty || true + case "${__ans,,}" in + j|ja|y|yes) printf -v "$__var" 'j'; log "[antwort] $__prompt → j"; return 0 ;; + n|nein|no) printf -v "$__var" 'n'; log "[antwort] $__prompt → n"; return 0 ;; + *) printf 'Bitte j oder n.\n' >/dev/tty ;; + esac + done +} + +record_result() { + local phase="$1" host="$2" watt="$3" bled="$4" rdisp="$5" note="${6:-}" + RES_PHASE+=("$phase") + RES_HOST+=("$host") + RES_WATT+=("$watt") + RES_BLED+=("$bled") + RES_RDISP+=("$rdisp") + RES_NOTE+=("$note") +} + +need_sudo() { + if [[ ! -x "$SUDO" ]]; then + log "FEHLER: $SUDO fehlt — Live-Phasen brauchen sudo." + return 1 + fi + if ! "$SUDO" -n true 2>/dev/null; then + log "FEHLER: sudo -n fehlgeschlagen." + log " Bitte einmalig: sudo -v (oder NOPASSWD für diesen User)" + log " Dann Skript erneut starten." + return 1 + fi + return 0 +} + +run_sudo() { + need_sudo || return 1 + "$SUDO" -n "$@" +} + +ini_get() { + local section="$1" key="$2" + [[ -f "$INI" ]] || return 0 + awk -F= -v s="[$section]" -v k="$key" ' + $0 ~ /^\[/ { cur=$0; gsub(/[[:space:]]/,"",cur) } + cur==s && $1 ~ "^[[:space:]]*"k"[[:space:]]*$" { + v=$2; gsub(/^[[:space:]]+|[[:space:]]+$/,"",v); print v; exit + }' "$INI" 2>/dev/null || true +} + +resolve_bcprd() { + if [[ -n "${BCPRD:-}" && "$BCPRD" != "bcprd" && -x "$BCPRD" ]]; then + printf '%s\n' "$BCPRD"; return 0 + fi + if command -v bcprd >/dev/null 2>&1; then + command -v bcprd; return 0 + fi + local cand + for cand in \ + "${BCPR_BUILD_DIR:-}/bin/bcprd" \ + "${ROOT}/build-bcpr/bin/bcprd" \ + "${ROOT}/build-bcpr-${USER:-user}/bin/bcprd" \ + "/tmp/max25-build-bcpr-${USER:-user}/bin/bcprd" \ + "${ROOT}/build/bin/bcprd" \ + /usr/local/bin/bcprd /usr/bin/bcprd; do + [[ -n "$cand" && -x "$cand" ]] && { printf '%s\n' "$cand"; return 0; } + done + return 1 +} + +serial_dev() { + local s + s="$(ini_get bc0 serial)" + printf '%s\n' "${s:-/dev/ttyS0}" +} + +state_dir() { + local sd + sd="$(ini_get bcpr state_dir)" + printf '%s\n' "${sd:-/tmp/bcpr}" +} + +iobase_val() { + local b + b="$(ini_get bc0 iobase)" + printf '%s\n' "${b:-0x3f8}" +} + +ensure_tools() { + local err=0 + if [[ ! -x "$CTL" ]]; then + log "FEHLER: bcpr-ctl fehlt: $CTL"; err=1 + fi + if [[ ! -x "$SMOKE_SH" ]]; then + log "FEHLER: bcpr-rxtx-smoke.sh fehlt: $SMOKE_SH"; err=1 + fi + return "$err" +} + +# ---------- Phase helpers ---------- + +# Last smoke host result (PASS|FAIL) — do not capture function stdout (log noise). +_LAST_HOST="—" + +smoke_force_tx() { + # smoke_force_tx SECONDS → sets _LAST_HOST + local secs="$1" + local listen=$((secs + 8)) + local rc=0 + [[ "$listen" -lt 12 ]] && listen=12 + log "→ L4 force-tx ${secs}s (smoke --live --tx --force-tx --tx-seconds ${secs})" + run_sudo "$CTL" -c "$INI" smoke --live --tx --force-tx --seconds "$listen" --tx-seconds "$secs" 2>&1 | log_raw + rc=${PIPESTATUS[0]} + if [[ "$rc" -eq 0 ]]; then + _LAST_HOST="PASS" + log "HOST: PASS (MCR keyed ~${secs}s target)" + else + _LAST_HOST="FAIL" + log "HOST: FAIL (smoke rc=$rc)" + fi +} + +prompt_rf_obs() { + # prompt_rf_obs PHASE_LABEL HOST_RESULT + # sets globals: _watt _bled _rdisp _extra + local label="$1" host="$2" + local watt bled rdisp extra + log "" + log "--- RF-Beobachtung: $label (Host=$host) ---" + log "Bitte Wattmeter / Board-LED / Radio-Display während des Keys prüfen." + ask watt "Wattmeter W (Zahl oder 0 / ?):" + ask_yn bled "Board-LED (PC-COM) an während Key?" + ask_yn rdisp "Radio-Display / TX-Anzeige an?" + ask extra "Kurznotiz (Enter = keine):" + _watt="$watt" + _bled="$bled" + _rdisp="$rdisp" + _extra="$extra" + record_result "$label" "$host" "$watt" "$bled" "$rdisp" "$extra" +} + +# ---------- Phases ---------- + +phase_preflight() { + banner "Phase 1 — Preflight" + local serial sd iobase irq fulldup dry + serial="$(serial_dev)" + sd="$(state_dir)" + iobase="$(iobase_val)" + irq="$(ini_get bc0 irq)" + fulldup="$(ini_get bc0 fulldup)" + dry="$(ini_get bcpr dry_run)" + + log "INI: $INI" + if [[ ! -f "$INI" ]]; then + log "FEHLER: INI fehlt: $INI" + log " Hinweis: Beispiel → stacks/bcpr/share/bcpr.ini.example → /etc/max25/bcpr.ini" + return 1 + fi + + log "--- INI Auszug [bcpr]/[bc0] ---" + log " dry_run=${dry:-?} state_dir=${sd}" + log " serial=${serial} iobase=${iobase} irq=${irq:-?} fulldup=${fulldup:-?}" + log " mode=$(ini_get bc0 mode) kiss_link=$(ini_get bc0 kiss_link)" + log " baud=$(ini_get bc0 baud) tx_delay=$(ini_get bc0 tx_delay)" + + log "--- Seriell ---" + if [[ -e "$serial" ]]; then + log "OK: $serial existiert" + ls -l "$serial" 2>&1 | log_raw || true + else + log "FEHLER: $serial fehlt" + fi + + log "--- fuser / lsof (wer hält Port?) ---" + if command -v fuser >/dev/null 2>&1; then + + run_sudo fuser -v "$serial" 2>&1 | log_raw + + else + log "WARN: fuser nicht installiert" + fi + if command -v lsof >/dev/null 2>&1; then + + run_sudo lsof "$serial" 2>&1 | log_raw + + fi + + log "--- dosbox-x / DOS-Gast ---" + if pgrep -a -f 'dosbox-x|dosbox' 2>/dev/null | log_raw; then + log "WARN: dosbox läuft — kann ttyS0/USB belegen (sniff/passthrough)." + else + log "OK: kein dosbox/dosbox-x Prozess" + fi + + log "--- Prozesse bcprd / max25d ---" + pgrep -a -x bcprd 2>/dev/null | log_raw || log " bcprd: nicht laufend" + pgrep -a -x max25d 2>/dev/null | log_raw || log " max25d: nicht laufend" + # wrapper scripts + pgrep -a -f 'run-max25d|max25d' 2>/dev/null | head -20 | log_raw || true + + log "--- Lock / State ($sd) ---" + if [[ -d "$sd" ]]; then + ls -la "$sd" 2>&1 | log_raw || true + for f in "$sd"/lock* "$sd"/*.lock "$sd"/bcprd.pid; do + [[ -e "$f" ]] || continue + log " lock/pid: $f" + [[ -f "$f" ]] && { log " content:"; cat "$f" 2>&1 | log_raw || true; } + done + else + log "WARN: state_dir fehlt: $sd" + fi + + log "--- Kernel baycom_* Module (sollten NICHT geladen sein) ---" + if command -v lsmod >/dev/null 2>&1; then + lsmod 2>/dev/null | awk '/^baycom_/ {print}' | log_raw || log "OK: keine baycom_* Module" + fi + + log "--- setserial (falls vorhanden) ---" + local ss + for ss in /usr/bin/setserial /bin/setserial /sbin/setserial /usr/sbin/setserial; do + if [[ -x "$ss" ]]; then + + run_sudo "$ss" -g "$serial" 2>&1 | log_raw + + break + fi + done + + log "--- bcpr-ctl preflight ---" + + run_sudo "$CTL" -c "$INI" preflight 2>&1 | log_raw + local pf=$? + + if [[ "$pf" -eq 0 ]]; then + log "OK: preflight PASS" + else + log "WARN: preflight rc=$pf (Port busy wenn Stack läuft — normal bei live max25d/bcprd)" + fi + + log "--- Tools ---" + log " CTL=$CTL" + log " SMOKE=$SMOKE_SH" + local bin + if bin="$(resolve_bcprd)"; then + log " bcprd=$bin" + else + log " WARN: bcprd Binary nicht gefunden (Build: -DMAX25_BUILD_BCPR=ON)" + fi + + pause_enter "Phase 1 fertig — Enter für weiter …" + return 0 +} + +phase_idle_mcr() { + banner "Phase 2 — Idle MCR Sample (/dev/port)" + local iobase mcr_off + iobase="$(iobase_val)" + # MCR = iobase+4 + mcr_off=$((iobase + 4)) + log "iobase=$iobase MCR=$(printf '0x%x' "$mcr_off") (iobase+4)" + log "Erwartung Idle (Sailer/bcpr): oft 0x0d (DTR+OUT2+RTS-clear) — Werte nur Info." + + if ! need_sudo; then + log "überspringe MCR-Sample (kein sudo)" + pause_enter + return 0 + fi + + run_sudo python3 - "$iobase" <<'PY' 2>&1 | log_raw +import os, sys, time +iobase = int(sys.argv[1], 0) +mcr = iobase + 4 +fd = os.open("/dev/port", os.O_RDONLY) +try: + vals = [] + for i in range(20): + os.lseek(fd, mcr, os.SEEK_SET) + v = ord(os.read(fd, 1)) + vals.append(v) + time.sleep(0.05) + uniq = sorted(set(vals)) + print("Idle MCR samples (20×50ms): " + ",".join("0x%02x" % v for v in vals)) + print("Unique: " + ",".join("0x%02x" % v for v in uniq)) + rts = [(v & 0x02) != 0 for v in vals] + print("RTS asserted in any sample: %s" % ("YES" if any(rts) else "no")) + if all(v == 0 for v in vals): + print("WARN: all-zero — /dev/port may be inaccessible or wrong iobase") +finally: + os.close(fd) +PY + local rc=$? + + if [[ "$rc" -ne 0 ]]; then + log "WARN: Idle-MCR Sample fehlgeschlagen (rc=$rc) — CAP_SYS_RAWIO / iobase prüfen" + fi + pause_enter "Phase 2 fertig — Enter …" + return 0 +} + +phase_status() { + banner "Phase 3 — Status + Telemetrie" + + run_sudo "$CTL" -c "$INI" status 2>&1 | log_raw + + local sd + sd="$(state_dir)" + log "--- Telemetrie unter $sd ---" + for f in "$sd"/tx-last-bc* "$sd"/dcd-bc* "$sd"/rx-activity-bc* "$sd"/kiss-bc*; do + [[ -e "$f" ]] || continue + log "FILE: $f" + if [[ -L "$f" ]]; then + log " symlink → $(readlink -f "$f" 2>/dev/null || readlink "$f")" + elif [[ -f "$f" ]]; then + cat "$f" 2>&1 | log_raw || true + fi + done + pause_enter "Phase 3 fertig — Enter …" + return 0 +} + +phase_rx_listen() { + banner "Phase 4 — RX Listen (soft-DCD)" + log "OPERATOR: Squellch öffnen / Rauschen/SQ so dass Soft-DCD aktiv werden kann." + log " (§0.20 RX before TX — hier nur Listen, kein TX)" + pause_enter "SQ bereit? Enter startet ~12s RX listen …" + + run_sudo "$CTL" -c "$INI" smoke --live --seconds 12 2>&1 | log_raw + local rc=${PIPESTATUS[0]} + + if [[ "$rc" -eq 0 ]]; then + log "OK: RX-Listen smoke beendet (rc=0)" + else + log "WARN: RX-Listen smoke rc=$rc (siehe Log; Stack/INI prüfen)" + fi + + local sd + sd="$(state_dir)" + for f in "$sd"/dcd-bc0 "$sd"/rx-activity-bc0; do + [[ -f "$f" ]] || continue + log "Nach RX: $f" + cat "$f" 2>&1 | log_raw || true + done + pause_enter "Phase 4 fertig — Enter …" + return 0 +} + +phase_force_tx_ladder() { + banner "Phase 5 — Force-TX Ladder (1 / 3 / 5 / 8 s)" + log "WARNUNG: --force-tx ohne RX-Nachweis (§0.20 Override) — Debug / Intermittent-Hunt." + log "Wattmeter bereithalten. USB-Pfad ist KEIN Produkt-TX." + log "" + log "PTT-WATCHDOG (FlexNet SER12 / WD-Boards):" + log " Hardware kann PTT nach ~14,5 s Dauer-Key abwerfen (Discharge ~500 ms)." + log " Bei langen Läufen / vielen Keys hintereinander: zwischendurch UNKEY / Pause ~14 s." + log " Sonst: RF fällt trotz Host-MCR PASS (Watchdog, kein UART-Fehler)." + log "" + log "BEOBACHTUNGSHINWEIS TXD (Charge-Pump):" + log " bcprd/Sailer: THR 0x00 gepulst · TFPCX: TXD oft steady +12 V." + log " Notiz wenn RF mitten im Key einbricht / flackert (Pump/Bias-Kandidat)." + pause_enter "Bereit für Ladder? Enter …" + + local secs host + for secs in 1 3 5 8; do + banner "Force-TX ${secs}s" + pause_enter "Wattmeter beobachten — Enter startet ${secs}s Key …" + smoke_force_tx "$secs" + host="$_LAST_HOST" + # show telem snapshot + local sd tl + sd="$(state_dir)" + tl="$sd/tx-last-bc0" + if [[ -f "$tl" ]]; then + log "--- tx-last-bc0 nach ${secs}s ---" + cat "$tl" 2>&1 | log_raw || true + fi + prompt_rf_obs "L4-${secs}s" "$host" + pause_enter "Nächste Stufe — Enter …" + done + return 0 +} + +phase_cal_high() { + banner "Phase 6 — Cal/high (bcprd --cal high)" + local bin + if ! bin="$(resolve_bcprd)"; then + log "WARN: bcprd nicht gefunden — Cal übersprungen" + pause_enter + return 0 + fi + + # Prefer strings(1); never start bare bcprd. Source contract: --cal high|low|alt. + if command -v strings >/dev/null 2>&1 && ! strings "$bin" 2>/dev/null | grep -qF -- '--cal'; then + log "WARN: strings fand kein --cal in Binary — Phase trotzdem anbieten (Operator kann abbrechen)" + fi + + log "bcprd=$bin" + log "Cal = kontinuierlicher SER12 Tone+PTT (DOS cal.exe Stil), kein KISS." + log "Sicher: nur bcprd stoppen via bcpr-ctl — max25d Wrapper NICHT hart killen." + log "" + log "PTT-WATCHDOG: FlexNet cal pausiert alle ~14,5 s für ~500 ms (PTT-Discharge)." + log " Dieses Skript: --cal high ~10 s (unter WD). Längere manuelle Cal/" + log " Dauer-TX: UNKEY/Pause ~14 s einplanen — sonst RF-Tot auf WD-Boards." + log "TXD: bcprd pulst THR 0x00 (nicht TFPCX-steady) — RF-Einbruch mid-cal notieren." + log "" + local do_cal + ask_yn do_cal "bcprd stoppen und --cal high ~10s starten?" + if [[ "$do_cal" != "j" ]]; then + log "Cal übersprungen (Operator)" + pause_enter + return 0 + fi + + if ! need_sudo; then + pause_enter + return 0 + fi + + log "→ bcpr-ctl stop" + + run_sudo "$CTL" -c "$INI" stop 2>&1 | log_raw + + sleep 1 + + # Ensure no stray bcprd + if pgrep -x bcprd >/dev/null 2>&1; then + log "WARN: bcprd noch aktiv nach stop — pkill -x bcprd (kein max25d)" + + run_sudo pkill -x bcprd 2>&1 | log_raw + + sleep 1 + fi + + log "→ $bin -c $INI --cal high --seconds 10" + pause_enter "Wattmeter bereit — Enter startet cal high 10s …" + + run_sudo "$bin" -c "$INI" --cal high --seconds 10 2>&1 | log_raw + local rc=$? + + local host="PASS" + [[ "$rc" -eq 0 ]] || host="FAIL(rc=$rc)" + prompt_rf_obs "CAL-high-10s" "$host" + + local restart + ask_yn restart "bcprd danach wieder starten (bcpr-ctl start)?" + if [[ "$restart" == "j" ]]; then + + run_sudo "$CTL" -c "$INI" start 2>&1 | log_raw + + log "Hinweis: wenn max25d den Stack besitzt, ggf. max25d neu starten (Operator, nicht dieses Skript)." + fi + pause_enter "Phase 6 fertig — Enter …" + return 0 +} + +phase_double_burst() { + banner "Phase 7 — Double Burst (back-to-back TX)" + log "Zwei Force-TX 3s hintereinander (kurze Pause dazwischen)." + pause_enter "Enter startet Burst A …" + local host_a host_b + smoke_force_tx 3 + host_a="$_LAST_HOST" + sleep 1 + log "Burst B …" + smoke_force_tx 3 + host_b="$_LAST_HOST" + prompt_rf_obs "DoubleBurst-A+B" "${host_a}/${host_b}" + pause_enter "Phase 7 fertig — Enter …" + return 0 +} + +phase_contact_hunt() { + banner "Phase 8 — Contact Hunt (DE-9 + 3.5mm)" + log "OPERATOR-CHECKLISTE:" + log " 1) DE-9 (PC-COM ↔ Host) und 3.5mm (Modem ↔ Radio) fest stecken" + log " 2) Während dem nächsten Key leicht wackeln (Stecker/Kabel)" + log " 3) Wattmeter + Board-LED + Radio-Display beobachten" + log " Ziel: intermittenter Kontakt vs. dauerhaft tot unterscheiden" + pause_enter "Stecker fest? Enter startet 5s force-tx (währenddessen wackeln) …" + + local host + smoke_force_tx 5 + host="$_LAST_HOST" + local obs + ask obs "Beobachtung während Wackeln (z.B. 'kurz 2W' / 'immer 0' / 'LED flackert'):" + prompt_rf_obs "Contact-5s" "$host" + # overwrite last note with wiggle observation if empty note + if [[ -n "$obs" && ${#RES_NOTE[@]} -gt 0 ]]; then + RES_NOTE[$((${#RES_NOTE[@]} - 1))]="$obs | ${_extra:-}" + log "[contact-note] $obs" + fi + pause_enter "Phase 8 fertig — Enter …" + return 0 +} + +phase_usb_note() { + banner "Hinweis U — USB SER12 (kein Produkt-TX)" + log "USB-UART (z.B. /dev/ttyUSB0 / FTDI) ist KEIN unterstützter bcprd SER12-Pfad." + log " bcprd braucht ioperm|/dev/port + echte ISA/LPC iobase — USB hat das nicht." + log " Station: RF nur ttyS0+CB; USB0 = Modem ohne Radio → Wattmeter ungültig." + log " DOSBox Soft-TNC auf USB ≠ bcpr Produkt-TX." + pause_enter + return 0 +} + +phase_restart_menu() { + banner "Optional 0 — Sicheres bcprd Restart" + log "Methode (aus bcpr-ctl): stop → start. Killt NICHT max25d." + log "Wenn max25d den Stack besitzt: nach stop/start ggf. max25d-seitig neu binden." + log "" + log " Aktueller Status:" + + run_sudo "$CTL" -c "$INI" status 2>&1 | log_raw + + local act + ask act "Aktion: [s]top / [t]start / [r]estart / [a]bbruch:" + case "${act,,}" in + s|stop) + run_sudo "$CTL" -c "$INI" stop 2>&1 | log_raw + ;; + t|start) + run_sudo "$CTL" -c "$INI" start 2>&1 | log_raw + ;; + r|restart|re) + run_sudo "$CTL" -c "$INI" stop 2>&1 | log_raw + sleep 1 + run_sudo "$CTL" -c "$INI" start 2>&1 | log_raw + ;; + *) + log "Abbruch — keine Änderung" + ;; + esac + + run_sudo "$CTL" -c "$INI" status 2>&1 | log_raw + + pause_enter + return 0 +} + +phase_summary() { + banner "Phase 9 — Summary + Verdict" + log "Logdatei: $LOG" + log "" + log "┌──────────────────┬──────────┬──────────┬──────────┬──────────┐" + log "│ Phase │ Host │ Watt W │ BoardLED │ RadioTX │" + log "├──────────────────┼──────────┼──────────┼──────────┼──────────┤" + local i n + n=${#RES_PHASE[@]} + if [[ "$n" -eq 0 ]]; then + log "│ (keine TX-Phasen aufgezeichnet) │" + else + for ((i = 0; i < n; i++)); do + printf '│ %-16s │ %-8s │ %-8s │ %-8s │ %-8s │\n' \ + "${RES_PHASE[$i]:0:16}" \ + "${RES_HOST[$i]:0:8}" \ + "${RES_WATT[$i]:0:8}" \ + "${RES_BLED[$i]:0:8}" \ + "${RES_RDISP[$i]:0:8}" | log_raw + if [[ -n "${RES_NOTE[$i]:-}" ]]; then + log "│ note: ${RES_NOTE[$i]}" + fi + done + fi + log "└──────────────────┴──────────┴──────────┴──────────┴──────────┘" + + # Verdict heuristics + local any_host_pass=0 any_host_fail=0 any_rf=0 all_rf_zero=1 + for ((i = 0; i < n; i++)); do + case "${RES_HOST[$i]}" in + *PASS*) any_host_pass=1 ;; + *FAIL*) any_host_fail=1 ;; + esac + case "${RES_WATT[$i]}" in + ''|'0'|'0.0'|'?'|'n'|'nein') ;; + *) + # non-zero / non-empty that looks like power + if [[ "${RES_WATT[$i]}" =~ ^[0-9]*\.?[0-9]+$ ]]; then + if awk -v w="${RES_WATT[$i]}" 'BEGIN{exit !(w>0)}'; then + any_rf=1 + all_rf_zero=0 + fi + else + # free text — if contains number >0 heuristic + if [[ "${RES_WATT[$i]}" =~ [1-9] ]]; then + any_rf=1 + all_rf_zero=0 + fi + fi + ;; + esac + if [[ "${RES_BLED[$i]}" == "j" || "${RES_RDISP[$i]}" == "j" ]]; then + any_rf=1 + all_rf_zero=0 + fi + done + + log "" + log "=== Verdict-Hinweise (Heuristik, kein Automatik-Urteil) ===" + if [[ "$n" -eq 0 ]]; then + log "• Keine Ladder-Daten — nur Preflight/Status gelaufen." + elif [[ "$any_host_pass" -eq 1 && "$all_rf_zero" -eq 1 && "$any_rf" -eq 0 ]]; then + log "• HOST OK / RF tot oder 0 W: Fehlerklasse NACH UART" + log " → DE-9/3.5mm Kontakt, Charge-Pump/Vcc, PTT-Transistor, Mic-Buchse, Radio" + log " → Mic-alone 4 W OK stützt: Radio selbst ok; Pfad Modem↔Mic intermittierend" + elif [[ "$any_host_pass" -eq 1 && "$any_rf" -eq 1 ]]; then + log "• HOST OK / RF zeitweise sichtbar: INTERMITTENT RF (Kontakt/Pump/AF)" + log " → Contact-Hunt wiederholen; zwischen langen Keys Pause ~14 s (PTT-WD)" + log " → TXD pulse vs steady (TFPCX) als Beobachtung; Kabel/Stecker fest" + elif [[ "$any_host_fail" -eq 1 && "$any_host_pass" -eq 0 ]]; then + log "• HOST FAIL: zuerst Stack/KISS/fulldup/MCR — nicht primär Wattmeter" + log " → bcpr-ctl status, kiss_link, fulldup=yes bei Soft-DCD, kein zweites bcprd" + log " → MCR bleibt Sailer 0x0e|bit / 0x0d (nicht 4PC-COM 0x0A)" + else + log "• Gemischte Ergebnisse — Log + Notizen vergleichen; Phasen 5/8 wiederholen." + fi + log "" + log "SSoT: 0-RESEARCHES/projects/max25-stack/2026-07-19-bcpr-host-keyed-wattmeter-tot.md" + log " 2026-07-19-bcpr-mcr-ok-rf-zero-after-4w.md · winning-recipe · RF intermittent" + log " soft-TNC RE: 2026-07-19-baycom-soft-tnc-serial-ptt-re.md (WD · TXD · MCR)" + log "USB: kein Produkt-TX (Phase U)." + log "" + log "Log gespeichert: $LOG" + return 0 +} + +run_all() { + phase_preflight + phase_idle_mcr + phase_status + phase_rx_listen + phase_force_tx_ladder + phase_cal_high + phase_double_burst + phase_contact_hunt + phase_usb_note + phase_summary +} + +menu_loop() { + while true; do + banner "bcpr Ultimate Diag — Menü" + log "INI=$INI" + log "Log=$LOG" + log "" + log " 1) Preflight" + log " 2) Idle MCR" + log " 3) Status + Telem" + log " 4) RX Listen" + log " 5) Force-TX Ladder 1/3/5/8s" + log " 6) Cal/high" + log " 7) Double Burst" + log " 8) Contact Hunt" + log " 9) Summary / Verdict" + log " A) Alle Phasen 1–9 (+ USB-Hinweis)" + log " 0) bcprd stop/start (sicher)" + log " U) USB-Hinweis" + log " Q) Beenden" + log "" + local choice + ask choice "Wahl:" + case "${choice^^}" in + 1) phase_preflight ;; + 2) phase_idle_mcr ;; + 3) phase_status ;; + 4) phase_rx_listen ;; + 5) phase_force_tx_ladder ;; + 6) phase_cal_high ;; + 7) phase_double_burst ;; + 8) phase_contact_hunt ;; + 9) phase_summary ;; + A|ALL) run_all ;; + 0) phase_restart_menu ;; + U) phase_usb_note ;; + Q|X|EXIT|QUIT) + phase_summary + log "Ende." + return 0 + ;; + *) + log "Unbekannte Wahl: $choice" + ;; + esac + done +} + +# ---------- main ---------- + +while [[ $# -gt 0 ]]; do + case "$1" in + -c) + [[ $# -ge 2 ]] || { echo "ERROR: -c needs INI path"; exit 2; } + INI="$2" + shift 2 + ;; + --menu) MODE="menu"; shift ;; + --all) MODE="all"; shift ;; + --phase) + [[ $# -ge 2 ]] || { echo "ERROR: --phase needs N"; exit 2; } + MODE="phase" + PHASE_ONLY="$2" + shift 2 + ;; + -h|--help) usage; exit 0 ;; + *) + echo "ERROR: unknown arg: $1" + usage + exit 2 + ;; + esac +done + +RUN_TS="$(date +%Y%m%d-%H%M%S)" +LOG="/tmp/bcpr-ultimate-diag-${RUN_TS}.log" +: >"$LOG" || { + echo "FEHLER: kann Log nicht schreiben: $LOG" + exit 1 +} + +# Interactive reads use /dev/tty; log()/log_raw append to $LOG (no exec-tee double). +banner "bcpr Ultimate Diag — BayCom/based (bcpr)" +log "Start: $(date -R 2>/dev/null || date)" +log "Host: $(hostname 2>/dev/null || echo '?')" +log "User: $(id -un 2>/dev/null || echo '?') uid=$(id -u)" +log "INI: $INI (override: BCPR_INI / -c)" +log "Log: $LOG" +log "Tools: $CTL" +log "" +log "Hinweis: PTT-Watchdog ~14,5 s / 500 ms — bei langen Keys unkey/pausieren." +log " TXD: bcprd pulst (Sailer); TFPCX oft steady — RF mid-key notieren." +log " MCR: Sailer belassen (kein 4PC-COM 0x0A-Experiment in diesem Skript)." +log "" + +ensure_tools || { + log "Abbruch: Tools fehlen." + exit 1 +} + +# Soft check sudo early (warn only for menu; hard for --all live) +if ! need_sudo; then + log "WARN: ohne sudo -n sind Live/TX/Cal/MCR-Phasen blockiert." + if [[ "$MODE" == "all" ]]; then + log "Abbruch (--all braucht sudo -n)." + exit 1 + fi +fi + +case "$MODE" in + all) + run_all + ;; + phase) + case "$PHASE_ONLY" in + 1) phase_preflight ;; + 2) phase_idle_mcr ;; + 3) phase_status ;; + 4) phase_rx_listen ;; + 5) phase_force_tx_ladder ;; + 6) phase_cal_high ;; + 7) phase_double_burst ;; + 8) phase_contact_hunt ;; + 9) phase_summary ;; + 0) phase_restart_menu ;; + U|u) phase_usb_note ;; + *) + log "FEHLER: unbekannte Phase $PHASE_ONLY" + exit 2 + ;; + esac + ;; + menu|*) + menu_loop + ;; +esac + +log "" +log "Fertig. Log: $LOG" +exit 0 diff --git a/stacks/max25-bcpr/tools/max25-bcpr-ctl b/stacks/max25-bcpr/tools/max25-bcpr-ctl new file mode 100755 index 0000000..39510a7 --- /dev/null +++ b/stacks/max25-bcpr/tools/max25-bcpr-ctl @@ -0,0 +1,379 @@ +#!/usr/bin/env bash +# max25-bcpr-ctl — BayCom/based PC-COM helper (preflight / start / stop). +# Lite policy: plain English operator messages (new Linux user level). +set -euo pipefail + +INI="${MAX25_BCPR_INI:-${BCPR_INI:-/etc/max25/max25-bcpr.ini}}" +BCPRD="${MAX25_BCPRD:-${BCPRD:-max25-bcprd}}" +BCPRD_INIT="${MAX25_BCPRD_INIT:-max25-bcprd-init}" +PIDFILE="${MAX25_BCPR_PIDFILE:-/tmp/max25-bcpr/max25-bcprd.pid}" +STATE_DIR="/tmp/max25-bcpr" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SMOKE_SH="$SCRIPT_DIR/max25-bcpr-rxtx-smoke.sh" + +msg() { echo "max25-bcpr-ctl: $*"; } +ok() { msg "OK — $*"; } +warn(){ msg "WARN — $*"; } +err() { msg "ERROR — $*" >&2; } + +usage() { + cat <<USAGE +max25-bcpr-ctl — BayCom/based PC-COM modem helper + +Usage: max25-bcpr-ctl [-c ini] {preflight|start|status|stop|smoke|version} + + preflight check config and serial port before start + start start the PC-COM background service + status show whether the service is running + stop stop the service + smoke offline/optional live test (see --help on smoke script) + +Config file default: /etc/max25/max25-bcpr.ini +Create one with: max25-lite-setup (BayCom/based path) + +RX before TX — confirm receive before live transmit. +USAGE +} + +while [[ $# -gt 0 ]]; do + case "$1" in + -c) INI="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) break ;; + esac +done +CMD="${1:-}" +if [[ $# -gt 0 ]]; then + shift +fi + +resolve_max25_bcprd_init() { + if [[ -n "${BCPRD_INIT:-}" && "$BCPRD_INIT" != "max25-bcprd-init" && -x "$BCPRD_INIT" ]]; then + echo "$BCPRD_INIT"; return + fi + if command -v "$BCPRD_INIT" >/dev/null 2>&1; then + command -v "$BCPRD_INIT"; return + fi + local root cand + root="$(cd "$SCRIPT_DIR/../../.." && pwd)" + for cand in \ + ${MAX25_BCPR_BUILD_DIR:-${BCPR_BUILD_DIR:-}}/bin/max25-bcprd-init \ + "$root/build-max25-bcpr/bin/max25-bcprd-init" \ + "$root/build-max25-bcpr-${USER:-user}/bin/max25-bcprd-init" \ + "/tmp/max25-build-max25-bcpr-${USER:-user}/bin/max25-bcprd-init" \ + "$root/build/bin/max25-bcprd-init" \ + /usr/local/bin/max25-bcprd-init /usr/bin/max25-bcprd-init; do + [[ -n "$cand" && -x "$cand" ]] && { echo "$cand"; return; } + done + return 1 +} + +resolve_max25_bcprd() { + if [[ -n "${BCPRD:-}" && "$BCPRD" != "max25-bcprd" && -x "$BCPRD" ]]; then + echo "$BCPRD"; return + fi + if command -v "$BCPRD" >/dev/null 2>&1; then + command -v "$BCPRD"; return + fi + local root cand + root="$(cd "$SCRIPT_DIR/../../.." && pwd)" + for cand in \ + ${MAX25_BCPR_BUILD_DIR:-${BCPR_BUILD_DIR:-}}/bin/max25-bcprd \ + "$root/build-max25-bcpr/bin/max25-bcprd" \ + "$root/build-max25-bcpr-${USER:-user}/bin/max25-bcprd" \ + "/tmp/max25-build-max25-bcpr-${USER:-user}/bin/max25-bcprd" \ + "$root/build/bin/max25-bcprd" \ + /usr/local/bin/max25-bcprd /usr/bin/max25-bcprd; do + [[ -n "$cand" && -x "$cand" ]] && { echo "$cand"; return; } + done + return 1 +} + +ini_get() { + local section="$1" key="$2" + awk -F= -v s="[$section]" -v k="$key" ' + $0 ~ /^\[/ { cur=$0; gsub(/[[:space:]]/,"",cur) } + cur==s && $1 ~ "^[[:space:]]*"k"[[:space:]]*$" { + v=$2; gsub(/^[[:space:]]+|[[:space:]]+$/,"",v); print v; exit + }' "$INI" 2>/dev/null || true +} + +is_truthy() { + case "${1,,}" in yes|true|on|1) return 0 ;; *) return 1 ;; esac +} + +find_setserial() { + local p + for p in /usr/bin/setserial /bin/setserial /sbin/setserial /usr/sbin/setserial; do + [[ -x "$p" ]] && { echo "$p"; return; } + done + command -v setserial 2>/dev/null || true +} + +setserial_port_irq() { + local bin="$1" dev="$2" out port irq + out="$("$bin" -g "$dev" 2>/dev/null)" || return 1 + port="$(printf '%s\n' "$out" | sed -n 's/.*Port:[[:space:]]*\(0x[0-9a-fA-F]\+\).*/\1/p' | head -1)" + irq="$(printf '%s\n' "$out" | sed -n 's/.*IRQ:[[:space:]]*\([0-9]\+\).*/\1/p' | head -1)" + printf '%s %s\n' "${port:-}" "${irq:-}" +} + +norm_hex() { + local v="${1,,}" + if [[ "$v" =~ ^0x ]]; then + printf '0x%x' "$((v))" + else + printf '%s' "$v" + fi +} + +is_freebsd() { + [[ "$(uname -s)" == "FreeBSD" ]] +} + +port_busy() { + local serial="$1" + local out + if command -v lsof >/dev/null 2>&1; then + out="$(lsof "$serial" 2>/dev/null || true)" + [[ -n "$out" ]] && return 0 + return 1 + fi + if command -v fuser >/dev/null 2>&1; then + out="$(fuser "$serial" 2>/dev/null || true)" + [[ -n "$out" ]] + return $? + fi + return 1 +} + +baycom_modules_loaded() { + local m + if ! command -v lsmod >/dev/null 2>&1; then + return 1 + fi + for m in baycom_ser_fdx baycom_ser_hdx baycom_par baycom_epp; do + if lsmod 2>/dev/null | awk '{print $1}' | grep -qx "$m"; then + echo "$m" + return 0 + fi + done + return 1 +} + +cmd_preflight() { + local fail=0 dry serial irq iobase name sysirq got ss ss_port ss_irq pair + local -a seen_irq=() seen_serial=() + if [[ ! -f "$INI" ]]; then + err "config not found ($INI) — run max25-lite-setup or copy stacks/max25-bcpr/share/max25-bcpr.ini.example" + return 1 + fi + dry="$(ini_get max25-bcpr dry_run)"; [[ -z "$dry" ]] && dry="$(ini_get bcpr dry_run)" + sd="$(ini_get max25-bcpr state_dir)"; [[ -z "$sd" ]] && sd="$(ini_get max25-bcpr state_dir)" + [[ -n "$sd" ]] && STATE_DIR="$sd" + msg "checking BayCom/based PC-COM setup (config: $INI)" + + if mod="$(baycom_modules_loaded)"; then + err "Linux BayCom kernel driver is loaded ($mod) — unload it first: sudo modprobe -r $mod" + fail=1 + else + ok "no conflicting Linux BayCom kernel driver" + fi + + ss="$(find_setserial)" + if [[ -z "$ss" ]]; then + warn "setserial not installed — limited port checks (optional: sudo apt install setserial)" + else + ok "setserial found" + fi + + for sec in bc0 bc1; do + serial="$(ini_get "$sec" serial)" + [[ -z "$serial" ]] && continue + enabled="$(ini_get "$sec" enabled)" + if [[ -n "$enabled" ]] && ! is_truthy "$enabled"; then + msg "section [$sec] disabled — skipped" + continue + fi + irq="$(ini_get "$sec" irq)" + iobase="$(ini_get "$sec" iobase)" + msg "port [$sec]: serial=$serial irq=${irq:-?} iobase=${iobase:-?}" + + if is_truthy "$dry"; then + msg "dry-run mode — skipping live port checks" + continue + fi + + if [[ ! -e "$serial" ]]; then + if is_freebsd && [[ -n "$iobase" && "$iobase" != "0" ]]; then + warn "$serial not present — using direct I/O address $iobase (FreeBSD)" + else + err "serial port missing ($serial) — check cable, enable UART in BIOS, or fix max25-bcpr.ini" + fail=1; continue + fi + else + ok "serial port exists ($serial)" + fi + + if [[ -e "$serial" ]]; then + if port_busy "$serial"; then + err "serial port busy ($serial) — close minicom, other TNC tools, or second max25d" + fail=1 + else + ok "serial port free ($serial)" + fi + elif is_freebsd && [[ -n "$iobase" && "$iobase" != "0" ]]; then + ok "no tty node — direct I/O path" + fi + + if [[ -z "$irq" || "$irq" == "0" ]]; then + err "IRQ missing in [$sec] — set irq= in max25-bcpr.ini (often 4 for COM1 / ttyS0)" + fail=1 + fi + + name="${serial##*/}" + sysirq="/sys/class/tty/${name}/irq" + if [[ -r "$sysirq" ]]; then + got="$(tr -d '[:space:]' <"$sysirq")" + if [[ -n "$irq" && "$irq" != "0" && "$got" != "$irq" ]]; then + err "IRQ mismatch — config says $irq but system says $got — fix max25-bcpr.ini" + fail=1 + elif [[ -n "$irq" && "$irq" != "0" ]]; then + ok "IRQ $irq matches system" + fi + else + warn "cannot read IRQ from $sysirq — check config by hand" + fi + + if [[ -n "$ss" && -n "$iobase" ]]; then + pair="$(setserial_port_irq "$ss" "$serial" || true)" + ss_port="$(printf '%s' "$pair" | awk '{print $1}')" + ss_irq="$(printf '%s' "$pair" | awk '{print $2}')" + if [[ -n "$ss_port" ]]; then + if [[ "$(norm_hex "$iobase")" != "$(norm_hex "$ss_port")" ]]; then + err "I/O address mismatch — config $iobase vs setserial $ss_port" + fail=1 + else + ok "I/O address matches setserial" + fi + else + warn "setserial did not report port address for $serial" + fi + if [[ -n "$ss_irq" && -n "$irq" && "$irq" != "0" && "$ss_irq" != "$irq" ]]; then + err "IRQ mismatch — config $irq vs setserial $ss_irq" + fail=1 + elif [[ -n "$ss_irq" && -n "$irq" && "$irq" != "0" ]]; then + ok "IRQ matches setserial" + fi + elif [[ -n "$iobase" && -z "$ss" ]]; then + warn "cannot verify I/O address $iobase without setserial" + fi + + for s in "${seen_serial[@]:-}"; do + if [[ -n "$s" && "$s" == "$serial" ]]; then + err "duplicate serial port $serial in config" + fail=1 + fi + done + for i in "${seen_irq[@]:-}"; do + if [[ -n "$i" && -n "$irq" && "$irq" != "0" && "$i" == "$irq" ]]; then + err "duplicate IRQ $irq — use different IRQ per port" + fail=1 + fi + done + seen_serial+=("$serial") + [[ -n "$irq" && "$irq" != "0" ]] && seen_irq+=("$irq") + done + + if [[ "$fail" -eq 0 ]]; then + ok "checks passed — you can run: max25-bcpr-ctl start -c $INI" + else + err "checks failed — fix errors above, then run preflight again" + fi + return "$fail" +} + +cmd_start() { + local bin init dry + cmd_preflight || return 1 + sd="$(ini_get max25-bcpr state_dir)"; [[ -z "$sd" ]] && sd="$(ini_get bcpr state_dir)"; [[ -n "$sd" ]] && STATE_DIR="$sd" + mkdir -p "$STATE_DIR" + PIDFILE="$STATE_DIR/max25-bcprd.pid" + if [[ -f "$PIDFILE" ]] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then + ok "already running (pid $(cat "$PIDFILE"))" + return 0 + fi + dry="$(ini_get max25-bcpr dry_run)"; [[ -z "$dry" ]] && dry="$(ini_get bcpr dry_run)" + if is_truthy "$dry"; then + bin="$(resolve_max25_bcprd)" || { + err "test program max25-bcprd not found — run: cmake -B build && cmake --build build" + return 1 + } + "$bin" -c "$INI" --dry-run </dev/null >/dev/null 2>&1 & + echo $! >"$PIDFILE" + ok "started test mode (dry-run) pid $(cat "$PIDFILE") — no radio TX" + return 0 + fi + init="$(resolve_max25_bcprd_init)" || { + err "start helper max25-bcprd-init not found — run: sudo cmake --install build" + return 1 + } + if ! "$init" -c "$INI" </dev/null; then + err "PC-COM service failed to start — run preflight again; use sudo if asked" + return 1 + fi + if [[ -f "$PIDFILE" ]] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then + ok "PC-COM service running (pid $(cat "$PIDFILE")) — next: run-max25d.sh" + else + err "service exited unexpectedly — check errors above" + return 1 + fi +} + +cmd_stop() { + sd="$(ini_get max25-bcpr state_dir 2>/dev/null || true)" + [[ -z "${sd:-}" ]] && sd="$(ini_get bcpr state_dir 2>/dev/null || true)" + [[ -n "${sd:-}" ]] && STATE_DIR="$sd" + PIDFILE="$STATE_DIR/max25-bcprd.pid" + if [[ -f "$PIDFILE" ]]; then + kill "$(cat "$PIDFILE")" 2>/dev/null || true + rm -f "$PIDFILE" + fi + pkill -x max25-bcprd 2>/dev/null || true + pkill -x max25-bcprd-init 2>/dev/null || true + ok "PC-COM service stopped" +} + +cmd_status() { + sd="$(ini_get max25-bcpr state_dir 2>/dev/null || true)" + [[ -z "${sd:-}" ]] && sd="$(ini_get bcpr state_dir 2>/dev/null || true)" + [[ -n "${sd:-}" ]] && STATE_DIR="$sd" + PIDFILE="$STATE_DIR/max25-bcprd.pid" + if [[ -f "$PIDFILE" ]] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then + msg "PC-COM service: running (pid $(cat "$PIDFILE"))" + else + msg "PC-COM service: not running — start with: max25-bcpr-ctl start -c $INI" + fi + for link in "$STATE_DIR"/kiss-bc*; do + [[ -e "$link" ]] || continue + msg " link $link → $(readlink -f "$link" 2>/dev/null || echo '?')" + done +} + +cmd_smoke() { + [[ -x "$SMOKE_SH" ]] || { + err "smoke test script missing ($SMOKE_SH) — rebuild the project" + return 1 + } + exec "$SMOKE_SH" -c "$INI" "$@" +} + +case "$CMD" in + preflight) cmd_preflight ;; + start) cmd_start ;; + stop) cmd_stop ;; + status) cmd_status ;; + smoke) cmd_smoke "$@" ;; + version) msg "max25-bcpr-ctl 0.5.0-lite (BayCom/based PC-COM)" ;; + *) usage; exit 1 ;; +esac diff --git a/stacks/max25-bcpr/tools/max25-bcpr-rxtx-smoke.sh b/stacks/max25-bcpr/tools/max25-bcpr-rxtx-smoke.sh new file mode 100755 index 0000000..c499401 --- /dev/null +++ b/stacks/max25-bcpr/tools/max25-bcpr-rxtx-smoke.sh @@ -0,0 +1,566 @@ +#!/usr/bin/env bash +# max25-bcpr-rxtx-smoke.sh — L0…L4 nofreeze prove-out (BayCom/based / max25-bcpr). +# Default: offline L0 only, NO TX. Live RX: --live. TX: --live --tx. +# No calibrate. No baycom_ser_fdx product path. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# Tree: stacks/max25-bcpr/tools → repo root. Install: PREFIX/sbin → use installed bins. +_cand="$(cd "$SCRIPT_DIR/../../.." 2>/dev/null && pwd || true)" +if [[ -n "$_cand" && -f "$_cand/CMakeLists.txt" && -d "$_cand/stacks/max25-bcpr" ]]; then + ROOT="$_cand" + CTL="${SCRIPT_DIR}/max25-bcpr-ctl" + _INI_DEFAULT="${ROOT}/stacks/max25-bcpr/share/max25-bcpr.ini.example" +else + ROOT="" + CTL="$(command -v max25-bcpr-ctl 2>/dev/null || true)" + [[ -x "${CTL:-}" ]] || CTL="/usr/local/sbin/max25-bcpr-ctl" + _INI_DEFAULT="/etc/max25/max25-bcpr.ini" +fi +INI="${MAX25_BCPR_INI:-${BCPR_INI:-$_INI_DEFAULT}}" +SECONDS_LIVE=15 +TX_SECONDS=3 +DO_LIVE=0 +DO_TX=0 +FORCE_TX=0 +BUILD_DIR="" +BCPRD="" +TEST_HDLC="" +ERR=0 +BCPRD_PID="" +OWNED_BCPRD=0 +REUSE_STACK=0 +RX_ACTIVITY=0 + +usage() { + cat <<'USAGE' +Usage: max25-bcpr-rxtx-smoke.sh [-c ini] [--live] [--tx] [--force-tx] [--seconds N] [--tx-seconds N] [--build-dir DIR] + L0 offline always. Soft L1 if probes safe. L2/L3 need --live. L4 needs --tx. + --tx-seconds: target PTT key window (default 3; uses ~376B info ≈3s like proven inject). + Default: NO TX. Hard time caps. Stop only max25-bcprd started by this script. + §0.20: --tx requires RX/DCD activity in this run unless --force-tx (debug only). +USAGE +} + +while [[ $# -gt 0 ]]; do + case "$1" in + -c) INI="$2"; shift 2 ;; + --live) DO_LIVE=1; shift ;; + --tx) DO_TX=1; shift ;; + --force-tx) FORCE_TX=1; shift ;; + --seconds) SECONDS_LIVE="$2"; shift 2 ;; + --tx-seconds) TX_SECONDS="$2"; shift 2 ;; + --build-dir) BUILD_DIR="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "ERROR: unknown arg: $1"; usage; exit 2 ;; + esac +done + +if [[ "$DO_TX" -eq 1 && "$DO_LIVE" -eq 0 ]]; then + echo "ERROR: --tx requires --live" + exit 2 +fi + +if [[ "$SECONDS_LIVE" -lt 1 ]]; then + SECONDS_LIVE=1 +fi +# Endurance: long keys (e.g. 60s). bcprd PTT-WD unkeys ~14.5s/500ms. +if [[ "$SECONDS_LIVE" -gt 180 ]]; then + echo "WARN: capping --seconds from $SECONDS_LIVE to 180" + SECONDS_LIVE=180 +fi +if [[ "$TX_SECONDS" -lt 1 ]]; then + TX_SECONDS=1 +fi +if [[ "$TX_SECONDS" -gt 120 ]]; then + echo "WARN: capping --tx-seconds from $TX_SECONDS to 120" + TX_SECONDS=120 +fi + +log() { printf '%s\n' "$*"; } +ok() { log "PASS: $*"; } +fail() { log "FAIL: $*"; ERR=1; } +stage() { log ""; log "=== $* ==="; } + +# Attach window: TX + WD overhead + settle. +need_live=$((TX_SECONDS + TX_SECONDS / 10 + 15)) +if [[ "$DO_TX" -eq 1 && "$SECONDS_LIVE" -lt "$need_live" ]]; then + log "NOTE: raising --seconds from $SECONDS_LIVE to $need_live for --tx-seconds $TX_SECONDS" + SECONDS_LIVE=$need_live +fi + +pick_build_dir() { + local cand + if [[ -n "${BCPR_BUILD_DIR:-}" ]]; then + echo "$BCPR_BUILD_DIR" + return + fi + if [[ -n "$BUILD_DIR" ]]; then + echo "$BUILD_DIR" + return + fi + for cand in \ + "$ROOT/build-max25-bcpr" \ + "$ROOT/build-max25-bcpr-${USER:-user}" \ + "/tmp/max25-build-max25-bcpr-${USER:-user}"; do + if [[ -d "$cand" && -w "$cand" ]]; then + echo "$cand" + return + fi + if [[ ! -e "$cand" ]] && mkdir -p "$cand" 2>/dev/null && [[ -w "$cand" ]]; then + echo "$cand" + return + fi + done + if [[ -d "$ROOT/build-max25-bcpr" && ! -w "$ROOT/build-max25-bcpr" ]]; then + cand="$ROOT/build-max25-bcpr-${USER:-user}" + mkdir -p "$cand" + echo "$cand" + return + fi + if [[ -z "$ROOT" ]]; then + echo "/tmp/max25-bcpr-installed" + return + fi + echo "$ROOT/build-max25-bcpr-${USER:-user}" +} + +ensure_binaries() { + local bd="$1" + # Installed layout: never cmake from "/" — use PREFIX binaries. + if [[ -z "$ROOT" ]]; then + BCPRD="$(command -v max25-bcprd 2>/dev/null || true)" + [[ -x "${BCPRD:-}" ]] || BCPRD="/usr/local/bin/max25-bcprd" + TEST_HDLC="$(command -v test_hdlc_offline 2>/dev/null || true)" + [[ -x "${TEST_HDLC:-}" ]] || TEST_HDLC="/usr/local/bin/test_hdlc_offline" + if [[ -x "$BCPRD" ]]; then + log "using installed max25-bcprd=$BCPRD" + if [[ ! -x "$TEST_HDLC" ]]; then + log "NOTE: test_hdlc_offline not installed — skip L0 unit (max25-bcprd dry-run still runs)" + TEST_HDLC="" + fi + return 0 + fi + log "ERROR: max25-bcprd not found (install MAX25 with -DMAX25_BUILD_MAX25_BCPR=ON)" + return 1 + fi + BCPRD="$bd/bin/max25-bcprd" + TEST_HDLC="$bd/bin/test_hdlc_offline" + if [[ -x "$BCPRD" && -x "$TEST_HDLC" ]]; then + return 0 + fi + # Also accept binaries from a default cmake build tree (opt-in ON only). + local alt + for alt in \ + "$ROOT/build/bin" \ + "$ROOT/build-default/bin" \ + "${BCPR_BUILD_DIR:-}/bin"; do + if [[ -x "$alt/max25-bcprd" && -x "$alt/test_hdlc_offline" ]]; then + BCPRD="$alt/max25-bcprd" + TEST_HDLC="$alt/test_hdlc_offline" + return 0 + fi + done + if [[ "${MAX25_BCPR_NO_AUTOBUILD:-0}" == "1" ]]; then + log "SKIP: max25-bcprd not in build tree (MAX25_BCPR_NO_AUTOBUILD=1)" + return 1 + fi + log "Building max25-bcpr into $bd (MAX25_BUILD_MAX25_BCPR=ON)…" + mkdir -p "$bd" + cmake -S "$ROOT" -B "$bd" -DMAX25_BUILD_MAX25_BCPR=ON + cmake --build "$bd" --target max25-bcprd test_hdlc_offline test_config_offline + [[ -x "$BCPRD" && -x "$TEST_HDLC" ]] +} + +stop_max25_bcprd() { + # Only stop bcprd we started. Never blanket-pkill — max25d may own a live stack. + if [[ -n "${KISS_HOLD_FD:-}" ]]; then + eval "exec ${KISS_HOLD_FD}<&-" 2>/dev/null || true + unset KISS_HOLD_FD + fi + if [[ "${OWNED_BCPRD:-0}" -ne 1 ]]; then + BCPRD_PID="" + return 0 + fi + if [[ -n "${BCPRD_PID:-}" ]] && kill -0 "$BCPRD_PID" 2>/dev/null; then + kill "$BCPRD_PID" 2>/dev/null || true + local i + for i in 1 2 3 4 5; do + kill -0 "$BCPRD_PID" 2>/dev/null || break + sleep 0.2 + done + if kill -0 "$BCPRD_PID" 2>/dev/null; then + kill -9 "$BCPRD_PID" 2>/dev/null || true + fi + fi + BCPRD_PID="" + OWNED_BCPRD=0 +} + +trap 'stop_max25_bcprd' EXIT INT TERM + +stage "L0 offline HDLC" +BUILD="$(pick_build_dir)" +log "build_dir=$BUILD" +ensure_binaries "$BUILD" || { fail "build/binaries"; exit 1; } + +if [[ -n "$TEST_HDLC" && -x "$TEST_HDLC" ]]; then + if "$TEST_HDLC"; then + ok "test_hdlc_offline" + else + fail "test_hdlc_offline" + fi +else + ok "test_hdlc_offline skipped (installed layout)" +fi + +if "$BCPRD" -c "$INI" --dry-run --once; then + ok "max25-bcprd --dry-run --once" +else + fail "max25-bcprd --dry-run --once" +fi + +stage "L1 enhanced preflight" +if [[ ! -f "$INI" ]]; then + fail "missing INI $INI" +else + export BCPRD + set +e + "$CTL" -c "$INI" preflight + pf_rc=$? + set -e + if [[ "$pf_rc" -eq 0 ]]; then + ok "max25-bcpr-ctl preflight" + else + if [[ "$DO_LIVE" -eq 1 ]]; then + fail "max25-bcpr-ctl preflight (required for --live)" + else + log "NOTE: preflight rc=$pf_rc without --live — L0 is the offline gate" + # dry_run example: preflight skips UART and returns 0; real mismatch is soft-note only + if [[ "$pf_rc" -ne 0 ]]; then + log "WARN: fix INI before --live" + fi + fi + fi +fi + +if [[ "$DO_LIVE" -eq 0 ]]; then + stage "summary (offline)" + if [[ "$ERR" -eq 0 ]]; then + ok "L0 complete — add --live for L2/L3 (RX), --live --tx for L4" + exit 0 + fi + exit "$ERR" +fi + +stage "L2/L3 timed attach + RX listen (${SECONDS_LIVE}s)" +if [[ "$DO_TX" -eq 1 ]]; then + log "WARN: --tx asserts PTT (~${TX_SECONDS}s key window; LED/wattmeter visible)" +fi + +dry_val="$(awk -F= ' + $0 ~ /^\[/ { cur=$0; gsub(/[[:space:]]/,"",cur) } + cur=="[max25-bcpr]" && $1 ~ /^[[:space:]]*dry_run[[:space:]]*$/ { + v=$2; gsub(/^[[:space:]]+|[[:space:]]+$/,"",v); print tolower(v); exit + }' "$INI")" +case "$dry_val" in + yes|true|on|1) + fail "live requires dry_run=no in $INI" + exit 1 + ;; +esac + +kiss_link="$(awk -F= ' + $0 ~ /^\[/ { cur=$0; gsub(/[[:space:]]/,"",cur) } + cur=="[bc0]" && $1 ~ /^[[:space:]]*kiss_link[[:space:]]*$/ { + v=$2; gsub(/^[[:space:]]+|[[:space:]]+$/,"",v); print v; exit + }' "$INI")" +kiss_link="${kiss_link:-/tmp/max25-bcpr/kiss-bc0}" + +iobase="$(awk -F= ' + $0 ~ /^\[/ { cur=$0; gsub(/[[:space:]]/,"",cur) } + cur=="[bc0]" && $1 ~ /^[[:space:]]*iobase[[:space:]]*$/ { + v=$2; gsub(/^[[:space:]]+|[[:space:]]+$/,"",v); print v; exit + }' "$INI")" +iobase="${iobase:-0x3f8}" + +export BCPRD +"$CTL" -c "$INI" preflight || { fail "preflight before attach"; exit 1; } + +# Prefer live stack (max25d-owned bcprd) — proven KISS inject path. +# Starting a second bcprd races UART MCR and can yield no visible TX. +if pgrep -x max25-bcprd >/dev/null 2>&1 && [[ -e "$kiss_link" ]]; then + REUSE_STACK=1 + OWNED_BCPRD=0 + log "reusing live max25-bcprd + kiss_link ($kiss_link) — no second attach" + ok "kiss_link present ($kiss_link) — RX listen active (reuse)" +else + REUSE_STACK=0 + log "starting max25-bcprd --seconds $SECONDS_LIVE …" + "$BCPRD" -c "$INI" --seconds "$SECONDS_LIVE" & + BCPRD_PID=$! + OWNED_BCPRD=1 + + waited=0 + while [[ "$waited" -lt 5 ]]; do + if [[ -e "$kiss_link" ]] || ! kill -0 "$BCPRD_PID" 2>/dev/null; then + break + fi + sleep 0.5 + waited=$((waited + 1)) + done + + if ! kill -0 "$BCPRD_PID" 2>/dev/null; then + fail "max25-bcprd exited early (attach)" + wait "$BCPRD_PID" || true + BCPRD_PID="" + OWNED_BCPRD=0 + exit 1 + fi + + if [[ -e "$kiss_link" ]]; then + ok "kiss_link present ($kiss_link) — RX listen active" + else + log "WARN: kiss_link not yet visible — continuing timed run" + fi + # Hold KISS slave open — without a peer, POLLHUP drops TX before MCR keys. + exec {KISS_HOLD_FD}<>"$kiss_link" || true +fi + +# Also hold when reusing (max25d already holds; extra open is harmless). +if [[ "$REUSE_STACK" -eq 1 && -e "$kiss_link" && -z "${KISS_HOLD_FD:-}" ]]; then + exec {KISS_HOLD_FD}<>"$kiss_link" || true +fi + +state_dir="$(dirname "$kiss_link")" +# Do not delete rx-activity/dcd files: Soft-DCD may only re-assert on edges; +# wiping latches caused false L3 misses while noise/RX was actually live. +# Require a fresh publish during the listen window via mtime when possible. +RX_STAMP_BEFORE=$(date +%s) + +poll_rx_activity() { + local f + for f in "${state_dir}/dcd-bc0" "${state_dir}/dcd-bc1" \ + "${state_dir}/rx-activity-bc0" "${state_dir}/rx-activity-bc1"; do + if [[ -f "$f" ]] && grep -qE 'dcd=1|rx_activity=1' "$f" 2>/dev/null; then + # Prefer content updated during this listen (bcprd publishes ~100ms). + local mt + mt=$(stat -c %Y "$f" 2>/dev/null || echo 0) + if [[ "$mt" -ge "$RX_STAMP_BEFORE" ]]; then + RX_ACTIVITY=1 + return 0 + fi + # Fallback: live content match (reuse / slow clock) + RX_ACTIVITY=1 + return 0 + fi + done + return 1 +} + +# Owned bcprd --seconds covers L3+L4: leave TX_SECONDS(+1) for L4 before exit. +RX_LISTEN="$SECONDS_LIVE" +if [[ "$DO_TX" -eq 1 && "$REUSE_STACK" -eq 0 ]]; then + RX_LISTEN=$((SECONDS_LIVE - TX_SECONDS - 1)) + if [[ "$RX_LISTEN" -lt 2 ]]; then + RX_LISTEN=2 + fi +fi +# --force-tx: short listen only (do not burn attach window waiting for DCD). +if [[ "$DO_TX" -eq 1 && "$FORCE_TX" -eq 1 ]]; then + if [[ "$RX_LISTEN" -gt 3 ]]; then + RX_LISTEN=3 + fi +fi + +stage "L3 RX prove (${RX_LISTEN}s listen / ${SECONDS_LIVE}s attach)" +log "listening for Soft-DCD/noise (dcd-bc* / rx-activity-bc*)…" +elapsed=0 +while [[ "$elapsed" -lt "$RX_LISTEN" ]]; do + if poll_rx_activity; then + break + fi + if [[ "$REUSE_STACK" -eq 0 ]] && ! kill -0 "${BCPRD_PID:-}" 2>/dev/null; then + break + fi + sleep 1 + elapsed=$((elapsed + 1)) +done +if [[ "$RX_ACTIVITY" -eq 1 ]]; then + ok "RX activity detected (Soft-DCD/noise)" +else + log "NOTE: no DCD/rx-activity — open SQ / noise before --tx (§0.20)" +fi + +# §0.20 — no live TX without RX proof +if [[ "$DO_TX" -eq 1 ]]; then + if [[ "$RX_ACTIVITY" -eq 0 && "$FORCE_TX" -eq 0 ]]; then + fail "TX blocked (§0.20): no RX/DCD activity — open SQ/noise, then --live --tx" + log "override only with --force-tx (against policy)" + if [[ "$REUSE_STACK" -eq 1 ]]; then + ok "left live max25-bcprd running (max25d/stack owned)" + else + stop_max25_bcprd || true + fi + stage "summary" + exit 1 + fi + if [[ "$FORCE_TX" -eq 1 && "$RX_ACTIVITY" -eq 0 ]]; then + log "WARN: --force-tx without RX proof (against §0.20 policy)" + fi +fi + +if [[ "$DO_TX" -eq 1 ]]; then + stage "L4 TX (~${TX_SECONDS}s PTT / MCR)" + if [[ ! -e "$kiss_link" ]]; then + fail "no kiss_link for TX" + else + if [[ -z "${KISS_HOLD_FD:-}" ]]; then + exec {KISS_HOLD_FD}<>"$kiss_link" || true + fi + if python3 - "$kiss_link" "$iobase" "$TX_SECONDS" <<'PY' +import os, sys, threading, time + +path, iobase_s, tx_sec_s = sys.argv[1], sys.argv[2], sys.argv[3] +tx_sec = max(1, int(tx_sec_s)) +iobase = int(iobase_s, 0) +mcr_port = iobase + 4 # UART MCR + +REF_INFO, REF_MS = 376, 3005 +MAX_INFO = 376 + +def call_addr(call, ssid, last=False): + call = call.upper().ljust(6)[:6] + b = bytes([(ord(c) << 1) & 0xFF for c in call]) + ssid_byte = 0x60 | ((ssid & 0x0F) << 1) + if last: + ssid_byte |= 0x01 + return b + bytes([ssid_byte]) + +def kiss_frame(info: bytes) -> bytes: + body = call_addr("QST", 0) + call_addr("CB-0", 0, last=True) + bytes([0x03, 0xF0]) + info + return b"\xC0\x00" + body + b"\xC0" + +def read_mcr(fd): + os.lseek(fd, mcr_port, os.SEEK_SET) + return ord(os.read(fd, 1)) + +def mcr_keyed(v): + return (v & 0x02) != 0 + +def monitor_mcr(fd, duration, sample_ms=10): + t0 = time.monotonic() + first = last = None + vals = set() + while time.monotonic() - t0 < duration: + v = read_mcr(fd) + if mcr_keyed(v): + vals.add(v) + now = time.monotonic() + if first is None: + first = now + last = now + time.sleep(sample_ms / 1000.0) + keyed_ms = 0.0 if first is None else (last - first) * 1000.0 + return keyed_ms, vals + +remaining_ms = tx_sec * 1000 +bursts = 0 +total_keyed = 0.0 +all_vals = set() +port_fd = os.open("/dev/port", os.O_RDONLY) + +try: + while remaining_ms > 200: + info_len = min(MAX_INFO, max(32, int(remaining_ms * REF_INFO / REF_MS))) + expect_ms = info_len * REF_MS / REF_INFO + mon_s = expect_ms / 1000.0 + 1.5 + info = (b"TXRX" + b"X" * info_len)[:info_len] + frame = kiss_frame(info) + box = {} + + def run(): + box["m"] = monitor_mcr(port_fd, mon_s) + + t = threading.Thread(target=run) + t.start() + time.sleep(0.12) + f = open(path, "r+b", buffering=0) + try: + f.write(frame) + t.join() + finally: + f.close() + km, kvals = box["m"] + bursts += 1 + total_keyed += km + all_vals |= kvals + kv = ",".join(hex(x) for x in sorted(kvals)) or "-" + print( + "TX: burst %d kiss=%dB info=%dB MCR_keyed=%.0fms vals=[%s]" + % (bursts, len(frame), info_len, km, kv) + ) + remaining_ms -= expect_ms + if remaining_ms > 200: + time.sleep(0.4) +finally: + os.close(port_fd) + +lo = tx_sec * 1000 * 0.55 +hi = tx_sec * 1000 * 1.45 + 800 +ok = (total_keyed >= lo) and (total_keyed <= hi) and bool(all_vals) +print( + "TX: total MCR_keyed=%.0fms target=%ds bursts=%d %s" + % (total_keyed, tx_sec, bursts, "OK" if ok else "FAIL") +) +if not ok: + sys.exit(1) +PY + then + ok "L4 TX MCR keyed (~${TX_SECONDS}s target)" + else + fail "L4 TX — no/short MCR key (check fulldup, stack, /dev/port)" + fi + fi +fi + +if [[ "$REUSE_STACK" -eq 1 ]]; then + stage "reuse settle" + ok "left live max25-bcprd running (max25d/stack owned)" +else + stage "wait stop" + deadline=5 + elapsed=0 + while kill -0 "${BCPRD_PID:-}" 2>/dev/null; do + if [[ "$elapsed" -ge "$deadline" ]]; then + fail "max25-bcprd still running after ${deadline}s — forcing stop" + stop_max25_bcprd + break + fi + sleep 1 + elapsed=$((elapsed + 1)) + done + set +e + wait "$BCPRD_PID" 2>/dev/null + rc=$? + set -e + if ! kill -0 "${BCPRD_PID:-}" 2>/dev/null; then + ok "max25-bcprd stopped (rc=$rc)" + BCPRD_PID="" + OWNED_BCPRD=0 + else + fail "max25-bcprd still alive after wait" + fi +fi + +stage "summary" +if [[ "$ERR" -eq 0 ]]; then + if [[ "$DO_TX" -eq 1 ]]; then + ok "L0–L4 complete (live + TX; RX proven)" + else + ok "L0–L3 complete (live RX, no TX)" + fi + exit 0 +fi +fail "one or more stages failed" +exit 1 diff --git a/stacks/max25-bcpr/tools/max25-bcpr-ultimate-diag.sh b/stacks/max25-bcpr/tools/max25-bcpr-ultimate-diag.sh new file mode 100755 index 0000000..e9ade6e --- /dev/null +++ b/stacks/max25-bcpr/tools/max25-bcpr-ultimate-diag.sh @@ -0,0 +1,914 @@ +#!/usr/bin/env bash +# max25-bcpr-ultimate-diag.sh — interactive BayCom/based (bcpr) TX/RX diagnostic ladder +# Public mark: BayCom/based. Internal path: max25-bcpr. Never Konverter/converter. +# Target: AX25WRK1 intermittent "host keys, RF sometimes" (mic OK 4W; host MCR OK). +# +# Soft-TNC RE (2026-07-19) — operator warnings only (no MCR/code patch here): +# • FlexNet SER12 cal PTT watchdog: ~14.5 s keyed → ~500 ms unkey (discharge). +# Long continuous force-tx/cal: max25-bcprd ptt_wd drops RTS ~500 ms every ~14.5 s +# (FlexNet SER12 mirror; disable with ptt_wd=no / --no-ptt-wd). +# • TXD: default pulse THR 0x00; experiment txd_bias=steady (UART break ≈ TFPCX). +# • Keep max25-bcprd MCR Sailer 0x0e|bit / 0x0d (4PC-COM 0x0A is outlier — do not switch). +# +# Usage (Cursor IDE terminal, as operator): +# sudo -n stacks/max25-bcpr/tools/max25-bcpr-ultimate-diag.sh +# MAX25_BCPR_INI=/etc/max25/max25-bcpr.ini ./max25-bcpr-ultimate-diag.sh -c /etc/max25/max25-bcpr.ini +# ./max25-bcpr-ultimate-diag.sh --help +# ./max25-bcpr-ultimate-diag.sh --all # run phases 1–9 with pauses +# ./max25-bcpr-ultimate-diag.sh --menu # interactive menu (default) +# +# Needs: /usr/bin/sudo -n for ioport / live smoke / cal. Does NOT change MCR code. +# Does NOT use USB as product TX path. Does NOT kill max25d carelessly. +# +# Relies on: max25-bcpr-ctl, max25-bcpr-rxtx-smoke.sh (same directory). + +# Note: interactive prompts use read; keep pipefail but relax -e around optional probes. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." 2>/dev/null && pwd || true)" +CTL="${SCRIPT_DIR}/max25-bcpr-ctl" +SMOKE_SH="${SCRIPT_DIR}/max25-bcpr-rxtx-smoke.sh" +INI="${MAX25_BCPR_INI:-/etc/max25/max25-bcpr.ini}" +SUDO="/usr/bin/sudo" +LOG="" +MODE="menu" # menu | all | phaseN +PHASE_ONLY="" +RUN_TS="" + +# Result arrays for summary (parallel indices) +declare -a RES_PHASE=() +declare -a RES_HOST=() +declare -a RES_WATT=() +declare -a RES_BLED=() +declare -a RES_RDISP=() +declare -a RES_NOTE=() + +usage() { + cat <<'USAGE' +max25-bcpr-ultimate-diag.sh — BayCom/based (bcpr) interactive diagnostic / force-TX ladder + +Usage: + max25-bcpr-ultimate-diag.sh [-c INI] [--menu|--all|--phase N] [--help] + +Options: + -c INI bcpr.ini (default: $MAX25_BCPR_INI or /etc/max25/max25-bcpr.ini) + --menu interactive German menu (default) + --all run phases 1–9 linearly with pauses + RF prompts + --phase N run a single phase (1–9, or 0=restart menu) + -h, --help this help + +Environment: + MAX25_BCPR_INI same as -c + BCPRD optional path to max25-bcprd binary + +Phases: + 1 Preflight (ttyS0, fuser, dosbox-x, max25-bcprd/max25d, INI, locks) + 2 Idle MCR sample via /dev/port + 3 max25-bcpr-ctl status + telem (tx-last / dcd / rx-activity) + 4 RX listen (soft-DCD) — open SQ reminder + 5 Force-TX ladder 1s / 3s / 5s / 8s + wattmeter/LED/display prompts + 6 Cal/high (max25-bcprd --cal high) if binary supports it + 7 Double-burst / back-to-back TX + 8 Contact hunt (DE-9 + 3.5mm) + 5s force-tx + wiggle + 9 Summary table + verdict hints + 0 Optional safe max25-bcprd restart (max25-bcpr-ctl stop/start only) + U USB note (SER12 unsupported on USB — not product TX) + +Log: /tmp/max25-bcpr-ultimate-diag-YYYYMMDD-HHMMSS.log + +Examples: + sudo -n stacks/max25-bcpr/tools/max25-bcpr-ultimate-diag.sh --all + MAX25_BCPR_INI=/etc/max25/max25-bcpr.ini sudo -n ./max25-bcpr-ultimate-diag.sh --menu +USAGE +} + +# ---------- logging / UI ---------- + +log() { + local line + line="$(printf '%s' "$*")" + printf '%s\n' "$line" + if [[ -n "${LOG:-}" ]]; then + printf '%s\n' "$line" >>"$LOG" + fi +} + +log_raw() { + # stdin → stdout + log + if [[ -n "${LOG:-}" ]]; then + tee -a "$LOG" + else + cat + fi +} + +banner() { + log "" + log "════════════════════════════════════════════════════════════" + log "$*" + log "════════════════════════════════════════════════════════════" +} + +pause_enter() { + local msg="${1:-Weiter mit Enter …}" + printf '\n%s ' "$msg" >/dev/tty + # shellcheck disable=SC2034 + local _dummy + read -r _dummy </dev/tty || true + log "[pause] $msg" +} + +ask() { + # ask VAR "prompt" + local __var="$1" + local __prompt="$2" + local __ans="" + printf '%s ' "$__prompt" >/dev/tty + read -r __ans </dev/tty || true + printf -v "$__var" '%s' "$__ans" + log "[antwort] $__prompt → ${__ans}" +} + +ask_yn() { + # ask_yn VAR "prompt" → stores j/n/y/n normalized to j|n + local __var="$1" + local __prompt="$2" + local __ans="" + while true; do + printf '%s [j/n]: ' "$__prompt" >/dev/tty + read -r __ans </dev/tty || true + case "${__ans,,}" in + j|ja|y|yes) printf -v "$__var" 'j'; log "[antwort] $__prompt → j"; return 0 ;; + n|nein|no) printf -v "$__var" 'n'; log "[antwort] $__prompt → n"; return 0 ;; + *) printf 'Bitte j oder n.\n' >/dev/tty ;; + esac + done +} + +record_result() { + local phase="$1" host="$2" watt="$3" bled="$4" rdisp="$5" note="${6:-}" + RES_PHASE+=("$phase") + RES_HOST+=("$host") + RES_WATT+=("$watt") + RES_BLED+=("$bled") + RES_RDISP+=("$rdisp") + RES_NOTE+=("$note") +} + +need_sudo() { + if [[ ! -x "$SUDO" ]]; then + log "FEHLER: $SUDO fehlt — Live-Phasen brauchen sudo." + return 1 + fi + if ! "$SUDO" -n true 2>/dev/null; then + log "FEHLER: sudo -n fehlgeschlagen." + log " Bitte einmalig: sudo -v (oder NOPASSWD für diesen User)" + log " Dann Skript erneut starten." + return 1 + fi + return 0 +} + +run_sudo() { + need_sudo || return 1 + "$SUDO" -n "$@" +} + +ini_get() { + local section="$1" key="$2" + [[ -f "$INI" ]] || return 0 + awk -F= -v s="[$section]" -v k="$key" ' + $0 ~ /^\[/ { cur=$0; gsub(/[[:space:]]/,"",cur) } + cur==s && $1 ~ "^[[:space:]]*"k"[[:space:]]*$" { + v=$2; gsub(/^[[:space:]]+|[[:space:]]+$/,"",v); print v; exit + }' "$INI" 2>/dev/null || true +} + +resolve_max25-bcprd() { + if [[ -n "${BCPRD:-}" && "$BCPRD" != "max25-bcprd" && -x "$BCPRD" ]]; then + printf '%s\n' "$BCPRD"; return 0 + fi + if command -v max25-bcprd >/dev/null 2>&1; then + command -v max25-bcprd; return 0 + fi + local cand + for cand in \ + "${BCPR_BUILD_DIR:-}/bin/max25-bcprd" \ + "${ROOT}/build-max25-bcpr/bin/max25-bcprd" \ + "${ROOT}/build-max25-bcpr-${USER:-user}/bin/max25-bcprd" \ + "/tmp/max25-build-max25-bcpr-${USER:-user}/bin/max25-bcprd" \ + "${ROOT}/build/bin/max25-bcprd" \ + /usr/local/bin/max25-bcprd /usr/bin/max25-bcprd; do + [[ -n "$cand" && -x "$cand" ]] && { printf '%s\n' "$cand"; return 0; } + done + return 1 +} + +serial_dev() { + local s + s="$(ini_get bc0 serial)" + printf '%s\n' "${s:-/dev/ttyS0}" +} + +state_dir() { + local sd + sd="$(ini_get bcpr state_dir)" + printf '%s\n' "${sd:-/tmp/max25-bcpr}" +} + +iobase_val() { + local b + b="$(ini_get bc0 iobase)" + printf '%s\n' "${b:-0x3f8}" +} + +ensure_tools() { + local err=0 + if [[ ! -x "$CTL" ]]; then + log "FEHLER: max25-bcpr-ctl fehlt: $CTL"; err=1 + fi + if [[ ! -x "$SMOKE_SH" ]]; then + log "FEHLER: max25-bcpr-rxtx-smoke.sh fehlt: $SMOKE_SH"; err=1 + fi + return "$err" +} + +# ---------- Phase helpers ---------- + +# Last smoke host result (PASS|FAIL) — do not capture function stdout (log noise). +_LAST_HOST="—" + +smoke_force_tx() { + # smoke_force_tx SECONDS → sets _LAST_HOST + local secs="$1" + local listen=$((secs + 8)) + local rc=0 + [[ "$listen" -lt 12 ]] && listen=12 + log "→ L4 force-tx ${secs}s (smoke --live --tx --force-tx --tx-seconds ${secs})" + run_sudo "$CTL" -c "$INI" smoke --live --tx --force-tx --seconds "$listen" --tx-seconds "$secs" 2>&1 | log_raw + rc=${PIPESTATUS[0]} + if [[ "$rc" -eq 0 ]]; then + _LAST_HOST="PASS" + log "HOST: PASS (MCR keyed ~${secs}s target)" + else + _LAST_HOST="FAIL" + log "HOST: FAIL (smoke rc=$rc)" + fi +} + +prompt_rf_obs() { + # prompt_rf_obs PHASE_LABEL HOST_RESULT + # sets globals: _watt _bled _rdisp _extra + local label="$1" host="$2" + local watt bled rdisp extra + log "" + log "--- RF-Beobachtung: $label (Host=$host) ---" + log "Bitte Wattmeter / Board-LED / Radio-Display während des Keys prüfen." + ask watt "Wattmeter W (Zahl oder 0 / ?):" + ask_yn bled "Board-LED (PC-COM) an während Key?" + ask_yn rdisp "Radio-Display / TX-Anzeige an?" + ask extra "Kurznotiz (Enter = keine):" + _watt="$watt" + _bled="$bled" + _rdisp="$rdisp" + _extra="$extra" + record_result "$label" "$host" "$watt" "$bled" "$rdisp" "$extra" +} + +# ---------- Phases ---------- + +phase_preflight() { + banner "Phase 1 — Preflight" + local serial sd iobase irq fulldup dry + serial="$(serial_dev)" + sd="$(state_dir)" + iobase="$(iobase_val)" + irq="$(ini_get bc0 irq)" + fulldup="$(ini_get bc0 fulldup)" + dry="$(ini_get bcpr dry_run)" + + log "INI: $INI" + if [[ ! -f "$INI" ]]; then + log "FEHLER: INI fehlt: $INI" + log " Hinweis: Beispiel → stacks/max25-bcpr/share/bcpr.ini.example → /etc/max25/max25-bcpr.ini" + return 1 + fi + + log "--- INI Auszug [bcpr]/[bc0] ---" + log " dry_run=${dry:-?} state_dir=${sd}" + log " serial=${serial} iobase=${iobase} irq=${irq:-?} fulldup=${fulldup:-?}" + log " mode=$(ini_get bc0 mode) kiss_link=$(ini_get bc0 kiss_link)" + log " baud=$(ini_get bc0 baud) tx_delay=$(ini_get bc0 tx_delay)" + + log "--- Seriell ---" + if [[ -e "$serial" ]]; then + log "OK: $serial existiert" + ls -l "$serial" 2>&1 | log_raw || true + else + log "FEHLER: $serial fehlt" + fi + + log "--- fuser / lsof (wer hält Port?) ---" + if command -v fuser >/dev/null 2>&1; then + + run_sudo fuser -v "$serial" 2>&1 | log_raw + + else + log "WARN: fuser nicht installiert" + fi + if command -v lsof >/dev/null 2>&1; then + + run_sudo lsof "$serial" 2>&1 | log_raw + + fi + + log "--- dosbox-x / DOS-Gast ---" + if pgrep -a -f 'dosbox-x|dosbox' 2>/dev/null | log_raw; then + log "WARN: dosbox läuft — kann ttyS0/USB belegen (sniff/passthrough)." + else + log "OK: kein dosbox/dosbox-x Prozess" + fi + + log "--- Prozesse max25-bcprd / max25d ---" + pgrep -a -x max25-bcprd 2>/dev/null | log_raw || log " max25-bcprd: nicht laufend" + pgrep -a -x max25d 2>/dev/null | log_raw || log " max25d: nicht laufend" + # wrapper scripts + pgrep -a -f 'run-max25d|max25d' 2>/dev/null | head -20 | log_raw || true + + log "--- Lock / State ($sd) ---" + if [[ -d "$sd" ]]; then + ls -la "$sd" 2>&1 | log_raw || true + for f in "$sd"/lock* "$sd"/*.lock "$sd"/max25-bcprd.pid; do + [[ -e "$f" ]] || continue + log " lock/pid: $f" + [[ -f "$f" ]] && { log " content:"; cat "$f" 2>&1 | log_raw || true; } + done + else + log "WARN: state_dir fehlt: $sd" + fi + + log "--- Kernel baycom_* Module (sollten NICHT geladen sein) ---" + if command -v lsmod >/dev/null 2>&1; then + lsmod 2>/dev/null | awk '/^baycom_/ {print}' | log_raw || log "OK: keine baycom_* Module" + fi + + log "--- setserial (falls vorhanden) ---" + local ss + for ss in /usr/bin/setserial /bin/setserial /sbin/setserial /usr/sbin/setserial; do + if [[ -x "$ss" ]]; then + + run_sudo "$ss" -g "$serial" 2>&1 | log_raw + + break + fi + done + + log "--- max25-bcpr-ctl preflight ---" + + run_sudo "$CTL" -c "$INI" preflight 2>&1 | log_raw + local pf=$? + + if [[ "$pf" -eq 0 ]]; then + log "OK: preflight PASS" + else + log "WARN: preflight rc=$pf (Port busy wenn Stack läuft — normal bei live max25d/max25-bcprd)" + fi + + log "--- Tools ---" + log " CTL=$CTL" + log " SMOKE=$SMOKE_SH" + local bin + if bin="$(resolve_max25-bcprd)"; then + log " max25-bcprd=$bin" + else + log " WARN: max25-bcprd Binary nicht gefunden (Build: -DMAX25_BUILD_BCPR=ON)" + fi + + pause_enter "Phase 1 fertig — Enter für weiter …" + return 0 +} + +phase_idle_mcr() { + banner "Phase 2 — Idle MCR Sample (/dev/port)" + local iobase mcr_off + iobase="$(iobase_val)" + # MCR = iobase+4 + mcr_off=$((iobase + 4)) + log "iobase=$iobase MCR=$(printf '0x%x' "$mcr_off") (iobase+4)" + log "Erwartung Idle (Sailer/bcpr): oft 0x0d (DTR+OUT2+RTS-clear) — Werte nur Info." + + if ! need_sudo; then + log "überspringe MCR-Sample (kein sudo)" + pause_enter + return 0 + fi + + run_sudo python3 - "$iobase" <<'PY' 2>&1 | log_raw +import os, sys, time +iobase = int(sys.argv[1], 0) +mcr = iobase + 4 +fd = os.open("/dev/port", os.O_RDONLY) +try: + vals = [] + for i in range(20): + os.lseek(fd, mcr, os.SEEK_SET) + v = ord(os.read(fd, 1)) + vals.append(v) + time.sleep(0.05) + uniq = sorted(set(vals)) + print("Idle MCR samples (20×50ms): " + ",".join("0x%02x" % v for v in vals)) + print("Unique: " + ",".join("0x%02x" % v for v in uniq)) + rts = [(v & 0x02) != 0 for v in vals] + print("RTS asserted in any sample: %s" % ("YES" if any(rts) else "no")) + if all(v == 0 for v in vals): + print("WARN: all-zero — /dev/port may be inaccessible or wrong iobase") +finally: + os.close(fd) +PY + local rc=$? + + if [[ "$rc" -ne 0 ]]; then + log "WARN: Idle-MCR Sample fehlgeschlagen (rc=$rc) — CAP_SYS_RAWIO / iobase prüfen" + fi + pause_enter "Phase 2 fertig — Enter …" + return 0 +} + +phase_status() { + banner "Phase 3 — Status + Telemetrie" + + run_sudo "$CTL" -c "$INI" status 2>&1 | log_raw + + local sd + sd="$(state_dir)" + log "--- Telemetrie unter $sd ---" + for f in "$sd"/tx-last-bc* "$sd"/dcd-bc* "$sd"/rx-activity-bc* "$sd"/kiss-bc*; do + [[ -e "$f" ]] || continue + log "FILE: $f" + if [[ -L "$f" ]]; then + log " symlink → $(readlink -f "$f" 2>/dev/null || readlink "$f")" + elif [[ -f "$f" ]]; then + cat "$f" 2>&1 | log_raw || true + fi + done + pause_enter "Phase 3 fertig — Enter …" + return 0 +} + +phase_rx_listen() { + banner "Phase 4 — RX Listen (soft-DCD)" + log "OPERATOR: Squellch öffnen / Rauschen/SQ so dass Soft-DCD aktiv werden kann." + log " (§0.20 RX before TX — hier nur Listen, kein TX)" + pause_enter "SQ bereit? Enter startet ~12s RX listen …" + + run_sudo "$CTL" -c "$INI" smoke --live --seconds 12 2>&1 | log_raw + local rc=${PIPESTATUS[0]} + + if [[ "$rc" -eq 0 ]]; then + log "OK: RX-Listen smoke beendet (rc=0)" + else + log "WARN: RX-Listen smoke rc=$rc (siehe Log; Stack/INI prüfen)" + fi + + local sd + sd="$(state_dir)" + for f in "$sd"/dcd-bc0 "$sd"/rx-activity-bc0; do + [[ -f "$f" ]] || continue + log "Nach RX: $f" + cat "$f" 2>&1 | log_raw || true + done + pause_enter "Phase 4 fertig — Enter …" + return 0 +} + +phase_force_tx_ladder() { + banner "Phase 5 — Force-TX Ladder (1 / 3 / 5 / 8 s)" + log "WARNUNG: --force-tx ohne RX-Nachweis (§0.20 Override) — Debug / Intermittent-Hunt." + log "Wattmeter bereithalten. USB-Pfad ist KEIN Produkt-TX." + log "" + log "PTT-WATCHDOG (FlexNet SER12 / WD-Boards):" + log " Hardware kann PTT nach ~14,5 s Dauer-Key abwerfen (Discharge ~500 ms)." + log " Bei langen Läufen / vielen Keys hintereinander: zwischendurch UNKEY / Pause ~14 s." + log " Sonst: RF fällt trotz Host-MCR PASS (Watchdog, kein UART-Fehler)." + log "" + log "BEOBACHTUNGSHINWEIS TXD (Charge-Pump):" + log " max25-bcprd/Sailer: THR 0x00 gepulst · TFPCX: TXD oft steady +12 V." + log " Notiz wenn RF mitten im Key einbricht / flackert (Pump/Bias-Kandidat)." + pause_enter "Bereit für Ladder? Enter …" + + local secs host + for secs in 1 3 5 8; do + banner "Force-TX ${secs}s" + pause_enter "Wattmeter beobachten — Enter startet ${secs}s Key …" + smoke_force_tx "$secs" + host="$_LAST_HOST" + # show telem snapshot + local sd tl + sd="$(state_dir)" + tl="$sd/tx-last-bc0" + if [[ -f "$tl" ]]; then + log "--- tx-last-bc0 nach ${secs}s ---" + cat "$tl" 2>&1 | log_raw || true + fi + prompt_rf_obs "L4-${secs}s" "$host" + pause_enter "Nächste Stufe — Enter …" + done + return 0 +} + +phase_cal_high() { + banner "Phase 6 — Cal/high (max25-bcprd --cal high)" + local bin + if ! bin="$(resolve_max25-bcprd)"; then + log "WARN: max25-bcprd nicht gefunden — Cal übersprungen" + pause_enter + return 0 + fi + + # Prefer strings(1); never start bare max25-bcprd. Source contract: --cal high|low|alt. + if command -v strings >/dev/null 2>&1 && ! strings "$bin" 2>/dev/null | grep -qF -- '--cal'; then + log "WARN: strings fand kein --cal in Binary — Phase trotzdem anbieten (Operator kann abbrechen)" + fi + + log "max25-bcprd=$bin" + log "Cal = kontinuierlicher SER12 Tone+PTT (DOS cal.exe Stil), kein KISS." + log "Sicher: nur max25-bcprd stoppen via max25-bcpr-ctl — max25d Wrapper NICHT hart killen." + log "" + log "PTT-WATCHDOG: FlexNet cal pausiert alle ~14,5 s für ~500 ms (PTT-Discharge)." + log " Dieses Skript: --cal high ~10 s (unter WD). Längere manuelle Cal/" + log " Dauer-TX: UNKEY/Pause ~14 s einplanen — sonst RF-Tot auf WD-Boards." + log "TXD: max25-bcprd pulst THR 0x00 (nicht TFPCX-steady) — RF-Einbruch mid-cal notieren." + log "" + local do_cal + ask_yn do_cal "max25-bcprd stoppen und --cal high ~10s starten?" + if [[ "$do_cal" != "j" ]]; then + log "Cal übersprungen (Operator)" + pause_enter + return 0 + fi + + if ! need_sudo; then + pause_enter + return 0 + fi + + log "→ max25-bcpr-ctl stop" + + run_sudo "$CTL" -c "$INI" stop 2>&1 | log_raw + + sleep 1 + + # Ensure no stray max25-bcprd + if pgrep -x max25-bcprd >/dev/null 2>&1; then + log "WARN: max25-bcprd noch aktiv nach stop — pkill -x max25-bcprd (kein max25d)" + + run_sudo pkill -x max25-bcprd 2>&1 | log_raw + + sleep 1 + fi + + log "→ $bin -c $INI --cal high --seconds 10" + pause_enter "Wattmeter bereit — Enter startet cal high 10s …" + + run_sudo "$bin" -c "$INI" --cal high --seconds 10 2>&1 | log_raw + local rc=$? + + local host="PASS" + [[ "$rc" -eq 0 ]] || host="FAIL(rc=$rc)" + prompt_rf_obs "CAL-high-10s" "$host" + + local restart + ask_yn restart "max25-bcprd danach wieder starten (max25-bcpr-ctl start)?" + if [[ "$restart" == "j" ]]; then + + run_sudo "$CTL" -c "$INI" start 2>&1 | log_raw + + log "Hinweis: wenn max25d den Stack besitzt, ggf. max25d neu starten (Operator, nicht dieses Skript)." + fi + pause_enter "Phase 6 fertig — Enter …" + return 0 +} + +phase_double_burst() { + banner "Phase 7 — Double Burst (back-to-back TX)" + log "Zwei Force-TX 3s hintereinander (kurze Pause dazwischen)." + pause_enter "Enter startet Burst A …" + local host_a host_b + smoke_force_tx 3 + host_a="$_LAST_HOST" + sleep 1 + log "Burst B …" + smoke_force_tx 3 + host_b="$_LAST_HOST" + prompt_rf_obs "DoubleBurst-A+B" "${host_a}/${host_b}" + pause_enter "Phase 7 fertig — Enter …" + return 0 +} + +phase_contact_hunt() { + banner "Phase 8 — Contact Hunt (DE-9 + 3.5mm)" + log "OPERATOR-CHECKLISTE:" + log " 1) DE-9 (PC-COM ↔ Host) und 3.5mm (Modem ↔ Radio) fest stecken" + log " 2) Während dem nächsten Key leicht wackeln (Stecker/Kabel)" + log " 3) Wattmeter + Board-LED + Radio-Display beobachten" + log " Ziel: intermittenter Kontakt vs. dauerhaft tot unterscheiden" + pause_enter "Stecker fest? Enter startet 5s force-tx (währenddessen wackeln) …" + + local host + smoke_force_tx 5 + host="$_LAST_HOST" + local obs + ask obs "Beobachtung während Wackeln (z.B. 'kurz 2W' / 'immer 0' / 'LED flackert'):" + prompt_rf_obs "Contact-5s" "$host" + # overwrite last note with wiggle observation if empty note + if [[ -n "$obs" && ${#RES_NOTE[@]} -gt 0 ]]; then + RES_NOTE[$((${#RES_NOTE[@]} - 1))]="$obs | ${_extra:-}" + log "[contact-note] $obs" + fi + pause_enter "Phase 8 fertig — Enter …" + return 0 +} + +phase_usb_note() { + banner "Hinweis U — USB SER12 (kein Produkt-TX)" + log "USB-UART (z.B. /dev/ttyUSB0 / FTDI) ist KEIN unterstützter max25-bcprd SER12-Pfad." + log " max25-bcprd braucht ioperm|/dev/port + echte ISA/LPC iobase — USB hat das nicht." + log " Station: RF nur ttyS0+CB; USB0 = Modem ohne Radio → Wattmeter ungültig." + log " DOSBox Soft-TNC auf USB ≠ bcpr Produkt-TX." + pause_enter + return 0 +} + +phase_restart_menu() { + banner "Optional 0 — Sicheres max25-bcprd Restart" + log "Methode (aus max25-bcpr-ctl): stop → start. Killt NICHT max25d." + log "Wenn max25d den Stack besitzt: nach stop/start ggf. max25d-seitig neu binden." + log "" + log " Aktueller Status:" + + run_sudo "$CTL" -c "$INI" status 2>&1 | log_raw + + local act + ask act "Aktion: [s]top / [t]start / [r]estart / [a]bbruch:" + case "${act,,}" in + s|stop) + run_sudo "$CTL" -c "$INI" stop 2>&1 | log_raw + ;; + t|start) + run_sudo "$CTL" -c "$INI" start 2>&1 | log_raw + ;; + r|restart|re) + run_sudo "$CTL" -c "$INI" stop 2>&1 | log_raw + sleep 1 + run_sudo "$CTL" -c "$INI" start 2>&1 | log_raw + ;; + *) + log "Abbruch — keine Änderung" + ;; + esac + + run_sudo "$CTL" -c "$INI" status 2>&1 | log_raw + + pause_enter + return 0 +} + +phase_summary() { + banner "Phase 9 — Summary + Verdict" + log "Logdatei: $LOG" + log "" + log "┌──────────────────┬──────────┬──────────┬──────────┬──────────┐" + log "│ Phase │ Host │ Watt W │ BoardLED │ RadioTX │" + log "├──────────────────┼──────────┼──────────┼──────────┼──────────┤" + local i n + n=${#RES_PHASE[@]} + if [[ "$n" -eq 0 ]]; then + log "│ (keine TX-Phasen aufgezeichnet) │" + else + for ((i = 0; i < n; i++)); do + printf '│ %-16s │ %-8s │ %-8s │ %-8s │ %-8s │\n' \ + "${RES_PHASE[$i]:0:16}" \ + "${RES_HOST[$i]:0:8}" \ + "${RES_WATT[$i]:0:8}" \ + "${RES_BLED[$i]:0:8}" \ + "${RES_RDISP[$i]:0:8}" | log_raw + if [[ -n "${RES_NOTE[$i]:-}" ]]; then + log "│ note: ${RES_NOTE[$i]}" + fi + done + fi + log "└──────────────────┴──────────┴──────────┴──────────┴──────────┘" + + # Verdict heuristics + local any_host_pass=0 any_host_fail=0 any_rf=0 all_rf_zero=1 + for ((i = 0; i < n; i++)); do + case "${RES_HOST[$i]}" in + *PASS*) any_host_pass=1 ;; + *FAIL*) any_host_fail=1 ;; + esac + case "${RES_WATT[$i]}" in + ''|'0'|'0.0'|'?'|'n'|'nein') ;; + *) + # non-zero / non-empty that looks like power + if [[ "${RES_WATT[$i]}" =~ ^[0-9]*\.?[0-9]+$ ]]; then + if awk -v w="${RES_WATT[$i]}" 'BEGIN{exit !(w>0)}'; then + any_rf=1 + all_rf_zero=0 + fi + else + # free text — if contains number >0 heuristic + if [[ "${RES_WATT[$i]}" =~ [1-9] ]]; then + any_rf=1 + all_rf_zero=0 + fi + fi + ;; + esac + if [[ "${RES_BLED[$i]}" == "j" || "${RES_RDISP[$i]}" == "j" ]]; then + any_rf=1 + all_rf_zero=0 + fi + done + + log "" + log "=== Verdict-Hinweise (Heuristik, kein Automatik-Urteil) ===" + if [[ "$n" -eq 0 ]]; then + log "• Keine Ladder-Daten — nur Preflight/Status gelaufen." + elif [[ "$any_host_pass" -eq 1 && "$all_rf_zero" -eq 1 && "$any_rf" -eq 0 ]]; then + log "• HOST OK / RF tot oder 0 W: Fehlerklasse NACH UART" + log " → DE-9/3.5mm Kontakt, Charge-Pump/Vcc, PTT-Transistor, Mic-Buchse, Radio" + log " → Mic-alone 4 W OK stützt: Radio selbst ok; Pfad Modem↔Mic intermittierend" + elif [[ "$any_host_pass" -eq 1 && "$any_rf" -eq 1 ]]; then + log "• HOST OK / RF zeitweise sichtbar: INTERMITTENT RF (Kontakt/Pump/AF)" + log " → Contact-Hunt wiederholen; zwischen langen Keys Pause ~14 s (PTT-WD)" + log " → TXD pulse vs steady (TFPCX) als Beobachtung; Kabel/Stecker fest" + elif [[ "$any_host_fail" -eq 1 && "$any_host_pass" -eq 0 ]]; then + log "• HOST FAIL: zuerst Stack/KISS/fulldup/MCR — nicht primär Wattmeter" + log " → max25-bcpr-ctl status, kiss_link, fulldup=yes bei Soft-DCD, kein zweites max25-bcprd" + log " → MCR bleibt Sailer 0x0e|bit / 0x0d (nicht 4PC-COM 0x0A)" + else + log "• Gemischte Ergebnisse — Log + Notizen vergleichen; Phasen 5/8 wiederholen." + fi + log "" + log "SSoT: operator research notes (private)" + log " 2026-07-19-bcpr-mcr-ok-rf-zero-after-4w.md · winning-recipe · RF intermittent" + log " soft-TNC RE: 2026-07-19-baycom-soft-tnc-serial-ptt-re.md (WD · TXD · MCR)" + log "USB: kein Produkt-TX (Phase U)." + log "" + log "Log gespeichert: $LOG" + return 0 +} + +run_all() { + phase_preflight + phase_idle_mcr + phase_status + phase_rx_listen + phase_force_tx_ladder + phase_cal_high + phase_double_burst + phase_contact_hunt + phase_usb_note + phase_summary +} + +menu_loop() { + while true; do + banner "max25-bcpr Ultimate Diag — Menü" + log "INI=$INI" + log "Log=$LOG" + log "" + log " 1) Preflight" + log " 2) Idle MCR" + log " 3) Status + Telem" + log " 4) RX Listen" + log " 5) Force-TX Ladder 1/3/5/8s" + log " 6) Cal/high" + log " 7) Double Burst" + log " 8) Contact Hunt" + log " 9) Summary / Verdict" + log " A) Alle Phasen 1–9 (+ USB-Hinweis)" + log " 0) max25-bcprd stop/start (sicher)" + log " U) USB-Hinweis" + log " Q) Beenden" + log "" + local choice + ask choice "Wahl:" + case "${choice^^}" in + 1) phase_preflight ;; + 2) phase_idle_mcr ;; + 3) phase_status ;; + 4) phase_rx_listen ;; + 5) phase_force_tx_ladder ;; + 6) phase_cal_high ;; + 7) phase_double_burst ;; + 8) phase_contact_hunt ;; + 9) phase_summary ;; + A|ALL) run_all ;; + 0) phase_restart_menu ;; + U) phase_usb_note ;; + Q|X|EXIT|QUIT) + phase_summary + log "Ende." + return 0 + ;; + *) + log "Unbekannte Wahl: $choice" + ;; + esac + done +} + +# ---------- main ---------- + +while [[ $# -gt 0 ]]; do + case "$1" in + -c) + [[ $# -ge 2 ]] || { echo "ERROR: -c needs INI path"; exit 2; } + INI="$2" + shift 2 + ;; + --menu) MODE="menu"; shift ;; + --all) MODE="all"; shift ;; + --phase) + [[ $# -ge 2 ]] || { echo "ERROR: --phase needs N"; exit 2; } + MODE="phase" + PHASE_ONLY="$2" + shift 2 + ;; + -h|--help) usage; exit 0 ;; + *) + echo "ERROR: unknown arg: $1" + usage + exit 2 + ;; + esac +done + +RUN_TS="$(date +%Y%m%d-%H%M%S)" +LOG="/tmp/max25-bcpr-ultimate-diag-${RUN_TS}.log" +: >"$LOG" || { + echo "FEHLER: kann Log nicht schreiben: $LOG" + exit 1 +} + +# Interactive reads use /dev/tty; log()/log_raw append to $LOG (no exec-tee double). +banner "max25-bcpr Ultimate Diag — BayCom/based (bcpr)" +log "Start: $(date -R 2>/dev/null || date)" +log "Host: $(hostname 2>/dev/null || echo '?')" +log "User: $(id -un 2>/dev/null || echo '?') uid=$(id -u)" +log "INI: $INI (override: MAX25_BCPR_INI / -c)" +log "Log: $LOG" +log "Tools: $CTL" +log "" +log "Hinweis: PTT-Watchdog ~14,5 s / 500 ms — bei langen Keys unkey/pausieren." +log " TXD: max25-bcprd pulst (Sailer); TFPCX oft steady — RF mid-key notieren." +log " MCR: Sailer belassen (kein 4PC-COM 0x0A-Experiment in diesem Skript)." +log "" + +ensure_tools || { + log "Abbruch: Tools fehlen." + exit 1 +} + +# Soft check sudo early (warn only for menu; hard for --all live) +if ! need_sudo; then + log "WARN: ohne sudo -n sind Live/TX/Cal/MCR-Phasen blockiert." + if [[ "$MODE" == "all" ]]; then + log "Abbruch (--all braucht sudo -n)." + exit 1 + fi +fi + +case "$MODE" in + all) + run_all + ;; + phase) + case "$PHASE_ONLY" in + 1) phase_preflight ;; + 2) phase_idle_mcr ;; + 3) phase_status ;; + 4) phase_rx_listen ;; + 5) phase_force_tx_ladder ;; + 6) phase_cal_high ;; + 7) phase_double_burst ;; + 8) phase_contact_hunt ;; + 9) phase_summary ;; + 0) phase_restart_menu ;; + U|u) phase_usb_note ;; + *) + log "FEHLER: unbekannte Phase $PHASE_ONLY" + exit 2 + ;; + esac + ;; + menu|*) + menu_loop + ;; +esac + +log "" +log "Fertig. Log: $LOG" +exit 0 diff --git a/stacks/terminal/.gitignore b/stacks/terminal/.gitignore new file mode 100644 index 0000000..bb6aac1 --- /dev/null +++ b/stacks/terminal/.gitignore @@ -0,0 +1,9 @@ +# Built client artifacts (make -C stacks/terminal all) +max25-terminal +max25-client +*.o +amiga/max25-terminal +amiga/*.o + +# vault/agent — never ship +AGENT-INDEX.md diff --git a/stacks/terminal/CMakeLists.txt b/stacks/terminal/CMakeLists.txt new file mode 100644 index 0000000..d391b9e --- /dev/null +++ b/stacks/terminal/CMakeLists.txt @@ -0,0 +1,40 @@ +set(MAX25_TERMINAL_SRCS + max25_terminal.c + max25_proto.c + max25_ui.c +) + +add_executable(max25-terminal ${MAX25_TERMINAL_SRCS}) +target_include_directories(max25-terminal PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}") + +find_package(Curses) +if(Curses_FOUND AND TARGET Curses::Curses) + target_compile_definitions(max25-terminal PRIVATE MAX25_HAVE_NCURSES) + target_link_libraries(max25-terminal PRIVATE Curses::Curses) +elseif(Curses_FOUND) + target_compile_definitions(max25-terminal PRIVATE MAX25_HAVE_NCURSES) + target_include_directories(max25-terminal PRIVATE ${CURSES_INCLUDE_DIR}) + target_link_libraries(max25-terminal PRIVATE ${CURSES_LIBRARIES}) +elseif(UNIX AND NOT APPLE) + target_compile_definitions(max25-terminal PRIVATE MAX25_HAVE_NCURSES) + target_link_libraries(max25-terminal PRIVATE ncurses tinfo) +endif() + +target_compile_options(max25-terminal PRIVATE -Wall -Wextra -std=c11 -O2) + +add_custom_command(TARGET max25-terminal POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E rm -f "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/max25-client" + COMMAND "${CMAKE_COMMAND}" -E create_symlink max25-terminal "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/max25-client" + COMMENT "max25-client -> max25-terminal" +) + +install(TARGETS max25-terminal RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}") + +install(CODE " + set(_dst \"\$ENV{DESTDIR}\${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_BINDIR}/max25-client\") + set(_src \"\$ENV{DESTDIR}\${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_BINDIR}/max25-terminal\") + if(EXISTS \"\${_dst}\") + file(REMOVE \"\${_dst}\") + endif() + execute_process(COMMAND \"${CMAKE_COMMAND}\" -E create_symlink max25-terminal \"\${_dst}\") +") diff --git a/stacks/terminal/README.md b/stacks/terminal/README.md new file mode 100644 index 0000000..609468d --- /dev/null +++ b/stacks/terminal/README.md @@ -0,0 +1,31 @@ +# MAX25 Terminal (`max25-terminal` / `max25-client`) + +Official text-only operator client for `max25d`. Cross-platform via M25/1 (TCP or Unix). + +| Doc | Audience | +|-----|----------| +| [../../include/max25/protocol.md](../../include/max25/protocol.md) | Developer — M25/1 binding | +| [../../docs/README.md](../../docs/README.md) | Operator — doc index (stub; depth in research vault) | + +## Build & test + +```bash +./scripts/build.sh +bash stacks/terminal/test-terminal.sh +``` + +Requires C11 compiler and ncurses (`libncurses-dev` on Linux). + +## Run (canonical) + +Prefer install/PATH or `build*/bin/max25-terminal`. Tree binary may live at `./max25-terminal` after copy (gitignored). + +```bash +max25-terminal -U /run/max25/modem.sock +# with [devices] default set in max25d.ini — no -d needed +# then: CONNECT (F10→6) → SEND … +``` + +Optional: `-d <id>` / `SET DEVICE` / F10→7 to override the default device. + +Daemon: [stacks/daemon/README.md](../daemon/README.md) — **not** under this directory. diff --git a/stacks/terminal/amiga/Makefile b/stacks/terminal/amiga/Makefile new file mode 100644 index 0000000..84cc53b --- /dev/null +++ b/stacks/terminal/amiga/Makefile @@ -0,0 +1,32 @@ +# MAX25 Terminal — AmigaOS 3.9+ cross-build (bebbo-gcc / clib2) + +AMIGA_SDK ?= /opt/amiga +CC := $(AMIGA_SDK)/bin/m68k-amigaos-gcc +CFLAGS ?= -Wall -Wextra -std=c11 -O2 -mcrt=clib2 +LDFLAGS ?= -mcrt=clib2 +LIBS := -lnet -lunix + +PROTO_DIR := .. +SRCS := max25_terminal_amiga.c $(PROTO_DIR)/max25_proto.c +OBJS := max25_terminal_amiga.o max25_proto.o +TARGET := max25-terminal + +.PHONY: all test clean + +all: $(TARGET) + +$(TARGET): $(OBJS) + $(CC) $(LDFLAGS) -o $@ $(OBJS) $(LIBS) + +max25_terminal_amiga.o: max25_terminal_amiga.c $(PROTO_DIR)/max25_proto.h + $(CC) $(CFLAGS) -DMAX25_AMIGA=1 -I$(PROTO_DIR) -c -o $@ max25_terminal_amiga.c + +max25_proto.o: $(PROTO_DIR)/max25_proto.c $(PROTO_DIR)/max25_proto.h + $(CC) $(CFLAGS) -DMAX25_AMIGA=1 -I$(PROTO_DIR) -c -o $@ $(PROTO_DIR)/max25_proto.c + +test: all + @test -x ./$(TARGET) + file ./$(TARGET) | grep -qiE 'amiga|68k|m68k' && echo "OK: Amiga m68k binary" + +clean: + rm -f $(OBJS) $(TARGET) diff --git a/stacks/terminal/amiga/max25_terminal_amiga.c b/stacks/terminal/amiga/max25_terminal_amiga.c new file mode 100644 index 0000000..3b0f513 --- /dev/null +++ b/stacks/terminal/amiga/max25_terminal_amiga.c @@ -0,0 +1,309 @@ +/* + * max25-terminal — AmigaOS 3.9+ reduced operator client (TCP/M25/1 only). + * + * HyBBX-style thin client: no ncurses/F10 menu; connects to Linux max25d. + */ +#define _DEFAULT_SOURCE 1 + +#include "../max25_proto.h" + +#include <errno.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <unistd.h> +#include <sys/select.h> + +#ifdef __AMIGA__ +#include <sys/socket.h> +#endif + +typedef struct { + char host[256]; + unsigned port; + char tcp_password[128]; + int ax25_ui; + int verbose; + int probe; +} amiga_config_t; + +static void config_defaults(amiga_config_t *cfg) +{ + const char *env; + + memset(cfg, 0, sizeof(*cfg)); + strncpy(cfg->host, "127.0.0.1", sizeof(cfg->host) - 1); + cfg->port = MAX25_DEFAULT_PORT; + cfg->ax25_ui = 1; + + env = getenv("MAX25_HOST"); + if (env != NULL && env[0] != '\0') { + strncpy(cfg->host, env, sizeof(cfg->host) - 1); + } + env = getenv("MAX25_PORT"); + if (env != NULL && env[0] != '\0') { + cfg->port = (unsigned)strtoul(env, NULL, 10); + } + env = getenv("MAX25_TCP_PASSWORD"); + if (env != NULL) { + strncpy(cfg->tcp_password, env, sizeof(cfg->tcp_password) - 1); + } +} + +static void usage(const char *prog) +{ + fprintf(stderr, + "MAX25 Terminal (AmigaOS) — reduced CLI client for max25d\n\n" + "Usage: %s [options] [host] [port]\n\n" + "Options:\n" + " -H HOST max25d TCP host (default 127.0.0.1)\n" + " -p PORT max25d TCP port (default %u)\n" + " -P PASSWORD TCP auth password (plain-text AUTH)\n" + " --ax25-ui AX.25 UI mode (default on)\n" + " --no-ax25-ui\n" + " -v Verbose protocol log on stderr\n" + " --probe Connect, print STATUS, exit\n" + " -h Help\n\n" + "Session: type a line to SEND; /status /callerid /callid /connect /quit\n" + "Environment: MAX25_HOST, MAX25_PORT, MAX25_TCP_PASSWORD\n\n", + prog, MAX25_DEFAULT_PORT); +} + +static int parse_args(int argc, char **argv, amiga_config_t *cfg) +{ + int i; + int positional = 0; + + for (i = 1; i < argc; i++) { + if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) { + usage(argv[0]); + return 1; + } + if (strcmp(argv[i], "-H") == 0 && i + 1 < argc) { + strncpy(cfg->host, argv[++i], sizeof(cfg->host) - 1); + continue; + } + if (strcmp(argv[i], "-p") == 0 && i + 1 < argc) { + cfg->port = (unsigned)strtoul(argv[++i], NULL, 10); + continue; + } + if ((strcmp(argv[i], "-P") == 0 || strcmp(argv[i], "--password") == 0) && + i + 1 < argc) { + strncpy(cfg->tcp_password, argv[++i], sizeof(cfg->tcp_password) - 1); + continue; + } + if (strcmp(argv[i], "-v") == 0 || strcmp(argv[i], "--verbose") == 0) { + cfg->verbose = 1; + continue; + } + if (strcmp(argv[i], "--probe") == 0) { + cfg->probe = 1; + continue; + } + if (strcmp(argv[i], "--ax25-ui") == 0) { + cfg->ax25_ui = 1; + continue; + } + if (strcmp(argv[i], "--no-ax25-ui") == 0) { + cfg->ax25_ui = 0; + continue; + } + if (argv[i][0] == '-') { + fprintf(stderr, "%s: unknown option %s\n", argv[0], argv[i]); + return -1; + } + if (positional == 0) { + strncpy(cfg->host, argv[i], sizeof(cfg->host) - 1); + } else if (positional == 1) { + cfg->port = (unsigned)strtoul(argv[i], NULL, 10); + } + positional++; + } + return 0; +} + +static void print_rx(const char *line, int verbose) +{ + if (verbose) { + fprintf(stderr, "max25-terminal: << %s\n", line); + } + if (strncmp(line, "RX ", 3) == 0) { + printf("<< %s\n", line + 3); + } else if (strncmp(line, "ERR ", 4) == 0) { + fprintf(stderr, "!! %s\n", line); + } else if (strncmp(line, "EVENT ", 6) == 0) { + printf("-- %s\n", line); + } + fflush(stdout); + fflush(stderr); +} + +static int drain_socket(max25_client_t *client, int verbose) +{ + char line[MAX25_LINE_MAX]; + int got = 0; + + for (;;) { + fd_set rfds; + struct timeval tv; + int fd = max25_client_fd(client); + + FD_ZERO(&rfds); + FD_SET(fd, &rfds); + tv.tv_sec = 0; + tv.tv_usec = 0; + if (select(fd + 1, &rfds, NULL, NULL, &tv) <= 0) { + break; + } + if (max25_client_read_line(client, line, sizeof(line)) != 0) { + return -1; + } + print_rx(line, verbose); + got = 1; + if (strcmp(line, "OK") == 0) { + break; + } + } + return got ? 0 : 0; +} + +static int send_cmd_wait(max25_client_t *client, const char *cmd, int verbose) +{ + char line[MAX25_LINE_MAX]; + + if (verbose) { + fprintf(stderr, "max25-terminal: >> %s\n", cmd); + } + if (max25_client_write(client, cmd) != 0) { + return -1; + } + for (;;) { + if (max25_client_read_line(client, line, sizeof(line)) != 0) { + return -1; + } + print_rx(line, verbose); + if (strncmp(line, "STATUS ", 7) == 0) { + continue; + } + if (strncmp(line, "RX ", 3) == 0) { + continue; + } + if (strncmp(line, "EVENT ", 6) == 0) { + continue; + } + return strcmp(line, "OK") == 0 ? 0 : -1; + } +} + +static int run_session(max25_client_t *client, amiga_config_t *cfg, + max25_status_t *status) +{ + char line[MAX25_LINE_MAX]; + + printf("MAX25 Terminal (Amiga) — connected to %s:%u\n", cfg->host, cfg->port); + printf("Type text to SEND. Commands: /status /callerid X /callid X /connect /quit\n"); + + if (!status->connected) { + send_cmd_wait(client, "CONNECT", cfg->verbose); + status->connected = 1; + } + + while (fgets(line, sizeof(line), stdin) != NULL) { + line[strcspn(line, "\r\n")] = '\0'; + if (line[0] == '\0') { + continue; + } + if (strcmp(line, "/quit") == 0 || strcmp(line, "/exit") == 0) { + break; + } + if (strcmp(line, "/status") == 0) { + send_cmd_wait(client, "GET STATUS", cfg->verbose); + continue; + } + if (strcmp(line, "/connect") == 0) { + send_cmd_wait(client, "CONNECT", cfg->verbose); + status->connected = 1; + continue; + } + if (strcmp(line, "/disconnect") == 0) { + send_cmd_wait(client, "DISCONNECT", cfg->verbose); + status->connected = 0; + continue; + } + if (strncmp(line, "/callerid ", 10) == 0) { + char cmd[MAX25_LINE_MAX]; + if (!max25_valid_callsign(line + 10)) { + fprintf(stderr, "invalid CALLERID\n"); + continue; + } + snprintf(cmd, sizeof(cmd), "SET CALLERID %s", line + 10); + send_cmd_wait(client, cmd, cfg->verbose); + continue; + } + if (strncmp(line, "/callid ", 8) == 0) { + char cmd[MAX25_LINE_MAX]; + if (!max25_valid_callsign(line + 8)) { + fprintf(stderr, "invalid CALLID\n"); + continue; + } + snprintf(cmd, sizeof(cmd), "SET CALLID %s", line + 8); + send_cmd_wait(client, cmd, cfg->verbose); + continue; + } + { + char cmd[MAX25_LINE_MAX + 8]; + snprintf(cmd, sizeof(cmd), "SEND %s", line); + send_cmd_wait(client, cmd, cfg->verbose); + } + (void)drain_socket(client, cfg->verbose); + } + return 0; +} + +int main(int argc, char **argv) +{ + amiga_config_t cfg; + max25_client_t *client = NULL; + max25_status_t status; + char reply[MAX25_LINE_MAX]; + int rc; + + config_defaults(&cfg); + rc = parse_args(argc, argv, &cfg); + if (rc != 0) { + return rc < 0 ? EXIT_FAILURE : EXIT_SUCCESS; + } + + if (max25_client_connect(cfg.host, cfg.port, "", 1, cfg.tcp_password, + &client, &status) != 0) { + fprintf(stderr, "max25-terminal: connect failed (tcp %s:%u)\n", + cfg.host, cfg.port); + return EXIT_FAILURE; + } + + if (cfg.probe) { + printf("STATUS hardware=%s device=%s mode=%s callerid=%s callid=%s " + "ax25_ui=%s connected=%s stack=%s\n", + status.hardware, status.device, status.mode, + status.callerid, status.callid, + status.ax25_ui ? "on" : "off", + status.connected ? "yes" : "no", + status.stack); + max25_client_write(client, "DISCONNECT"); + max25_client_free(client); + return EXIT_SUCCESS; + } + + if (cfg.ax25_ui) { + max25_client_command(client, "SET AX25_UI on", reply, sizeof(reply)); + status.ax25_ui = 1; + } else { + max25_client_command(client, "SET AX25_UI off", reply, sizeof(reply)); + status.ax25_ui = 0; + } + + run_session(client, &cfg, &status); + max25_client_write(client, "DISCONNECT"); + max25_client_free(client); + return EXIT_SUCCESS; +} diff --git a/stacks/terminal/max25_proto.c b/stacks/terminal/max25_proto.c new file mode 100644 index 0000000..b4a9725 --- /dev/null +++ b/stacks/terminal/max25_proto.c @@ -0,0 +1,472 @@ +#define _POSIX_C_SOURCE 200809L + +#include "max25_proto.h" + +#if defined(__AMIGA__) || defined(__amigaos__) +#define MAX25_AMIGA 1 +#endif + +#include <ctype.h> +#include <errno.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <stdint.h> +#include <unistd.h> + +#ifndef MAX25_AMIGA +#include <netdb.h> +#include <sys/socket.h> +#include <sys/un.h> +#else +#include <sys/socket.h> +#include <netinet/in.h> +#include <arpa/inet.h> +#include <netdb.h> +#endif + +struct max25_client { + int fd; +}; + +static int read_line_fd(int fd, char *line, size_t line_sz) +{ + size_t pos = 0; + char ch; + + while (pos + 1 < line_sz) { + ssize_t n = read(fd, &ch, 1); + if (n < 0) { + if (errno == EINTR) { + continue; + } + return -1; + } + if (n == 0) { + return -1; + } + if (ch == '\n') { + line[pos] = '\0'; + return 0; + } + if (ch != '\r') { + line[pos++] = ch; + } + } + return -1; +} + +static int write_cmd(int fd, const char *cmd) +{ + char buf[MAX25_LINE_MAX + 2]; + int len = snprintf(buf, sizeof(buf), "%s\n", cmd); + ssize_t off = 0; + + if (len < 0 || (size_t)len >= sizeof(buf)) { + return -1; + } + while (off < len) { + ssize_t n = write(fd, buf + off, (size_t)len - (size_t)off); + if (n < 0) { + if (errno == EINTR) { + continue; + } + return -1; + } + off += n; + } + return 0; +} + +static int connect_tcp(const char *host, unsigned port) +{ +#ifdef MAX25_AMIGA + struct hostent *he; + struct sockaddr_in addr; + int fd; + + he = gethostbyname(host); + if (he == NULL || he->h_addr_list[0] == NULL) { + return -1; + } + + fd = socket(AF_INET, SOCK_STREAM, 0); + if (fd < 0) { + return -1; + } + + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_port = htons((uint16_t)port); + memcpy(&addr.sin_addr, he->h_addr_list[0], (size_t)he->h_length); + + if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) != 0) { + close(fd); + return -1; + } + return fd; +#else + char portbuf[16]; + struct addrinfo hints; + struct addrinfo *res = NULL; + struct addrinfo *ai; + int fd = -1; + + snprintf(portbuf, sizeof(portbuf), "%u", port); + memset(&hints, 0, sizeof(hints)); + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + + if (getaddrinfo(host, portbuf, &hints, &res) != 0) { + return -1; + } + + for (ai = res; ai != NULL; ai = ai->ai_next) { + fd = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol); + if (fd < 0) { + continue; + } + if (connect(fd, ai->ai_addr, ai->ai_addrlen) == 0) { + break; + } + close(fd); + fd = -1; + } + freeaddrinfo(res); + return fd; +#endif +} + +static int connect_unix(const char *path) +{ +#ifndef MAX25_AMIGA + struct sockaddr_un addr; + int fd; + + if (path == NULL || path[0] == '\0') { + return -1; + } + + fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0) { + return -1; + } + + memset(&addr, 0, sizeof(addr)); + addr.sun_family = AF_UNIX; + strncpy(addr.sun_path, path, sizeof(addr.sun_path) - 1); + + if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) != 0) { + close(fd); + return -1; + } + return fd; +#else + (void)path; + return -1; +#endif +} + +static int tcp_authenticate(int fd, const char *password) +{ + char line[MAX25_LINE_MAX]; + char cmd[MAX25_LINE_MAX + 8]; + + if (password == NULL || password[0] == '\0') { + return -1; + } + snprintf(cmd, sizeof(cmd), "AUTH %s", password); + if (write_cmd(fd, cmd) != 0) { + return -1; + } + if (read_line_fd(fd, line, sizeof(line)) != 0) { + return -1; + } + return strcmp(line, "OK") == 0 ? 0 : -1; +} + +static int handshake(int fd, const char *tcp_password, max25_status_t *initial_status) +{ + char line[MAX25_LINE_MAX]; + + if (read_line_fd(fd, line, sizeof(line)) != 0) { + return -1; + } + if (strcmp(line, "AUTH required") == 0) { + if (tcp_authenticate(fd, tcp_password) != 0) { + return -1; + } + if (read_line_fd(fd, line, sizeof(line)) != 0 || strcmp(line, "OK") != 0) { + return -1; + } + } else if (strcmp(line, "OK") != 0) { + return -1; + } + + if (read_line_fd(fd, line, sizeof(line)) != 0 || + strncmp(line, "STATUS ", 7) != 0) { + return -1; + } + + if (initial_status != NULL) { + max25_client_parse_status(line, initial_status); + } + return 0; +} + +int max25_client_connect(const char *host, unsigned port, const char *unix_path, + int tcp_only, const char *tcp_password, + max25_client_t **out, max25_status_t *initial_status) +{ + max25_client_t *client; + int fd = -1; + + if (out == NULL) { + return -1; + } + + if (!tcp_only && unix_path != NULL && unix_path[0] != '\0') { +#ifndef MAX25_AMIGA + fd = connect_unix(unix_path); +#endif + } + if (fd < 0 && host != NULL && host[0] != '\0') { + fd = connect_tcp(host, port); + } + if (fd < 0) { + return -1; + } + + client = calloc(1, sizeof(*client)); + if (client == NULL) { + close(fd); + return -1; + } + client->fd = fd; + + if (handshake(fd, tcp_password, initial_status) != 0) { + max25_client_free(client); + return -1; + } + + *out = client; + return 0; +} + +void max25_client_free(max25_client_t *client) +{ + if (client == NULL) { + return; + } + if (client->fd >= 0) { + close(client->fd); + } + free(client); +} + +int max25_client_fd(const max25_client_t *client) +{ + return client != NULL ? client->fd : -1; +} + +int max25_client_write(max25_client_t *client, const char *cmd) +{ + if (client == NULL || cmd == NULL) { + return -1; + } + return write_cmd(client->fd, cmd); +} + +max25_line_kind_t max25_client_classify_line(const char *line) +{ + if (line == NULL || line[0] == '\0') { + return MAX25_LINE_IGNORE; + } + if (strcmp(line, "OK") == 0) { + return MAX25_LINE_OK; + } + if (strncmp(line, "ERR ", 4) == 0) { + return MAX25_LINE_ERR; + } + if (strncmp(line, "STATUS ", 7) == 0) { + return MAX25_LINE_STATUS; + } + if (strncmp(line, "RX ", 3) == 0) { + return MAX25_LINE_RX; + } + if (strncmp(line, "EVENT ", 6) == 0) { + return MAX25_LINE_EVENT; + } + if (strncmp(line, "DEVICE ", 7) == 0) { + return MAX25_LINE_DEVICE; + } + return MAX25_LINE_OTHER; +} + +int max25_client_command(max25_client_t *client, const char *cmd, + char *reply, size_t reply_sz) +{ + char line[MAX25_LINE_MAX]; + + if (client == NULL || cmd == NULL) { + return -1; + } + if (write_cmd(client->fd, cmd) != 0) { + return -1; + } + for (;;) { + if (read_line_fd(client->fd, line, sizeof(line)) != 0) { + return -1; + } + if (strncmp(line, "STATUS ", 7) == 0) { + if (reply != NULL && reply_sz > 0) { + strncpy(reply, line, reply_sz - 1); + reply[reply_sz - 1] = '\0'; + } + continue; + } + if (strncmp(line, "RX ", 3) == 0) { + continue; + } + if (strncmp(line, "EVENT ", 6) == 0) { + continue; + } + if (strncmp(line, "DEVICE ", 7) == 0) { + continue; + } + /* GET STATUS replies: STATUS … then OK — keep STATUS in reply. */ + if (strcmp(line, "OK") == 0) { + if (reply != NULL && reply_sz > 0 && + strncmp(reply, "STATUS ", 7) != 0) { + strncpy(reply, line, reply_sz - 1); + reply[reply_sz - 1] = '\0'; + } + return 0; + } + if (reply != NULL && reply_sz > 0) { + strncpy(reply, line, reply_sz - 1); + reply[reply_sz - 1] = '\0'; + } + return -1; + } +} + +int max25_client_read_line(max25_client_t *client, char *line, size_t line_sz) +{ + if (client == NULL) { + return -1; + } + return read_line_fd(client->fd, line, line_sz); +} + +static void copy_field(const char *src, char *dst, size_t dst_sz) +{ + if (dst == NULL || dst_sz == 0) { + return; + } + if (src == NULL) { + dst[0] = '\0'; + return; + } + strncpy(dst, src, dst_sz - 1); + dst[dst_sz - 1] = '\0'; +} + +int max25_client_apply_event(const char *line, max25_status_t *status) +{ + if (line == NULL || status == NULL || strncmp(line, "EVENT ", 6) != 0) { + return 0; + } + if (strcmp(line, "EVENT connected") == 0) { + status->connected = 1; + return 1; + } + if (strcmp(line, "EVENT disconnected") == 0) { + status->connected = 0; + return 1; + } + return 0; +} + +int max25_client_parse_status(const char *line, max25_status_t *status) +{ + char buf[MAX25_LINE_MAX]; + char *save = NULL; + char *tok; + + if (line == NULL || status == NULL || strncmp(line, "STATUS ", 7) != 0) { + return -1; + } + + strncpy(buf, line + 7, sizeof(buf) - 1); + buf[sizeof(buf) - 1] = '\0'; + + memset(status, 0, sizeof(*status)); + for (tok = strtok_r(buf, " ", &save); tok != NULL; + tok = strtok_r(NULL, " ", &save)) { + char *eq = strchr(tok, '='); + if (eq == NULL) { + continue; + } + *eq = '\0'; + if (strcmp(tok, "hardware") == 0) { + copy_field(eq + 1, status->hardware, sizeof(status->hardware)); + } else if (strcmp(tok, "device") == 0) { + copy_field(eq + 1, status->device, sizeof(status->device)); + } else if (strcmp(tok, "mode") == 0) { + copy_field(eq + 1, status->mode, sizeof(status->mode)); + } else if (strcmp(tok, "callerid") == 0) { + copy_field(eq + 1, status->callerid, sizeof(status->callerid)); + } else if (strcmp(tok, "callid") == 0) { + copy_field(eq + 1, status->callid, sizeof(status->callid)); + } else if (strcmp(tok, "ax25_ui") == 0) { + status->ax25_ui = (strcmp(eq + 1, "on") == 0); + } else if (strcmp(tok, "connected") == 0) { + status->connected = (strcmp(eq + 1, "yes") == 0); + } else if (strcmp(tok, "stack") == 0) { + copy_field(eq + 1, status->stack, sizeof(status->stack)); + } else if (strcmp(tok, "error") == 0) { + copy_field(eq + 1, status->error, sizeof(status->error)); + } else if (strcmp(tok, "voice") == 0) { + copy_field(eq + 1, status->voice, sizeof(status->voice)); + } + } + return 0; +} + +int max25_valid_callsign(const char *value) +{ + const char *dash; + size_t call_len; + unsigned long ssid; + char *end = NULL; + size_t i; + + if (value == NULL) { + return 0; + } + + dash = strchr(value, '-'); + if (dash != NULL) { + call_len = (size_t)(dash - value); + ssid = strtoul(dash + 1, &end, 10); + if (end == dash + 1 || ssid > 15) { + return 0; + } + } else { + call_len = strlen(value); + } + + if (call_len == 0 || call_len > 6) { + return 0; + } + + for (i = 0; i < call_len; i++) { + char c = (char)toupper((unsigned char)value[i]); + if (!((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9'))) { + return 0; + } + } + + return 1; +} diff --git a/stacks/terminal/max25_proto.h b/stacks/terminal/max25_proto.h new file mode 100644 index 0000000..25ae5d5 --- /dev/null +++ b/stacks/terminal/max25_proto.h @@ -0,0 +1,55 @@ +#ifndef MAX25_PROTO_H +#define MAX25_PROTO_H + +#include <stddef.h> + +#define MAX25_DEFAULT_PORT 7325 +#define MAX25_DEFAULT_UNIX "/run/max25/modem.sock" +#define MAX25_LINE_MAX 512 +#define MAX25_CALLSIGN_MAX 16 + +typedef struct max25_client max25_client_t; + +typedef struct max25_status { + char hardware[32]; + char device[32]; + char mode[32]; + char callerid[MAX25_CALLSIGN_MAX]; + char callid[MAX25_CALLSIGN_MAX]; + int ax25_ui; + int connected; + int monitor; + char stack[32]; + char error[16]; + char voice[16]; +} max25_status_t; + +typedef enum { + MAX25_LINE_IGNORE = 0, + MAX25_LINE_OK, + MAX25_LINE_ERR, + MAX25_LINE_STATUS, + MAX25_LINE_RX, + MAX25_LINE_EVENT, + MAX25_LINE_DEVICE, + MAX25_LINE_OTHER +} max25_line_kind_t; + +int max25_client_connect(const char *host, unsigned port, const char *unix_path, + int tcp_only, const char *tcp_password, + max25_client_t **out, max25_status_t *initial_status); +void max25_client_free(max25_client_t *client); +int max25_client_fd(const max25_client_t *client); + +int max25_client_write(max25_client_t *client, const char *cmd); +int max25_client_command(max25_client_t *client, const char *cmd, + char *reply, size_t reply_sz); +int max25_client_read_line(max25_client_t *client, char *line, size_t line_sz); +max25_line_kind_t max25_client_classify_line(const char *line); +int max25_client_parse_status(const char *line, max25_status_t *status); +/** Apply EVENT connected / EVENT disconnected to status. Returns 1 if handled. */ +int max25_client_apply_event(const char *line, max25_status_t *status); + +int max25_valid_callsign(const char *value); + +#endif /* MAX25_PROTO_H */ diff --git a/stacks/terminal/max25_terminal.c b/stacks/terminal/max25_terminal.c new file mode 100644 index 0000000..8b4eb52 --- /dev/null +++ b/stacks/terminal/max25_terminal.c @@ -0,0 +1,767 @@ +#define _POSIX_C_SOURCE 200809L + +#include "max25_proto.h" +#include "max25_ui.h" + +#include <errno.h> +#include <getopt.h> +#include <poll.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <termios.h> +#include <unistd.h> + +#ifdef MAX25_HAVE_NCURSES +#include <ncurses.h> +#endif + +typedef struct { + char host[256]; + unsigned port; + char unix_path[256]; + char tcp_password[128]; + char default_device[32]; + int tcp_only; + int ax25_ui; + int verbose; + int probe; +} terminal_config_t; + +typedef struct { + int waiting_reply; + char last_err[MAX25_LINE_MAX]; +} session_io_t; + +static struct termios g_saved_tty; +static int g_tty_raw; + +static void config_defaults(terminal_config_t *cfg) +{ + const char *env; + + memset(cfg, 0, sizeof(*cfg)); + strncpy(cfg->host, "127.0.0.1", sizeof(cfg->host) - 1); + cfg->port = MAX25_DEFAULT_PORT; + strncpy(cfg->unix_path, MAX25_DEFAULT_UNIX, sizeof(cfg->unix_path) - 1); + cfg->ax25_ui = 1; + + env = getenv("MAX25_HOST"); + if (env != NULL && env[0] != '\0') { + strncpy(cfg->host, env, sizeof(cfg->host) - 1); + } + env = getenv("MAX25_PORT"); + if (env != NULL && env[0] != '\0') { + cfg->port = (unsigned)strtoul(env, NULL, 10); + } + env = getenv("MAX25_UNIX"); + if (env != NULL) { + strncpy(cfg->unix_path, env, sizeof(cfg->unix_path) - 1); + } + env = getenv("MAX25_TCP_PASSWORD"); + if (env != NULL) { + strncpy(cfg->tcp_password, env, sizeof(cfg->tcp_password) - 1); + } +} + +static void usage(const char *prog) +{ + fprintf(stderr, + "MAX25 Terminal — CLI operator client (max25d via TCP/M25/1)\n\n" + "Usage: %s [options] [host] [port]\n\n" + "Options:\n" + " -H, --host HOST max25d TCP host (default 127.0.0.1)\n" + " -p, --port PORT max25d TCP port (default %u)\n" + " -T, --tcp-only TCP only — do not try Unix socket\n" + " -U, --unix PATH Unix socket (default %s, before TCP)\n" + " --ax25-ui AX.25 UI mode (default on)\n" + " --no-ax25-ui Plain SEND lines\n" + " -P, --password PASS TCP auth password (plain-text M25/1 AUTH)\n" + " -d, --device ID SET DEVICE after connect (configured device id)\n" + " -v, --verbose Protocol log on stderr\n" + " --probe Connect, print STATUS, exit (CI)\n" + " -h, --help Show help\n\n" + "Remote example:\n" + " %s -T -H linux-host.local -p 7325\n\n" + "Environment: MAX25_HOST, MAX25_PORT, MAX25_UNIX, MAX25_TCP_PASSWORD\n" + "Symlink: max25-client -> max25-terminal\n\n", + prog, MAX25_DEFAULT_PORT, MAX25_DEFAULT_UNIX, prog); +} + +static int tty_enable_raw(void) +{ + struct termios tty; + + if (!isatty(STDIN_FILENO)) { + return 0; + } + if (tcgetattr(STDIN_FILENO, &g_saved_tty) != 0) { + return -1; + } + tty = g_saved_tty; + tty.c_lflag &= (tcflag_t)~(ICANON | ECHO); + tty.c_cc[VMIN] = 0; + tty.c_cc[VTIME] = 1; + if (tcsetattr(STDIN_FILENO, TCSANOW, &tty) != 0) { + return -1; + } + g_tty_raw = 1; + return 0; +} + +static void tty_restore(void) +{ + if (g_tty_raw) { + tcsetattr(STDIN_FILENO, TCSANOW, &g_saved_tty); + g_tty_raw = 0; + } +} + +static int begin_cmd(session_io_t *io) +{ + if (io == NULL) { + return -1; + } + io->waiting_reply = 1; + io->last_err[0] = '\0'; + return 0; +} + +static int send_cmd_async(max25_client_t *client, session_io_t *io, + const char *cmd) +{ + if (max25_client_write(client, cmd) != 0) { + return -1; + } + return begin_cmd(io); +} + +static int dispatch_daemon_line(const char *line, session_io_t *io, + max25_ui_t *ui, max25_status_t *status, + terminal_config_t *cfg, int *need_redraw) +{ + max25_line_kind_t kind = max25_client_classify_line(line); + + if (need_redraw != NULL) { + *need_redraw = 0; + } + + if (cfg->verbose) { + fprintf(stderr, "max25-terminal: << %s\n", line); + } + + if (kind == MAX25_LINE_RX) { + max25_ui_append_rx(ui, line + 3); + if (need_redraw != NULL) { + *need_redraw = 1; + } + return 0; + } + + /* CONNECT/DISCONNECT emit EVENT before OK — update header even while waiting. */ + if (kind == MAX25_LINE_EVENT) { + if (max25_client_apply_event(line, status)) { + max25_ui_append_rx(ui, line); + if (need_redraw != NULL) { + *need_redraw = 1; + } + } else if (cfg->verbose) { + max25_ui_append_rx(ui, line); + if (need_redraw != NULL) { + *need_redraw = 1; + } + } + if (io->waiting_reply) { + return 0; + } + return 0; + } + + if (io->waiting_reply) { + if (kind == MAX25_LINE_STATUS && status != NULL) { + char summary[MAX25_LINE_MAX]; + + max25_client_parse_status(line, status); + /* Show STATUS in RX pane (F10→3 and other GET STATUS callers). */ + snprintf(summary, sizeof(summary), + "STATUS device=%s stack=%s connected=%s callerid=%s callid=%s", + status->device[0] != '\0' ? status->device : "-", + status->stack[0] != '\0' ? status->stack : "-", + status->connected ? "yes" : "no", + status->callerid[0] != '\0' ? status->callerid : "-", + status->callid[0] != '\0' ? status->callid : "-"); + max25_ui_append_rx(ui, summary); + if (need_redraw != NULL) { + *need_redraw = 1; + } + return 0; + } + if (kind == MAX25_LINE_DEVICE) { + max25_ui_append_rx(ui, line); + if (need_redraw != NULL) { + *need_redraw = 1; + } + return 0; + } + if (kind == MAX25_LINE_OK) { + io->waiting_reply = 0; + if (need_redraw != NULL) { + *need_redraw = 1; + } + return 0; + } + if (kind == MAX25_LINE_ERR) { + strncpy(io->last_err, line, sizeof(io->last_err) - 1); + io->last_err[sizeof(io->last_err) - 1] = '\0'; + io->waiting_reply = 0; + max25_ui_append_rx(ui, line); + if (need_redraw != NULL) { + *need_redraw = 1; + } + return -1; + } + return 0; + } + + if (kind == MAX25_LINE_ERR) { + max25_ui_append_rx(ui, line); + if (need_redraw != NULL) { + *need_redraw = 1; + } + } + return 0; +} + + +static int send_callsign(max25_client_t *client, session_io_t *io, + const char *field, const char *value) +{ + char cmd[MAX25_LINE_MAX]; + + if (!max25_valid_callsign(value)) { + fprintf(stderr, "max25-terminal: invalid %s\n", field); + return -1; + } + snprintf(cmd, sizeof(cmd), "SET %s %s", field, value); + return send_cmd_async(client, io, cmd); +} + +static int send_line_async(max25_client_t *client, session_io_t *io, + const char *line) +{ + char cmd[MAX25_LINE_MAX + 8]; + size_t max_payload = sizeof(cmd) - 6; + + if (strlen(line) > max_payload) { + return -1; + } + snprintf(cmd, sizeof(cmd), "SEND %s", line); + return send_cmd_async(client, io, cmd); +} + +static int refresh_status(max25_client_t *client, max25_status_t *status) +{ + char reply[MAX25_LINE_MAX]; + + if (max25_client_command(client, "GET STATUS", reply, sizeof(reply)) != 0) { + return -1; + } + return max25_client_parse_status(reply, status); +} + +static int set_device_sync(max25_client_t *client, max25_status_t *status, + const char *device_id) +{ + char cmd[MAX25_LINE_MAX + 16]; + char reply[MAX25_LINE_MAX]; + + if (device_id == NULL || device_id[0] == '\0') { + return -1; + } + snprintf(cmd, sizeof(cmd), "SET DEVICE %s", device_id); + if (max25_client_command(client, cmd, reply, sizeof(reply)) != 0) { + return -1; + } + return refresh_status(client, status); +} + +static int fetch_devices(max25_client_t *client, max25_ui_t *ui) +{ + char line[MAX25_LINE_MAX]; + + if (max25_client_write(client, "GET DEVICES") != 0) { + return -1; + } + max25_ui_append_rx(ui, "-- max25d devices --"); + for (;;) { + if (max25_client_read_line(client, line, sizeof(line)) != 0) { + return -1; + } + if (strcmp(line, "OK") == 0) { + break; + } + if (strncmp(line, "DEVICE ", 7) == 0) { + max25_ui_append_rx(ui, line); + continue; + } + if (strncmp(line, "ERR ", 4) == 0) { + max25_ui_append_rx(ui, line); + return -1; + } + } + return 0; +} + +static int menu_action_needs_prompt(max25_menu_action_t action) +{ + return action == MAX25_MENU_CALLERID || action == MAX25_MENU_CALLID || + action == MAX25_MENU_SEND || action == MAX25_MENU_DEVICE; +} + +/* Returns 1 to quit session, 0 otherwise. Hides menu before action. */ +static int apply_menu_pick(max25_menu_action_t action, max25_client_t *client, + session_io_t *io, max25_ui_t *ui, + max25_status_t *status, const char *input_line) +{ + char buf[MAX25_LINE_MAX]; + char cmd[64]; + + if (action == MAX25_MENU_NONE) { + return 0; + } + if (action == MAX25_MENU_QUIT) { + return 1; + } + + /* Leave overlay so prompts / STATUS RX / header redraw are visible. */ + max25_ui_hide_menu(ui); + max25_ui_draw_screen(ui, status, input_line != NULL ? input_line : ""); + + switch (action) { + case MAX25_MENU_CALLERID: + if (max25_ui_prompt(ui, "CALLERID", buf, sizeof(buf)) > 0) { + send_callsign(client, io, "CALLERID", buf); + } + break; + case MAX25_MENU_CALLID: + if (max25_ui_prompt(ui, "CALLID", buf, sizeof(buf)) > 0) { + send_callsign(client, io, "CALLID", buf); + } + break; + case MAX25_MENU_STATUS: + send_cmd_async(client, io, "GET STATUS"); + break; + case MAX25_MENU_SEND: + if (max25_ui_prompt(ui, "Send", buf, sizeof(buf)) > 0) { + send_line_async(client, io, buf); + } + break; + case MAX25_MENU_MONITOR: + snprintf(cmd, sizeof(cmd), "MONITOR %s", + status->monitor ? "off" : "on"); + status->monitor = !status->monitor; + send_cmd_async(client, io, cmd); + max25_ui_append_rx(ui, status->monitor ? "MONITOR on" : "MONITOR off"); + max25_ui_draw_screen(ui, status, input_line != NULL ? input_line : ""); + break; + case MAX25_MENU_CONNECT: + send_cmd_async(client, io, + status->connected ? "DISCONNECT" : "CONNECT"); + break; + case MAX25_MENU_DEVICE: + (void)fetch_devices(client, ui); + if (max25_ui_prompt(ui, "DEVICE id", buf, sizeof(buf)) > 0) { + if (set_device_sync(client, status, buf) != 0) { + max25_ui_append_rx(ui, "ERR SET DEVICE failed"); + } + max25_ui_draw_screen(ui, status, + input_line != NULL ? input_line : ""); + } + break; + case MAX25_MENU_DEVICES: + (void)fetch_devices(client, ui); + max25_ui_draw_screen(ui, status, input_line != NULL ? input_line : ""); + break; + default: + break; + } + + if (!menu_action_needs_prompt(action) && action != MAX25_MENU_MONITOR) { + max25_ui_draw_screen(ui, status, input_line != NULL ? input_line : ""); + } + return 0; +} + +#ifdef MAX25_HAVE_NCURSES +static int read_key_ncurses(void) +{ + int ch = getch(); + + if (ch == KEY_F(10)) { + return 999; + } + return ch; +} +#endif + +static int read_plain_key(void) +{ + unsigned char buf[8]; + ssize_t n; + + n = read(STDIN_FILENO, buf, sizeof(buf)); + if (n <= 0) { + if (n < 0 && errno == EAGAIN) { + return -1; + } + return 0; + } + + if (buf[0] == 0x1b && n >= 3 && buf[1] == '[') { + if (n >= 4 && buf[2] == '2' && buf[3] == '1' && (n < 5 || buf[4] == '~')) { + return 999; + } + } + + return (int)buf[0]; +} + +static int run_session(max25_client_t *client, max25_ui_t *ui, + terminal_config_t *cfg, max25_status_t *status) +{ + struct pollfd pfds[2]; + session_io_t io; + char line[MAX25_LINE_MAX]; + char input[MAX25_UI_INPUT_MAX]; + size_t input_len = 0; + int running = 1; + int use_ncurses = max25_ui_has_ncurses(ui); + int use_plain_raw = 0; + + memset(&io, 0, sizeof(io)); + input[0] = '\0'; + + if (!use_ncurses) { + max25_ui_apply_palette(); + } + max25_ui_reset_rx(ui); + max25_ui_draw_screen(ui, status, input); + + /* Arm TX session before accepting Enter/F10 SEND (daemon also auto-arms + * on SEND; sync CONNECT here so header connected=yes before first key). */ + if (!status->connected) { + char creply[MAX25_LINE_MAX]; + + if (max25_client_command(client, "CONNECT", creply, sizeof(creply)) == 0) { + status->connected = 1; + max25_ui_append_rx(ui, "EVENT connected"); + } else { + max25_ui_append_rx(ui, creply[0] ? creply : "ERR CONNECT failed"); + } + max25_ui_draw_screen(ui, status, input); + } + + (void)fetch_devices(client, ui); + max25_ui_draw_screen(ui, status, input); + + if (!use_ncurses && isatty(STDIN_FILENO)) { + use_plain_raw = (tty_enable_raw() == 0); + } + + while (running) { + if (!io.waiting_reply) { + max25_ui_draw_screen(ui, status, input); + } + + pfds[0].fd = STDIN_FILENO; + pfds[0].events = POLLIN; + pfds[0].revents = 0; + pfds[1].fd = max25_client_fd(client); + pfds[1].events = POLLIN; + pfds[1].revents = 0; + + if (poll(pfds, 2, 200) < 0) { + if (errno == EINTR) { + continue; + } + break; + } + + if ((pfds[1].revents & (POLLERR | POLLHUP | POLLNVAL)) != 0) { + max25_ui_append_rx(ui, "max25d connection lost"); + break; + } + + if ((pfds[1].revents & POLLIN) != 0) { + int need_redraw = 0; + + if (max25_client_read_line(client, line, sizeof(line)) != 0) { + max25_ui_append_rx(ui, "max25d connection closed"); + break; + } + dispatch_daemon_line(line, &io, ui, status, cfg, &need_redraw); + if (io.last_err[0] != '\0' && cfg->verbose) { + fprintf(stderr, "max25-terminal: %s\n", io.last_err); + } + if (need_redraw && !max25_ui_menu_visible(ui)) { + max25_ui_draw_screen(ui, status, input); + } + continue; + } + + if ((pfds[0].revents & POLLIN) == 0) { + continue; + } + +#ifdef MAX25_HAVE_NCURSES + if (use_ncurses) { + int ch = read_key_ncurses(); + if (ch == -1) { + continue; + } + if (ch == 999) { + if (max25_ui_menu_visible(ui)) { + max25_ui_hide_menu(ui); + max25_ui_draw_screen(ui, status, input); + } else { + max25_ui_show_menu(ui, status); + } + continue; + } + if (max25_ui_menu_visible(ui)) { + if (ch == 27) { + max25_ui_hide_menu(ui); + max25_ui_draw_screen(ui, status, input); + continue; + } + if (apply_menu_pick(max25_ui_menu_pick(ui, ch), client, &io, ui, + status, input) != 0) { + running = 0; + } + continue; + } + if (ch >= 32 && ch < 127) { + if (input_len + 1 < sizeof(input)) { + input[input_len++] = (char)ch; + input[input_len] = '\0'; + } + } else if (ch == '\n' || ch == KEY_ENTER || ch == '\r') { + if (input_len > 0) { + send_line_async(client, &io, input); + input_len = 0; + input[0] = '\0'; + } + } else if (ch == KEY_BACKSPACE || ch == 127 || ch == '\b') { + if (input_len > 0) { + input[--input_len] = '\0'; + } + } + continue; + } +#endif + + if (use_plain_raw) { + int ch = read_plain_key(); + if (ch <= 0) { + continue; + } + if (ch == 999) { + if (max25_ui_menu_visible(ui)) { + max25_ui_hide_menu(ui); + max25_ui_draw_screen(ui, status, input); + } else { + max25_ui_show_menu(ui, status); + } + continue; + } + if (max25_ui_menu_visible(ui)) { + if (ch == 27) { + max25_ui_hide_menu(ui); + max25_ui_draw_screen(ui, status, input); + continue; + } + if (apply_menu_pick(max25_ui_menu_pick(ui, ch), client, &io, ui, + status, input) != 0) { + running = 0; + } + continue; + } + if (ch >= 32 && ch < 127) { + if (input_len + 1 < sizeof(input)) { + input[input_len++] = (char)ch; + input[input_len] = '\0'; + } + } else if (ch == '\n' || ch == '\r') { + if (input_len > 0) { + send_line_async(client, &io, input); + input_len = 0; + input[0] = '\0'; + } + } else if (ch == 127 || ch == '\b') { + if (input_len > 0) { + input[--input_len] = '\0'; + } + } + continue; + } + + if (fgets(input, sizeof(input), stdin) == NULL) { + running = 0; + } else { + input[strcspn(input, "\r\n")] = '\0'; + if (max25_ui_menu_visible(ui)) { + if (apply_menu_pick(max25_ui_menu_pick(ui, input[0]), client, + &io, ui, status, "") != 0) { + running = 0; + } + } else if (input[0] != '\0') { + send_line_async(client, &io, input); + } + input[0] = '\0'; + input_len = 0; + } + } + + tty_restore(); + return 0; +} + +int main(int argc, char *argv[]) +{ + terminal_config_t cfg; + max25_client_t *client = NULL; + max25_ui_t *ui = NULL; + max25_status_t status; + char reply[MAX25_LINE_MAX]; + int opt; + int argi; + static const struct option long_opts[] = { + {"host", required_argument, NULL, 'H'}, + {"port", required_argument, NULL, 'p'}, + {"unix", required_argument, NULL, 'U'}, + {"tcp-only", no_argument, NULL, 'T'}, + {"ax25-ui", no_argument, NULL, 1000}, + {"no-ax25-ui", no_argument, NULL, 1001}, + {"password", required_argument, NULL, 'P'}, + {"device", required_argument, NULL, 'd'}, + {"verbose", no_argument, NULL, 'v'}, + {"probe", no_argument, NULL, 1002}, + {"help", no_argument, NULL, 'h'}, + {NULL, 0, NULL, 0} + }; + + config_defaults(&cfg); + + while ((opt = getopt_long(argc, argv, "H:p:TU:P:d:v:h", long_opts, NULL)) != -1) { + switch (opt) { + case 'H': + strncpy(cfg.host, optarg, sizeof(cfg.host) - 1); + break; + case 'p': + cfg.port = (unsigned)strtoul(optarg, NULL, 10); + break; + case 'T': + cfg.tcp_only = 1; + break; + case 'U': + strncpy(cfg.unix_path, optarg, sizeof(cfg.unix_path) - 1); + break; + case 'P': + strncpy(cfg.tcp_password, optarg, sizeof(cfg.tcp_password) - 1); + break; + case 'd': + strncpy(cfg.default_device, optarg, sizeof(cfg.default_device) - 1); + break; + case 'v': + cfg.verbose = 1; + break; + case 1002: + cfg.probe = 1; + cfg.tcp_only = 1; + break; + case 'h': + usage(argv[0]); + return EXIT_SUCCESS; + case 1000: + cfg.ax25_ui = 1; + break; + case 1001: + cfg.ax25_ui = 0; + break; + default: + usage(argv[0]); + return EXIT_FAILURE; + } + } + + argi = optind; + if (argi < argc) { + strncpy(cfg.host, argv[argi++], sizeof(cfg.host) - 1); + } + if (argi < argc) { + cfg.port = (unsigned)strtoul(argv[argi++], NULL, 10); + } + + if (max25_client_connect(cfg.host, cfg.port, + cfg.tcp_only ? "" : cfg.unix_path, + cfg.tcp_only, cfg.tcp_password, + &client, &status) != 0) { + fprintf(stderr, "max25-terminal: connect failed"); + if (cfg.tcp_only) { + fprintf(stderr, " (tcp %s:%u)", cfg.host, cfg.port); + } else { + fprintf(stderr, " (unix %s / tcp %s:%u)", + cfg.unix_path, cfg.host, cfg.port); + } + fputc('\n', stderr); + return EXIT_FAILURE; + } + + if (cfg.probe) { + printf("STATUS hardware=%s device=%s mode=%s callerid=%s callid=%s " + "ax25_ui=%s connected=%s stack=%s\n", + status.hardware, status.device, status.mode, + status.callerid, status.callid, + status.ax25_ui ? "on" : "off", + status.connected ? "yes" : "no", + status.stack); + max25_client_write(client, "DISCONNECT"); + max25_client_free(client); + return EXIT_SUCCESS; + } + + if (max25_ui_init(&ui) != 0) { + max25_client_free(client); + return EXIT_FAILURE; + } + + if (cfg.default_device[0] != '\0') { + if (set_device_sync(client, &status, cfg.default_device) != 0) { + fprintf(stderr, "max25-terminal: SET DEVICE %s failed\n", + cfg.default_device); + max25_client_free(client); + return EXIT_FAILURE; + } + } + + if (cfg.ax25_ui) { + max25_client_command(client, "SET AX25_UI on", reply, sizeof(reply)); + status.ax25_ui = 1; + } else { + max25_client_command(client, "SET AX25_UI off", reply, sizeof(reply)); + status.ax25_ui = 0; + } + + if (cfg.verbose) { + fprintf(stderr, "max25-terminal: connected %s:%u tcp_only=%s ax25-ui=%s\n", + cfg.host, cfg.port, cfg.tcp_only ? "yes" : "no", + status.ax25_ui ? "on" : "off"); + } + + run_session(client, ui, &cfg, &status); + + max25_client_write(client, "DISCONNECT"); + max25_ui_shutdown(ui); + max25_client_free(client); + return EXIT_SUCCESS; +} diff --git a/stacks/terminal/max25_ui.c b/stacks/terminal/max25_ui.c new file mode 100644 index 0000000..91cc977 --- /dev/null +++ b/stacks/terminal/max25_ui.c @@ -0,0 +1,407 @@ +#include "max25_ui.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <unistd.h> + +#ifdef MAX25_HAVE_NCURSES +#include <ncurses.h> +#endif + +struct max25_ui { + int menu_open; + char rx_lines[MAX25_UI_RX_LINES][MAX25_LINE_MAX]; + int rx_count; + int rx_head; +#ifdef MAX25_HAVE_NCURSES + int ncurses_on; +#endif +}; + +#ifdef MAX25_HAVE_NCURSES +static int term_ok_for_ncurses(void) +{ + const char *term = getenv("TERM"); + + if (term == NULL || term[0] == '\0') { + return 0; + } + if (strcmp(term, "dumb") == 0) { + return 0; + } + if (strncmp(term, "unknown", 7) == 0) { + return 0; + } + return 1; +} +#endif + +int max25_ui_init(max25_ui_t **ui) +{ + max25_ui_t *u; + + if (ui == NULL) { + return -1; + } + + u = calloc(1, sizeof(*u)); + if (u == NULL) { + return -1; + } + +#ifdef MAX25_HAVE_NCURSES + if (isatty(STDOUT_FILENO) && isatty(STDIN_FILENO) && term_ok_for_ncurses() + && initscr() != NULL) { + cbreak(); + noecho(); + keypad(stdscr, TRUE); + nodelay(stdscr, TRUE); + if (has_colors()) { + start_color(); + init_pair(1, COLOR_WHITE, COLOR_BLACK); + } + bkgd(COLOR_PAIR(1)); + curs_set(0); + clear(); + refresh(); + u->ncurses_on = 1; + } +#endif + + *ui = u; + return 0; +} + +void max25_ui_shutdown(max25_ui_t *ui) +{ + if (ui == NULL) { + return; + } +#ifdef MAX25_HAVE_NCURSES + if (ui->ncurses_on) { + endwin(); + } +#endif + free(ui); +} + +void max25_ui_apply_palette(void) +{ + fputs(MAX25_TERM_SGR, stdout); + fflush(stdout); +} + +void max25_ui_clear_screen(void) +{ + fputs(MAX25_TERM_CLEAR, stdout); + fputs(MAX25_TERM_SGR, stdout); + fflush(stdout); +} + +int max25_ui_has_ncurses(const max25_ui_t *ui) +{ +#ifdef MAX25_HAVE_NCURSES + return ui != NULL && ui->ncurses_on; +#else + (void)ui; + return 0; +#endif +} + +void max25_ui_reset_rx(max25_ui_t *ui) +{ + if (ui == NULL) { + return; + } + ui->rx_count = 0; + ui->rx_head = 0; + memset(ui->rx_lines, 0, sizeof(ui->rx_lines)); +} + +void max25_ui_append_rx(max25_ui_t *ui, const char *text) +{ + int slot; + + if (ui == NULL || text == NULL) { + return; + } + + slot = ui->rx_head; + strncpy(ui->rx_lines[slot], text, MAX25_LINE_MAX - 1); + ui->rx_lines[slot][MAX25_LINE_MAX - 1] = '\0'; + ui->rx_head = (ui->rx_head + 1) % MAX25_UI_RX_LINES; + if (ui->rx_count < MAX25_UI_RX_LINES) { + ui->rx_count++; + } +} + +static void draw_header_plain(const max25_status_t *status) +{ + printf("%sMAX25 Terminal DEVICE: %-12s CALLERID: %-9s CALLID: %-9s ax25-ui: %s connected: %s F10=Menu\n", + MAX25_TERM_SGR, + status->device[0] != '\0' ? status->device : "-", + status->callerid, status->callid, + status->ax25_ui ? "on" : "off", + status->connected ? "yes" : "no"); + fflush(stdout); +} + +static void draw_rx_plain(const max25_ui_t *ui) +{ + int i; + int start; + int idx; + + if (ui == NULL) { + return; + } + + start = ui->rx_count < MAX25_UI_RX_LINES ? 0 : ui->rx_head; + for (i = 0; i < ui->rx_count; i++) { + idx = (start + i) % MAX25_UI_RX_LINES; + printf("%s%s\n", MAX25_TERM_SGR, ui->rx_lines[idx]); + } + fflush(stdout); +} + +void max25_ui_draw_screen(max25_ui_t *ui, const max25_status_t *status, + const char *input_line) +{ + int i; + int start; + int idx; + int rows; + int y; + + if (ui == NULL || status == NULL) { + return; + } + +#ifdef MAX25_HAVE_NCURSES + if (ui->ncurses_on && !ui->menu_open) { + getmaxyx(stdscr, rows, y); + (void)y; + if (rows < 4) { + rows = 24; + } + + attron(COLOR_PAIR(1)); + mvprintw(0, 0, + "MAX25 Terminal DEVICE: %-12s CALLERID: %-9s CALLID: %-9s ax25-ui: %s connected: %s F10=Menu", + status->device[0] != '\0' ? status->device : "-", + status->callerid, status->callid, + status->ax25_ui ? "on" : "off", + status->connected ? "yes" : "no"); + clrtoeol(); + + start = ui->rx_count < MAX25_UI_RX_LINES ? 0 : ui->rx_head; + for (i = 0; i < ui->rx_count && i + 2 < rows - 2; i++) { + idx = (start + i) % MAX25_UI_RX_LINES; + mvprintw(i + 2, 0, "%.*s", (int)(rows > 0 ? 200 : 80), ui->rx_lines[idx]); + clrtoeol(); + } + + mvprintw(rows - 2, 0, "%s", "--------------------------------------------------------"); + clrtoeol(); + mvprintw(rows - 1, 0, "> %s", input_line != NULL ? input_line : ""); + clrtoeol(); + attroff(COLOR_PAIR(1)); + refresh(); + return; + } +#endif + + if (!ui->menu_open) { + fputs(MAX25_TERM_CLEAR, stdout); + fputs(MAX25_TERM_SGR, stdout); + draw_header_plain(status); + draw_rx_plain(ui); + printf("%s> %s\n", MAX25_TERM_SGR, input_line != NULL ? input_line : ""); + fflush(stdout); + } +} + +static void draw_menu_plain(const max25_status_t *status) +{ + puts(""); + puts("+- MAX25 Terminal ---------------------+"); + printf("| DEVICE: %-12s |\n", + status->device[0] != '\0' ? status->device : "-"); + printf("| CALLERID: %-9s CALLID: %-9s |\n", + status->callerid, status->callid); + puts("+---------------------------------------+"); + puts("| 1 Change CALLERID (live) |"); + puts("| 2 Change CALLID (live) |"); + puts("| 3 Status |"); + puts("| 4 Send line |"); + puts("| 5 RX only (Monitor) |"); + puts("| 6 Connection on/off |"); + puts("| 7 Change DEVICE (TX target) |"); + puts("| 8 List DEVICES (max25d) |"); + puts("| 0 Quit client |"); + puts("+---------------------------------------+"); + puts("Pick a number - F10 closes"); + fflush(stdout); +} + +#ifdef MAX25_HAVE_NCURSES +static void draw_menu_ncurses(const max25_status_t *status, int y0) +{ + attron(COLOR_PAIR(1)); + mvprintw(y0, 0, "+- MAX25 Terminal ---------------------+"); + mvprintw(y0 + 1, 0, "| DEVICE: %-12s |", + status->device[0] != '\0' ? status->device : "-"); + mvprintw(y0 + 2, 0, "| CALLERID: %-9s CALLID: %-9s |", + status->callerid, status->callid); + mvprintw(y0 + 3, 0, "+---------------------------------------+"); + mvprintw(y0 + 4, 0, "| 1 Change CALLERID (live) |"); + mvprintw(y0 + 5, 0, "| 2 Change CALLID (live) |"); + mvprintw(y0 + 6, 0, "| 3 Status |"); + mvprintw(y0 + 7, 0, "| 4 Send line |"); + mvprintw(y0 + 8, 0, "| 5 RX only (Monitor) |"); + mvprintw(y0 + 9, 0, "| 6 Connection on/off |"); + mvprintw(y0 + 10, 0, "| 7 Change DEVICE (TX target) |"); + mvprintw(y0 + 11, 0, "| 8 List DEVICES (max25d) |"); + mvprintw(y0 + 12, 0, "| 0 Quit client |"); + mvprintw(y0 + 13, 0, "+---------------------------------------+"); + mvprintw(y0 + 14, 0, "Pick a number - F10 closes"); + attroff(COLOR_PAIR(1)); +} +#endif + +void max25_ui_show_menu(max25_ui_t *ui, const max25_status_t *status) +{ + if (ui == NULL || status == NULL) { + return; + } + ui->menu_open = 1; + +#ifdef MAX25_HAVE_NCURSES + if (ui->ncurses_on) { + clear(); + bkgd(COLOR_PAIR(1)); + draw_menu_ncurses(status, 2); + refresh(); + return; + } +#endif + fputs(MAX25_TERM_CLEAR, stdout); + fputs(MAX25_TERM_SGR, stdout); + draw_menu_plain(status); +} + +void max25_ui_hide_menu(max25_ui_t *ui) +{ + if (ui == NULL) { + return; + } + ui->menu_open = 0; +#ifdef MAX25_HAVE_NCURSES + if (ui->ncurses_on) { + clear(); + bkgd(COLOR_PAIR(1)); + refresh(); + } +#endif +} + +int max25_ui_menu_visible(const max25_ui_t *ui) +{ + return ui != NULL && ui->menu_open; +} + +max25_menu_action_t max25_ui_menu_pick(max25_ui_t *ui, int ch) +{ + (void)ui; + switch (ch) { + case '1': + return MAX25_MENU_CALLERID; + case '2': + return MAX25_MENU_CALLID; + case '3': + return MAX25_MENU_STATUS; + case '4': + return MAX25_MENU_SEND; + case '5': + return MAX25_MENU_MONITOR; + case '6': + return MAX25_MENU_CONNECT; + case '7': + return MAX25_MENU_DEVICE; + case '8': + return MAX25_MENU_DEVICES; + case '0': + return MAX25_MENU_QUIT; + default: + return MAX25_MENU_NONE; + } +} + +int max25_ui_prompt(max25_ui_t *ui, const char *label, char *buf, size_t buf_sz) +{ + if (label == NULL || buf == NULL || buf_sz == 0) { + return -1; + } + +#ifdef MAX25_HAVE_NCURSES + if (ui != NULL && ui->ncurses_on) { + int ch; + size_t len = 0; + int rows; + + getmaxyx(stdscr, rows, ch); + (void)rows; + echo(); + curs_set(1); + attron(COLOR_PAIR(1)); + mvprintw(14, 0, "%s: ", label); + clrtoeol(); + refresh(); + buf[0] = '\0'; + + nodelay(stdscr, FALSE); + while (len + 1 < buf_sz) { + ch = getch(); + if (ch == '\n' || ch == KEY_ENTER || ch == '\r') { + break; + } + if (ch == 27) { + nodelay(stdscr, TRUE); + noecho(); + curs_set(0); + return -1; + } + if (ch == KEY_BACKSPACE || ch == 127 || ch == '\b') { + if (len > 0) { + len--; + buf[len] = '\0'; + mvprintw(14, (int)strlen(label) + 2, "%s ", buf); + clrtoeol(); + refresh(); + } + continue; + } + if (ch >= 32 && ch < 127) { + buf[len++] = (char)ch; + buf[len] = '\0'; + mvprintw(14, (int)strlen(label) + 2, "%s", buf); + refresh(); + } + } + nodelay(stdscr, TRUE); + noecho(); + curs_set(0); + attroff(COLOR_PAIR(1)); + return (int)len; + } +#endif + + printf("%s%s: ", MAX25_TERM_SGR, label); + fflush(stdout); + if (fgets(buf, (int)buf_sz, stdin) == NULL) { + return -1; + } + buf[strcspn(buf, "\r\n")] = '\0'; + return (int)strlen(buf); +} diff --git a/stacks/terminal/max25_ui.h b/stacks/terminal/max25_ui.h new file mode 100644 index 0000000..9c1928d --- /dev/null +++ b/stacks/terminal/max25_ui.h @@ -0,0 +1,45 @@ +#ifndef MAX25_UI_H +#define MAX25_UI_H + +#include "max25_proto.h" + +#define MAX25_TERM_SGR "\033[37;40m" +#define MAX25_TERM_CLEAR "\033[2J\033[H" +#define MAX25_UI_RX_LINES 12 +#define MAX25_UI_INPUT_MAX 256 + +typedef struct max25_ui max25_ui_t; + +typedef enum { + MAX25_MENU_NONE = 0, + MAX25_MENU_CALLERID, + MAX25_MENU_CALLID, + MAX25_MENU_STATUS, + MAX25_MENU_SEND, + MAX25_MENU_MONITOR, + MAX25_MENU_CONNECT, + MAX25_MENU_DEVICE, + MAX25_MENU_DEVICES, + MAX25_MENU_QUIT +} max25_menu_action_t; + +int max25_ui_init(max25_ui_t **ui); +void max25_ui_shutdown(max25_ui_t *ui); + +void max25_ui_apply_palette(void); +void max25_ui_clear_screen(void); + +int max25_ui_has_ncurses(const max25_ui_t *ui); + +void max25_ui_reset_rx(max25_ui_t *ui); +void max25_ui_append_rx(max25_ui_t *ui, const char *text); +void max25_ui_draw_screen(max25_ui_t *ui, const max25_status_t *status, + const char *input_line); +void max25_ui_show_menu(max25_ui_t *ui, const max25_status_t *status); +void max25_ui_hide_menu(max25_ui_t *ui); +int max25_ui_menu_visible(const max25_ui_t *ui); + +max25_menu_action_t max25_ui_menu_pick(max25_ui_t *ui, int ch); +int max25_ui_prompt(max25_ui_t *ui, const char *label, char *buf, size_t buf_sz); + +#endif /* MAX25_UI_H */ diff --git a/stacks/terminal/test-terminal.sh b/stacks/terminal/test-terminal.sh new file mode 100755 index 0000000..6a510f3 --- /dev/null +++ b/stacks/terminal/test-terminal.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# max25-terminal offline probe — binary + TCP connect test. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")" && pwd)" +REPO="$(cd "${ROOT}/../.." && pwd)" +TERM="${MAX25_TERMINAL:-${REPO}/build/bin/max25-terminal}" + +[[ -x "${TERM}" ]] || { echo "FAIL: ${TERM} missing" >&2; exit 1; } + +CLIENT="${REPO}/build/bin/max25-client" +if [[ -L "${CLIENT}" ]] || [[ -x "${CLIENT}" ]]; then + : +else + echo "WARN: max25-client symlink missing until cmake --install" >&2 +fi + +"${TERM}" --help >/dev/null + +# Unit: EVENT connected/disconnected updates status (header bug regression) +UNIT="${ROOT}/.test_apply_event" +gcc -std=c11 -Wall -Wextra -O2 -I"${ROOT}" \ + -o "${UNIT}" "${ROOT}/test_apply_event.c" "${ROOT}/max25_proto.c" +"${UNIT}" +rm -f "${UNIT}" + +MAX25_TERMINAL="${TERM}" python3 "${ROOT}/test_connect.py" +echo "OK: max25-terminal" diff --git a/stacks/terminal/test_apply_event.c b/stacks/terminal/test_apply_event.c new file mode 100644 index 0000000..36e10f0 --- /dev/null +++ b/stacks/terminal/test_apply_event.c @@ -0,0 +1,30 @@ +/* Offline unit test: EVENT connected/disconnected → status.connected */ +#include "max25_proto.h" + +#include <stdio.h> +#include <string.h> + +int main(void) +{ + max25_status_t st; + + memset(&st, 0, sizeof(st)); + if (max25_client_apply_event("EVENT connected", &st) != 1 || !st.connected) { + fprintf(stderr, "FAIL: EVENT connected\n"); + return 1; + } + if (max25_client_apply_event("EVENT disconnected", &st) != 1 || st.connected) { + fprintf(stderr, "FAIL: EVENT disconnected\n"); + return 1; + } + if (max25_client_apply_event("EVENT device=max25e0 serial=ready", &st) != 0) { + fprintf(stderr, "FAIL: unrelated EVENT should be ignored\n"); + return 1; + } + if (max25_client_apply_event("OK", &st) != 0) { + fprintf(stderr, "FAIL: non-EVENT\n"); + return 1; + } + puts("OK: apply_event"); + return 0; +} diff --git a/stacks/terminal/test_connect.py b/stacks/terminal/test_connect.py new file mode 100644 index 0000000..6496b69 --- /dev/null +++ b/stacks/terminal/test_connect.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""TCP connect smoke test for max25-terminal --probe.""" +import socket +import subprocess +import sys +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +DAEMON = ROOT / "stacks/daemon/max25d" +INI = ROOT / "share/max25/max25d.ini.example" + + +def terminal_binary() -> Path: + import os + + env = os.environ.get("MAX25_TERMINAL") + if env: + return Path(env) + for candidate in ( + ROOT / "build/bin/max25-terminal", + ROOT / "stacks/terminal/max25-terminal", + ): + if candidate.is_file(): + return candidate + return ROOT / "build/bin/max25-terminal" + + +def free_port() -> int: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + sock.close() + return port + + +def main() -> int: + terminal = terminal_binary() + if not terminal.is_file(): + print( + f"FAIL: max25-terminal missing — run cmake --build build --target max25-terminal " + f"(looked for {terminal})", + file=sys.stderr, + ) + return 1 + + port = free_port() + proc = subprocess.Popen( + [str(DAEMON), "--no-stack", "-c", str(INI), "--tcp-port", str(port)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + time.sleep(0.6) + try: + out = subprocess.check_output( + [str(terminal), "-T", "-H", "127.0.0.1", "-p", str(port), "--probe"], + text=True, + timeout=10, + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc: + proc.terminate() + proc.wait(timeout=5) + print(f"FAIL: terminal probe: {exc}", file=sys.stderr) + return 1 + finally: + proc.terminate() + proc.wait(timeout=5) + + if "STATUS hardware=" not in out or "callerid=" not in out: + print(f"FAIL: unexpected probe output: {out!r}", file=sys.stderr) + return 1 + + print("OK: max25-terminal TCP probe") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) |
