Skip to content

feat(tnc): AX.25 worker thread, AFSK profiles A/B/AB/A+/B+/AB+, HdlcCodec Phase 1a+1b#3466

Closed
K5PTB wants to merge 2 commits into
aethersdr:mainfrom
K5PTB:feat/ax25-hdlc-thread
Closed

feat(tnc): AX.25 worker thread, AFSK profiles A/B/AB/A+/B+/AB+, HdlcCodec Phase 1a+1b#3466
K5PTB wants to merge 2 commits into
aethersdr:mainfrom
K5PTB:feat/ax25-hdlc-thread

Conversation

@K5PTB

@K5PTB K5PTB commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

AetherSDR's VHF 1200 baud AX.25 decode rate was 27% of Direwolf's on identical audio. This PR brings it to 96% on the default A+ profile — matching and slightly exceeding both Direwolf and Graywolf — by replacing the libmodem sinc-correlator with a Direwolf-derived AFSK demodulator.

Summary

Three branches of development have been squashed into a single commit for review:

  • feat/aether-afsk-demod — Direwolf-derived AFSK demodulator, Profile A
  • feat/aether-afsk-profile-b — full 6-mode selector (A / B / AB / A+ / B+ / AB+)
  • feat/ax25-shim-worker-thread — worker thread + full 9-slicer bank + HdlcCodec Phase 1a+1b

Also addresses #3387.

Relationship to issue #2701

Issue #2701 describes a phased roadmap for a native Soft-TNC. This PR lands the following pieces of that plan:

Phase 1a — HdlcCodec (src/core/tnc/HdlcCodec.{h,cpp} + tests/hdlc_codec_test.cpp): the native HDLC framing layer called out in the plan. 27 unit tests; CRC-16-CCITT cross-checked against both Direwolf and graywolf as the plan specified. This is the first of the planned test suite to land.

Phase 2 — AFSK demodulator: the plan described a "correlator-difference AFSK demod + early-late symbol clock" for a new ClientTnc. Rather than build a new wrapper, we replaced the demodulator inside the existing AetherAx25LibmodemShim, producing a working VHF 1200 baud decode path without disrupting the surrounding architecture. The demodulator is Direwolf-derived (Profiles A/B/AB/A+/B+/AB+). AX.25 structure parsing remains on libmodem for now.

Threading: The plan describes ClientTnc running inline on the AudioEngine thread. We chose a dedicated QThread instead — see the Worker thread section below for the reasoning.

Changes

1. Direwolf-derived AFSK demodulator (third_party/direwolf_afsk/)

Replaces lm::sinc_corr_afsk_demodulator for VHF 1200 baud. HF 300 baud continues to use libmodem unchanged.

Source location: third_party/direwolf_afsk/, following the libmodem_core/ subset pattern. README.aethersdr.md records the upstream release, referenced files, license compatibility rationale, and what was omitted. THIRD_PARTY_LICENSES updated.

License: Direwolf is GPL-2-or-later. AetherSDR is GPL-3. GPL-2-or-later is one-way compatible into GPL-3 (FSF). Source headers reflect GPL-3, not the previously-cited GPL-2+.

Six VHF modes exposed via a new VhfMode enum and runtime QComboBox selector. The AETHERSDR_USE_AETHERDEMOD compile-time switch from the initial draft has been removed — VHF 1200 always uses the Direwolf-derived path. The selector is disabled when HF 300 is active:

Mode Description Lanes
Off VHF demodulation disabled 0
A IQ-mix · 1 slicer 1
B FM discriminator · 1 slicer 1
AB IQ-mix + FM discriminator · 1 slicer each 2
A+ IQ-mix · 9 slicers (Direwolf default) 9
B+ FM discriminator · 9 slicers 9
AB+ IQ-mix + FM discriminator · 9 slicers each 18

Default is A+, matching Direwolf's MODEM 1200 default.

Profile A (AetherAFSKDemod): IQ-mix + RRC + DPLL, space-gain bank. The A+ 9-slicer values {0.500, 0.648, 0.841, 1.091, 1.414, 1.834, 2.378, 3.084, 4.000} are the exact geometric series from Direwolf's demod_afsk.c (MAX_SUBCHANS=9), compensating for VHF FM de-emphasis on the 2200 Hz space tone.

Profile B (AetherFMDiscrimDemod): FM discriminator — IQ-mix → atan2 → phase rate → normalize. Targets signals where amplitude balance between mark and space is unreliable (frequency-offset stations, Doppler, weak paths). Complements A+: each catches signals the other misses. The B+ 9 slice offsets {−0.500, −0.375, …, 0.500} match Direwolf's MAX_SLICERS=9 linear series.

Screenshot 2026-06-08 at 08 18 42 Screenshot 2026-06-08 at 08 18 56

2. Worker thread

AetherAx25LibmodemShim now runs entirely on a dedicated QThread (m_shimThread) owned by Ax25HfPacketDecodeDialog. Both the VHF (AetherAFSKDemod) and HF 300 baud (libmodem) paths run through the same feedAudio() entry point, so both move to the worker thread automatically.

Issue #2701 describes the long-term ClientTnc architecture running inline on the AudioEngine thread. We chose a dedicated worker thread instead, for two reasons:

  • The original shim ran on the GUI thread. Multi-lane AFSK demodulation is heavy enough to cause visible UI degradation, and the RPi 5 demonstrated that audio sampling was also affected. Isolating the demod to its own thread keeps both the UI and the audio path responsive. In AB+ mode (18 lanes) on the RPi 5, the demod worker uses approximately 23% of one core.
  • The AudioEngine thread is real-time / high-priority; blocking it with a 9–18 lane correlator bank would cause glitches in the waterfall, RX audio, and QSO recording. The roadmap's AudioEngine-thread approach assumes a lightweight single-lane demodulator; this implementation is considerably heavier.

When the roadmap's ClientTnc is eventually built as a lightweight single-lane demodulator, the AudioEngine-thread approach may be appropriate for that path. The shim stays on its dedicated worker.

Implementation details:

  • Cross-thread calls (configure(), reset(), feedAudio(), setDiagnosticsLoggingEnabled()) dispatched via QMetaObject::invokeMethod with Qt::QueuedConnection
  • A main-thread Ax25DemodConfig cache eliminates cross-thread reads on the hot path
  • Signal connections (frameDecoded, diagnosticsUpdated, statusChanged) use AutoConnection — Qt resolves these to QueuedConnection automatically

Audio sampling is unaffected even when running under AddressSanitizer on the RPi 5.

3. HdlcCodec — Phase 1a (new file: src/core/tnc/HdlcCodec.{h,cpp})

Standalone native HDLC framing class replacing lm::ax25::bitstream_state in the decode lanes. Pure C++, no Qt, no libmodem dependency.

Implements: NRZI decode, bit-stuffing, CRC-16-CCITT (0x8408, poly bit-reversed, init/final XOR 0xFFFF), LSB-first byte assembly. State machine: Searching → InPreamble → InFrame → Complete/Aborted.

Verified algorithmically equivalent to both Direwolf (hdlc_rec.c / fcs_calc.c) and libmodem before replacement. 27/27 unit tests in tests/hdlc_codec_test.cpp.

4. HdlcCodec — Phase 1b (integrated into AetherAx25LibmodemShim)

DecodeLane members replaced:

  • lm::ax25::bitstream_state bitstreamStateHdlcCodec hdlcCodec
  • std::vector<uint8_t> bitstreamBuffer → removed (internal to HdlcCodec)
  • std::array<uint8_t, 1000> candidateFrameBytes → removed (use hdlcCodec.frameData())

libmodem is retained for try_decode_frame_no_fcs (AX.25 structure parsing) and the TX packet builder. The HF 300 baud demodulator (lm::sinc_corr_afsk_demodulator) is also unchanged.

5. MSVC compatibility

third_party/direwolf_afsk: GNU __builtin_expect replaced with standard [[likely]]/[[unlikely]]; VLAs replaced with std::vector.

Addressing #3387 triage items

License (point 1): Confirmed — AetherSDR is GPL-3, not GPL-2+. Direwolf GPL-2-or-later is compatible. Source headers corrected.

Vendoring (point 2): Direwolf sources are in third_party/direwolf_afsk/ with README.aethersdr.md, matching the libmodem_core/ pattern. THIRD_PARTY_LICENSES updated.

HF 300 coverage (point 3): The Direwolf path is VHF 1200 only. The VhfMode selector is disabled for HF 300. HF 300 parity is a separate future item.

Framing layer scope (point 4): We went one step further than the triage suggested and also replaced the HDLC framing layer (Phase 1a+1b). Justification: analysis of Direwolf's hdlc_rec.c and libmodem confirmed they are algorithmically identical — same NRZI formula, same bit-stuffing check, same CRC polynomial, same byte alignment rule. The decode gap is entirely in the AFSK demodulator. Replacing libmodem's HDLC gives clean ownership of the framing layer, enables future instrumentation, and is required for the Phase 2 roadmap. The blast radius is low: the AX.25 structure parser (try_decode_frame_no_fcs) and TX path remain on libmodem.

Long-term build switch (point 5): The AETHERSDR_USE_AETHERDEMOD compile switch is removed. VHF 1200 always uses the Direwolf-derived demodulator. The runtime VhfMode selector (default A+) gives operators the tradeoff knob.

Performance

The original comparison cited in #3387 was libmodem 777 vs Direwolf 2891 over the same audio (3.7×). Long-duration live comparisons on 144.390 MHz APRS on a Raspberry Pi 5 show AetherSDR at or above parity with both Direwolf and Graywolf on the default A+ profile:

A+ mode (default) — 987 packets heard:

Decoder Frames % of all Avg quality
AetherSDR 946 96% confidence 61
Direwolf 940 95% level 58
Graywolf 933 95% confidence 54

AB+ mode (18 lanes) — 2,405 packets heard, 12-hour run:

Decoder Frames % of all Avg quality
AetherSDR 2230 93% confidence 64
Graywolf 2326 97% confidence 55
Direwolf 2329 97% level 29

On A+, AetherSDR leads both reference decoders. The AB+ result (4 points behind) is characterised by the confidence data: AetherSDR's average confidence on missed packets (GW+DW only: 23 frames, confidence 57–59) is marginally lower than on all-three packets (61), indicating conservative behaviour on the very weakest signals rather than systematic error. AetherSDR's false-positive rate is effectively zero in both datasets.

Comparison methodology

Decode performance was measured using a three-decoder comparison running continuously on a Raspberry Pi 5, listening to live 144.390 MHz APRS traffic via a FlexRadio 6600. All three decoders ran on separate slice receivers, each receiving the same 144.390 MHz signal. Direwolf and Graywolf listened to DAX audio streams from their respective slices; AetherSDR's internal decoder published decoded frames via MQTT.

Comparator: A purpose-built Python webapp subscribes to all three sources simultaneously — MQTT for AetherSDR, REST API polling for Graywolf, and subprocess stdout for Direwolf. Frames are matched within a 5-second window on content. Duplicate copies of the same RF burst (digipeated repeats) are counted once per decoder, making the totals directly comparable. A live browser UI and analysis endpoint show per-decoder counts and decode-combination breakdowns in real time.

Quality metrics: Each decoder was instrumented to report a signal quality value alongside each decoded frame — AetherSDR's existing confidence field (0–1, from the MQTT payload), Graywolf's decoded.quality field from its REST response, and Direwolf's per-bit EMA quality from its q=N audio level line. These were used to characterise the missed-packet population rather than as a pass/fail threshold.

What does not change

  • HF 300 baud decode path — unchanged, still uses libmodem
  • AX.25 structure parsing (try_decode_frame_no_fcs) — unchanged, still libmodem
  • TX audio generation — unchanged
  • KISS TNC, TCI server, MQTT paths — unchanged

Testing

tests/hdlc_codec_test.cpp — 27 tests, all pass.

Built against both HdlcCodec and libmodem simultaneously, using libmodem as ground truth:

Test What it covers
testStateTransitions Initial state (Searching); single-flag NRZI input transitions to InPreamble
testSingleFrame Libmodem-encoded AX.25 packet round-trips through HdlcCodec; exactly one frame detected, FCS valid
testFrameContents Decoded frame bytes passed to lm::ax25::try_decode_frame_no_fcs; callsigns and payload match the encoded packet
testFcsMatchesLibmodem HdlcCodec's computed FCS compared byte-for-byte against lm::ax25::compute_crc — both produce identical CRC-16-CCITT values
testBadFcs Single bit-flip in a valid frame produces a frame candidate with FCS failure
testTwoConsecutiveFrames Two frames with correct NRZI continuity both decode with valid FCS
testPreambleCount Preamble flag counter reflects the encoded preamble length
testReset After decoding, reset() returns to Searching state; same bitstream decodes again cleanly
testLongPayload 256-byte payload exercises the frame buffer boundary

The testFcsMatchesLibmodem test is the key cross-check called out in the #2701 plan — it proves HdlcCodec and libmodem compute the same CRC-16-CCITT over identical frame bytes, ruling out the polynomial/init/final-XOR mismatch the plan specifically flagged as a risk.

tests/ax25_libmodem_shim_test — 120 tests, all pass. Covers the full shim end-to-end: AFSK loopback (HF 300 and VHF 1200), TX/RX round-trips, KISS framing, diagnostics, and bad-FCS detection. This suite exercises HdlcCodec through the shim integration, confirming the Phase 1b replacement did not change observable behaviour.

Live VHF decode confirmed on 144.390 MHz on macOS (Apple Silicon M2), Raspberry Pi 5, and Windows 11.


Built and smoke tested on: MacBook Air M2 / RPi 5 Debian trixie / Windows 11 on Intel i3


Happy to split into separate PRs (demodulator / worker thread / HdlcCodec) if that makes review easier — the three areas are independent once the lane-management interface is stable.

…1a+1b

Squashed history (oldest → newest):
- feat(tnc): AX.25 worker thread, AFSK profiles A/B/D, HdlcCodec Phase 1a
- fix(tnc): repair merge artifacts from branch squash/combination
- refactor(tnc): remove phase diversity — extracted to feat/phase-diversity
- fix(afsk): MSVC compatibility for direwolf_afsk demodulators
- feat(tnc): HdlcCodec Phase 1b — integrate into AX.25 shim

Worker thread:
- AetherAx25LibmodemShim runs entirely on a dedicated QThread (m_shimThread)
  in Ax25HfPacketDecodeDialog; all cross-thread calls use Qt::QueuedConnection

AFSK demodulator (third_party/direwolf_afsk):
- Profile A: original AetherAFSKDemod (9-correlator)
- Profile B: 9-correlator variant with wider tracking bandwidth
- Profile D: FM-discriminator demod (AetherFMDiscrimDemod)
- Mode selector: Off / A / B / D / AB / AB+ (A×9 + B×9)
- Phase diversity removed; checkbox and AppSettings key deleted
  (extracted to separate feat/phase-diversity branch for independent review)

HdlcCodec (Phase 1a — src/core/tnc/HdlcCodec.{h,cpp}):
- Pure C++, no Qt, no libmodem; replaces lm::ax25::bitstream_state
- NRZI decode, bit stuffing, CRC-16-CCITT (0x8408), LSB-first byte assembly
- State machine: Searching → InPreamble → InFrame → Complete/Aborted
- 27/27 unit tests in tests/hdlc_codec_test.cpp

HdlcCodec Phase 1b — integrated into AetherAx25LibmodemShim:
- DecodeLane: bitstreamState + bitstreamBuffer + candidateFrameBytes
  replaced by hdlcCodec (HdlcCodec)
- processBit(): try_decode_bitstream replaced by hdlcCodec.processBit()
- resetLaneBitstream(): calls hdlcCodec.reset()
- Diagnostics: lane.hdlcCodec.searching()/inFrame()/etc.
- libmodem kept for: try_decode_frame_no_fcs (AX.25 structure), TX path

MSVC compatibility:
- direwolf_afsk demodulators: replaced GNU __builtin_expect with
  standard [[likely]]/[[unlikely]]; fixed VLA → std::vector
@K5PTB K5PTB requested review from a team as code owners June 8, 2026 13:23
…lf_afsk

std::cosf, std::sinf, std::fabsf, std::hypotf, std::atan2f are not
guaranteed members of std:: on all compilers (GCC/Linux CI). Replace
with std::cos, std::sin, std::fabs, std::hypot, std::atan2, which
C++11 <cmath> overloads for float and returns float — no behavior change.

Fixes CI build failure on feat/ax25-hdlc-thread PR aethersdr#3466.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@jensenpat jensenpat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi Paul, I'm excited to have you work on the AX.25 stack. I want to help you get these changes merged. A couple of things we need from you on this:

  1. We will need you to unsquash this PR and submit the threading, profile and codec work separately as standalone PRs.
  2. These should include individual test plans, and should support our Qt backend where it touches the UI.
  3. These PRs will need defensive testing across Windows, macOS and Linux, especially the threading model.
  4. Let's look at how we can make profile selection more user friendly for non-technical users. Can we pick this highest performing model and not expose the complexity to the end user? How can we make that easier?
  5. Because this now involves two AFSK demodulator stacks with different performance profiles across HF and VHF, there will be added maintenance complexity. Is this something you are considering maintaining after it is committed?

Let me know how I can help you with this, or if you have any questions.

@K5PTB

K5PTB commented Jun 8, 2026

Copy link
Copy Markdown
Contributor Author

@jensenpat

I can maintain the VHF demod. But, I don't have the ability to test the HF demod, so I left it alone. My primary goal was to improve the performance on VHF, because I found libmodem was falling short. However, I don't know who uses AX.25 on HF now, as VARA seems to be the most popular.. but that's not cross-platform.

I've done testing across all of the platforms, verifying the codec works. I just did the comparison testing on the Raspberry Pi, as I could let it run for long periods of time on an RPi I don't use for anything else. I could do a similar comparison between different platforms to validate they are all performing the same. I would need to add a payload field to the MQTT publish topic to identify the hostname originating the message.

I'm not sure how to address the profile selection. Most will be happy with A+, as it matches Direwolf's default selection. Frankly, I didn't even know about the others until I started digging into the code. Do we drop all the profiles completely, or leave them in the code but preselect A+ and hold the others in reserve for the future, or move the setting somewhere else? If we decide to keep the selector, I will put together a Qt test.

I will split these into separate features and PRs. Do you have a preferred ordering? I suggest:

  1. Threading (that makes next one easier)
  2. Codec (includes all profiles, but A+ selected)
  3. Profile selector, if we decide to keep it.

@jensenpat

jensenpat commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

@K5PTB I support your order proposal.

I'll look to your guidance on profile simplification because I know you have a test harness and findings built for this already. Love to see you document your test setup as well, but you can save that for a rainy day. Let's see if we can get this merge done and then we can do an APRS bring up.

Thank you for the hard work on this!

@jensenpat jensenpat marked this pull request as draft June 8, 2026 15:04
@K5PTB

K5PTB commented Jun 8, 2026

Copy link
Copy Markdown
Contributor Author

@jensenpat I'm not sure how to do this administratively: Should I make this PR only for the separate thread, then submit a new PR for the codec? I'd like to get these in, I will hold back the profile selector until we decide on a direction for that.

My comparator is a Python script. Setting it all up on Linux is a bit fiddly, but I have that documented. I can put it into the tests directory, unless you want me to convert it to C++ first.

@jensenpat

Copy link
Copy Markdown
Collaborator

@K5PTB Go ahead and close this one out, ask your agent to split the commits with new standalone PRs for threading, codec and profile selector.

It knows which commit IDs are tied to which purposes locally in your existing conversation. Then have it prepare a detailed PR for each with a test plan. You already have commit signing in place, so that's great.

You can also reference and feed CONTRIBUTING.md to your agent, specifically see items 3-7.

@K5PTB

K5PTB commented Jun 8, 2026

Copy link
Copy Markdown
Contributor Author

Closing in favor of the split PR approach per @jensenpat's direction. Replacing with separate focused PRs: feat/ax25-worker-thread (threading), feat/ax25-hdlc-codec (HDLC codec), feat/ax25-direwolf-demod (Direwolf AFSK profiles), and feat/ax25-direwolf-profiles (VHF mode selector UI). Each will have a full description and test plan per CONTRIBUTING.md.

@K5PTB K5PTB closed this Jun 8, 2026
jensenpat pushed a commit that referenced this pull request Jun 8, 2026
## Summary

First of three focused PRs extracted from #3466 per @jensenpat's review
guidance. Lays the threading groundwork for #2701 (Native Soft-TNC +
APRS Integration) — the shim thread boundary is required before a more
capable demodulator can be dropped in without blocking the GUI.

## Motivation

`AetherAx25LibmodemShim` ran on the GUI thread. Audio callbacks, HDLC
decode, and profile configuration all competed with paint events and
user interaction, making it impossible to later add a CPU-heavier
demodulator without degrading responsiveness.

## Changes

- `AetherAx25LibmodemShim` moved to a dedicated `QThread`
(`m_shimThread`)
- All GUI→shim calls use `QMetaObject::invokeMethod(...,
Qt::QueuedConnection)`
- `m_shimConfig` cache added to dialog — GUI thread never reads
`m_impl->config` directly
- Free functions extracted: `ax25DemodDescription`,
`ax25BuildTransmitAudio`, `ax25BuildTransmitAudioFromFrame`
- `startTransmit(const QString&)` extracted from `startTransmitFromUi`
- Destructor: `m_shimThread.quit(); m_shimThread.wait()`
- **ASAN fix (`b495dac9`):** `MainWindow::~MainWindow()` now explicitly
deletes `m_ax25HfPacketDecodeDialog` before AudioEngine teardown.
Pre-existing UAF: dialog destructor called
`m_audio->setTncRxTapEnabled(false)` via raw pointer after AudioEngine
was freed via `deleteLater()`. Same pattern as the existing
`m_networkDiagnosticsDialog` fix in that destructor. Found on RPi 5 with
ASAN; confirmed clean after fix.

## Test plan

Tested on all three target platforms.

1. **Basic decode** — enabled modem on 144.390 MHz APRS; frames appeared
in the log within seconds; disable/re-enable confirmed clean reset with
no stale state.
2. **Profile switching under load** — switched HF↔VHF while audio was
flowing, including rapid successive switches; no crash, new profile
description appeared correctly each time.
3. **TX path** — transmitted a UI frame; confirmed key, send, and unkey
with no crash or hang. KISS frame sent via `kiss_test.py` also keyed
cleanly, exercising `ax25BuildTransmitAudioFromFrame` as a free
function. **Hardware note:** transverter attenuated to prevent RF output
— full software TX path exercised but no RF generated; iGate/APRS-IS
verification deferred pending hardware modification.
4. **Clean shutdown** — closed dialog while modem was enabled; no hang
or crash; reopened and re-initialized correctly.
5. **Extended run** — modem left running 10+ minutes on a busy APRS
channel; no crashes, no memory growth, GUI remained responsive.
6. **ASAN (RPi 5)** — Debug+ASAN build clean after fix; no new leaks or
UAF reported.

**RX sensitivity note:** A single decode was achieved with an HT at
close range. Consistent decode is limited by libmodem's single-slicer
VHF sensitivity — a known pre-existing limitation addressed in the third
PR in this series (Direwolf demodulator). This PR does not regress
decode behavior.

**300-baud HF decode** — not tested on-air; no 300-baud packet
infrastructure available in the test environment.

---

Built and smoke tested on: MacBook Air M2 / RPi 5 Debian trixie / Win 11
i3

Extracted from #3466 (closed).

**Note:** The follow-on PR introducing `HdlcCodec`
([feat/ax25-hdlc-codec](https://github.com/K5PTB/AetherSDR/tree/feat/ax25-hdlc-codec))
is ready but will be held until this PR merges, so it can be opened
cleanly against `main`.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
jensenpat pushed a commit that referenced this pull request Jun 9, 2026
… HDLC framer (#3475)

## Summary

Second of three focused PRs extracted from #3466 per @jensenpat's review guidance, implementing part of the design laid out in #2701 (Native Soft-TNC + APRS Integration). Introduces `HdlcCodec`, a self-contained pure-C++ HDLC framer with CRC-16-CCITT, and wires it into the VHF 1200-baud decode lane in place of libmodem's `bitstream_state`. libmodem is retained for HF demodulation and AX.25 frame parsing.

Also addresses non-blocking review notes 1 and 2 from #3473: lane count restored in `ax25DemodDescription()` via `ax25DemodLaneCount()`, and two explanatory comments restored in `ax25BuildTransmitAudioFromFrame`.

## Motivation

libmodem's `bitstream_state` is an opaque internal struct — not unit-testable in isolation and not replaceable without forking libmodem. Extracting HDLC framing into `HdlcCodec` makes the state machine independently testable, and establishes the boundary where the Direwolf demodulator (PR 3) will plug in.

## Changes

- **New:** `src/core/tnc/HdlcCodec.h` / `HdlcCodec.cpp` — pure C++ HDLC bit-unstuffing and CRC-16-CCITT framer
- **New:** `tests/hdlc_codec_test.cpp` — 27 tests covering state transitions, FCS validation cross-checked against libmodem, two-frame decode, long payload, and reset
- `CMakeLists.txt`: added `HdlcCodec.cpp` to main build and shim test; added `hdlc_codec_test` target
- `AetherAx25LibmodemShim`: `DecodeLane` replaces `bitstreamState` with `HdlcCodec hdlcCodec`
- `ax25DemodLaneCount()` free function added; `ax25DemodDescription()` now includes lane count suffix
- Two explanatory comments restored in `ax25BuildTransmitAudioFromFrame` (minimum-frame-size invariant; host-omits-FCS note)

Squashed-from: #3475

Co-authored-by: Paul <274291579+K5PTB@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: jensenpat <84298137+jensenpat@users.noreply.github.com>
@K5PTB K5PTB deleted the feat/ax25-hdlc-thread branch June 10, 2026 05:14
G6PWY-Chris pushed a commit to G6PWY-Chris/AetherSDR that referenced this pull request Jun 22, 2026
## Summary

First of three focused PRs extracted from aethersdr#3466 per @jensenpat's review
guidance. Lays the threading groundwork for aethersdr#2701 (Native Soft-TNC +
APRS Integration) — the shim thread boundary is required before a more
capable demodulator can be dropped in without blocking the GUI.

## Motivation

`AetherAx25LibmodemShim` ran on the GUI thread. Audio callbacks, HDLC
decode, and profile configuration all competed with paint events and
user interaction, making it impossible to later add a CPU-heavier
demodulator without degrading responsiveness.

## Changes

- `AetherAx25LibmodemShim` moved to a dedicated `QThread`
(`m_shimThread`)
- All GUI→shim calls use `QMetaObject::invokeMethod(...,
Qt::QueuedConnection)`
- `m_shimConfig` cache added to dialog — GUI thread never reads
`m_impl->config` directly
- Free functions extracted: `ax25DemodDescription`,
`ax25BuildTransmitAudio`, `ax25BuildTransmitAudioFromFrame`
- `startTransmit(const QString&)` extracted from `startTransmitFromUi`
- Destructor: `m_shimThread.quit(); m_shimThread.wait()`
- **ASAN fix (`b495dac9`):** `MainWindow::~MainWindow()` now explicitly
deletes `m_ax25HfPacketDecodeDialog` before AudioEngine teardown.
Pre-existing UAF: dialog destructor called
`m_audio->setTncRxTapEnabled(false)` via raw pointer after AudioEngine
was freed via `deleteLater()`. Same pattern as the existing
`m_networkDiagnosticsDialog` fix in that destructor. Found on RPi 5 with
ASAN; confirmed clean after fix.

## Test plan

Tested on all three target platforms.

1. **Basic decode** — enabled modem on 144.390 MHz APRS; frames appeared
in the log within seconds; disable/re-enable confirmed clean reset with
no stale state.
2. **Profile switching under load** — switched HF↔VHF while audio was
flowing, including rapid successive switches; no crash, new profile
description appeared correctly each time.
3. **TX path** — transmitted a UI frame; confirmed key, send, and unkey
with no crash or hang. KISS frame sent via `kiss_test.py` also keyed
cleanly, exercising `ax25BuildTransmitAudioFromFrame` as a free
function. **Hardware note:** transverter attenuated to prevent RF output
— full software TX path exercised but no RF generated; iGate/APRS-IS
verification deferred pending hardware modification.
4. **Clean shutdown** — closed dialog while modem was enabled; no hang
or crash; reopened and re-initialized correctly.
5. **Extended run** — modem left running 10+ minutes on a busy APRS
channel; no crashes, no memory growth, GUI remained responsive.
6. **ASAN (RPi 5)** — Debug+ASAN build clean after fix; no new leaks or
UAF reported.

**RX sensitivity note:** A single decode was achieved with an HT at
close range. Consistent decode is limited by libmodem's single-slicer
VHF sensitivity — a known pre-existing limitation addressed in the third
PR in this series (Direwolf demodulator). This PR does not regress
decode behavior.

**300-baud HF decode** — not tested on-air; no 300-baud packet
infrastructure available in the test environment.

---

Built and smoke tested on: MacBook Air M2 / RPi 5 Debian trixie / Win 11
i3

Extracted from aethersdr#3466 (closed).

**Note:** The follow-on PR introducing `HdlcCodec`
([feat/ax25-hdlc-codec](https://github.com/K5PTB/AetherSDR/tree/feat/ax25-hdlc-codec))
is ready but will be held until this PR merges, so it can be opened
cleanly against `main`.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
G6PWY-Chris pushed a commit to G6PWY-Chris/AetherSDR that referenced this pull request Jun 22, 2026
… HDLC framer (aethersdr#3475)

## Summary

Second of three focused PRs extracted from aethersdr#3466 per @jensenpat's review guidance, implementing part of the design laid out in aethersdr#2701 (Native Soft-TNC + APRS Integration). Introduces `HdlcCodec`, a self-contained pure-C++ HDLC framer with CRC-16-CCITT, and wires it into the VHF 1200-baud decode lane in place of libmodem's `bitstream_state`. libmodem is retained for HF demodulation and AX.25 frame parsing.

Also addresses non-blocking review notes 1 and 2 from aethersdr#3473: lane count restored in `ax25DemodDescription()` via `ax25DemodLaneCount()`, and two explanatory comments restored in `ax25BuildTransmitAudioFromFrame`.

## Motivation

libmodem's `bitstream_state` is an opaque internal struct — not unit-testable in isolation and not replaceable without forking libmodem. Extracting HDLC framing into `HdlcCodec` makes the state machine independently testable, and establishes the boundary where the Direwolf demodulator (PR 3) will plug in.

## Changes

- **New:** `src/core/tnc/HdlcCodec.h` / `HdlcCodec.cpp` — pure C++ HDLC bit-unstuffing and CRC-16-CCITT framer
- **New:** `tests/hdlc_codec_test.cpp` — 27 tests covering state transitions, FCS validation cross-checked against libmodem, two-frame decode, long payload, and reset
- `CMakeLists.txt`: added `HdlcCodec.cpp` to main build and shim test; added `hdlc_codec_test` target
- `AetherAx25LibmodemShim`: `DecodeLane` replaces `bitstreamState` with `HdlcCodec hdlcCodec`
- `ax25DemodLaneCount()` free function added; `ax25DemodDescription()` now includes lane count suffix
- Two explanatory comments restored in `ax25BuildTransmitAudioFromFrame` (minimum-frame-size invariant; host-omits-FCS note)

Squashed-from: aethersdr#3475

Co-authored-by: Paul <274291579+K5PTB@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: jensenpat <84298137+jensenpat@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants