diff options
| author | info@mode42.com <info@mode42.com> | 2026-08-07 18:25:13 +0000 |
|---|---|---|
| committer | info@mode42.com <info@mode42.com> | 2026-08-07 18:25:13 +0000 |
| commit | 04d965d67a7264a1c7c211494aebda1953df7603 (patch) | |
| tree | 0ebd700a6e219f84a26a656f4bee8778bc75da7b /stacks/crdop | |
Initial push
Diffstat (limited to 'stacks/crdop')
68 files changed, 4627 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 |
