feat(modem): Personal Mailbox System (PMS) over connected-mode AX.25#3290
feat(modem): Personal Mailbox System (PMS) over connected-mode AX.25#3290jensenpat wants to merge 5 commits into
Conversation
Add a Kantronics-style Personal Mailbox System to AetherModem. A single remote caller can connect over 1200-baud AX.25 connected mode to read, list, and send messages, list stations heard, and disconnect; a new Mailbox config tab and an hourly beacon are included. Built on aethersdr#3279. This required AX.25 v2.0 connected mode (LAPB), which the modem lacked (it only did connectionless UI/APRS). New reusable, RF-agnostic layers: - src/core/tnc/Ax25.{h,cpp}: Address + Frame parse/build (I, RR/RNR/REJ, SABM, DISC, DM, UA, FRMR, UI), mod-8, command/response C-bits. - src/core/tnc/Ax25Connection.{h,cpp}: single-connection data-link state machine (SABM->UA, V(S)/V(R)/V(A), RR acks, I-frame segmentation, T1 retransmit up to N2, REJ/RNR/DISC). Tuned for 1200-baud FM + PTT. - src/core/pms/PmsMailbox.{h,cpp}: the mailbox service (greeting, command interpreter, JSON store, heard list, beacon) in one file pair. GUI: a Mailbox tab in Ax25HfPacketDecodeDialog (enable, answer SSID, welcome/PTEXT, hourly beacon, last 5 callers, stats); settings persist in AppSettings; outbound frames share the existing PTT/DAX keying queue. Tests: pms_mailbox_test (Qt6::Core) covers frame codec round-trips, the connection handshake, and a full mailbox session; isolated via AETHER_PMS_DIR so it is repeatable and never touches a real mailbox. The connected-mode protocol layer is unit-tested; on-air RF validation at 1200 baud against a real TNC is the top follow-up (see docs/MODEM.md). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
8cea523 to
d1aafe0
Compare
|
UI iteration pass (commit
|
…tats/callers split Iterate on the Mailbox tab per review: - Replace the answer-SSID spinbox with two free-text callsign fields and no defaults: a full LISTEN CALLSIGN (e.g. KI6BCJ-10) and an optional VANITY ALIAS. AX.25 limits a callsign to 6 characters plus an optional -SSID, so the alias must be <= 6 chars (e.g. AETBBS); the field placeholder/tooltip says so. The mailbox answers on either address; the one the caller dialed is used for the whole session (UA, greeting, replies). - Lay Statistics on the left and Last Callers on the right as separate, evenly-sized panels in one row. - Remove the "A single remote caller..." help paragraph. - Collapse MODEM STATUS / GAIN STAGE / PACKET ACTIVITY into one slim inline status bar with a compact activity strip instead of three tall panels. Model: Ax25Connection gains an optional alias address (setAliasAddress) and matches inbound frames against primary-or-alias while idle, latching onto the dialed address for the session. PmsMailbox swaps base-call+SSID for setListenCallsign()/setAliasCallsign() (parsed ax25::Address) and hasValidAddress(); onAirFrame lets the link do address matching. Settings: AetherModemPmsListenCallsign / AetherModemPmsAliasCallsign replace AetherModemPmsSsid. pms_mailbox_test gains an alias-dial case (UA + greeting sent from the alias; unrelated callsigns ignored) and address-length checks. Verified: full AetherSDR app builds clean (Ninja, macOS) and pms_mailbox_test passes on repeated runs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3172890 to
42c0e22
Compare
…e length gate Found while troubleshooting a live PMS connect failure (a caller's TNC connect frames were never answered). Replaying the operator's "Capture 3m" recording through the decoder, then adding a SABM AFSK loopback test, isolated a real decoder bug — separate from the original no-audio symptom. try_decode_frame() in third_party/libmodem_core/bitstream.h rejected any frame < 18 bytes, but the shortest valid AX.25 frame WITH FCS is 17: 14 address + 1 control + 2 FCS, no PID/info. That is exactly every connected-mode U-frame (SABM, DISC, UA, DM). So the decoder silently dropped all connect/disconnect/ack frames — a PMS/BBS could never be reached in connected mode even with perfect audio. UI/APRS frames carry a PID byte (>= 18), which is why connectionless decode always worked. The sibling try_decode_frame variant already used the correct < 15 (no-FCS) minimum. Fix: gate < 18 -> < 17, and align the shim's reject-classifier threshold to match. Diagnostics added alongside: - tools/ax25_replay.cpp: offline tool that replays a captured mono-float32 WAV through the decoder, sweeping both tone polarities, printing decoded frames and reject counters. Built on demand (EXCLUDE_FROM_ALL). - ax25_libmodem_shim_test: testSabmConnectFrameLoopbackDecodes builds a real SABM, renders it through the AFSK modem, and asserts it decodes back as FrameType::SABM with the right addresses. Regression guard for the gate. - PmsMailbox::onAirFrame logs every decoded frame with the listen/alias address-match decision, so a future connect shows decode-vs-mismatch at a glance on aether.ax25. Verified: ax25_libmodem_shim_test (incl. 10 SABM assertions) passes deterministically over repeated runs; pms_mailbox_test passes; full app and the replay tool build clean on macOS. Replaying the original noise-only capture decodes 0 frames across both polarities (no false-positive regression). Note: the operator's specific capture contained no packet keyups at all (flat ~-23 dBFS hiss), so their immediate issue is RX-audio routing/level — but this bug would have blocked the connect regardless once audio is fixed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
42c0e22 to
beb73ae
Compare
Root cause found: connect frames (SABM) were silently dropped by an off-by-one length gateTroubleshooting a live PMS connect failure (caller's TNC connect frames never answered) surfaced a real decoder bug, independent of the original no-audio symptom. The bug: How it was found / tooling added:
Verified: shim test (incl. 10 SABM assertions) + pms test pass deterministically; app + replay build clean. Operator note (separate issue)The capture you provided contained no packet keyups at all — flat ~−23 dBFS hiss for the full 180 s, only ~10 dB dynamic range, zero bad-FCS candidates across 21k HDLC false-triggers. So the connect audio wasn't reaching the modem. That points at the RX audio path, not this bug. Worth checking: the slice is actually receiving on the packet freq, squelch open, PC/DAX RX audio routed to AetherModem, and the BTECH is keying on the same frequency/tone. Once audio flows, this fix is what lets the connect actually complete. |
…emitted signal ax25_replay connected to AetherAx25LibmodemShim::frameDecoded and counted that signal — but processMonoFloat() (which the tool calls) does not emit it; only feedAudio() does. So the tool always printed 'decoded frames: 0' even when the diagnostics showed accepted frames, which masked the real SABM decodes in a live capture. Count the processMonoFloat() return value instead, and print fcsOk and proper U/S/I frame-type names. Verified against the operator capture: now correctly reports 18 decoded SABM connect frames (KI6BCJ>KI6BCJ-10, fcsOk=1). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ll in a T1 loop First live connect worked, but a multi-I-frame reply (the LIST command) stalled: the data displayed correctly on the caller's TNC, yet our side never saw an acknowledgement and retransmitted until N2 link failure. A single-frame reply (INFO) acked fine. Log (aether.ax25) showed the cause: the LIST reply went out as three I-frames back-to-back (NS=1,2,3), each its own PTT keyup, then 8 retransmits with ZERO received frames in between, then link failure. INFO was one frame followed by a clean listen window, and its RR arrived 520 ms later. On a half-duplex radio the back-to-back keyups (and the long 3-frame retransmit bursts) keep us transmitting while the peer's ack arrives, so we are deaf to it every cycle. Fix: default the send window to 1 (MAXFRAME=1) — one unacknowledged I-frame in flight at a time, so each frame is a solo keyup followed by a listen window for its ack before the next goes out. This is exactly the INFO pattern that works, repeated per frame. kWindow (was constexpr 4) becomes a configurable m_window (setWindow(), qBound 1..7) so a future single-keyup multi-frame TX path or a full-duplex transport can raise it. Regression test (pms_mailbox_test): drive Ax25Connection with a 300-byte payload (3 I-frames at paclen 128) and a peer that acks each with a standalone RR; assert only one I-frame is in flight at a time, frames advance one-per-ack, and each N(S) is transmitted exactly once (no duplicate/retransmit storm). Would have caught this. Diagnosis confidence: the half-duplex-deafness root cause is inferred from the logs (multi-frame burst -> zero RX -> loop; single frame -> ack heard) and is consistent, but not yet confirmed on the air. window=1 strictly cannot regress vs the old k=4 here and matches the proven INFO path. On-air retest of LIST is the confirmation. Verified: pms_mailbox_test (incl. 8 new window assertions) and ax25_libmodem_shim_test pass; full app builds clean on macOS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
b698de8 to
25c9db3
Compare
✅ Validated over the airTested end-to-end on real hardware — BTECH UV-Pro + PacketCommander (iOS TNC), 1200-baud 2 m FM:
Fixes landed since this PR opened (each with a regression test)
The PR description has been updated with the window=1 rationale, the listen+vanity-alias callsign UI, and the OTA results. |
|
@jensenpat — heads up: this PR was auto-closed as a side effect of my admin-merging #3279 with `--delete-branch`. The base ref `aether/kiss-tnc-ux` was deleted on merge, which GitHub then took as a signal to auto-close every PR stacked on it. The closure was not intentional and is not a rejection of the PMS work. The good news: your stacking design has the PMS commits preserved inside #3381's cumulative diff per your own note there. So the work isn't lost — it'll naturally surface when #3381 rebases against current main now that #3279 has landed. If you'd prefer to keep the PMS work in its own PR (separate from the terminal client), feel free to reopen this against `main` and rebase — the branch `aether/pms-mailbox` should still exist with all commits intact. Otherwise the cumulative #3381 path also works. Sorry for the merge-mechanics caused noise. Tracking the deeper KissTncServer ownership refactor in #3424. |
…1200-baud packet BBS Adds a "Terminal" tab to AetherModem: a connected-mode AX.25 client that calls a VHF packet BBS, reads/sends messages, and disconnects, sharing the data-link machinery (Ax25Connection) with the PMS mailbox rather than duplicating it. Connection state machine (client role added to the shared Ax25Connection): - Outbound connectTo() with SABM/UA handshake, N2 retries, optional VIA digipeater path. - Lost-UA adoption: an inbound I/RR/RNR/REJ received while still connecting is treated as proof the UA was lost, and the link is adopted. - T2 deferred-ack: on a half-duplex link, defer an unpolled ack so we don't key the radio mid-burst and go deaf to the rest of the peer's window. - Silent reject-exception: send exactly one REJ per sequence gap, then listen — even on polled retransmits — to break the half-duplex REJ phase-lock that stalled multi-frame replies (e.g. a long BBS help menu). - REJ recovery resends the outstanding I-frames from the store (the old path rewound V(S) and called pumpOutbound() on an already-drained buffer, so it resent nothing and the link silently desynced). - ackUpTo() ignores an out-of-range N(R) instead of corrupting the send window. - Poll on the window-filling I-frame so the peer acknowledges promptly. - Per-session telemetry counters (I sent/resent/rcvd/dropped, RR/REJ/RNR in & out, T1 timeouts, T2 acks, FRMR, ignored bad-N(R)). Terminal UI + commands: - CONNECT [VIA digi...], BYE/DISC, CONV, STATUS, MHEARD, MYCALL, LOG, ESCAPE, HELP. - Monospace transcript, Up/Down command history, right-click Clear / Command Mode. - Quick-connect dropdown from a shared HeardList; timestamped session logging; last-called BBS persisted across restarts. - Tunable T1 / N2 / paclen and a tunable TX tail (half-duplex turnaround); live drop/resent readout in the status line. - Auto-enables the modem RX tap when a connect is initiated. Shared / refactor: - New HeardList class backing MHEARD and quick-connect (reusable by PMS and a future digipeater). - FramelessWindowTitleBar: min/max/close buttons are no longer the dialog's default button, so pressing Return in a text field no longer minimizes the window (macOS) in any AetherModem field. Tooling / tests: - tools/ax25_session_analyze: replays a capture WAV through the real decoder AND the real state machine to surface sequencing / timer / retry gaps. - tests/tnc_terminal_test: connect/converse/disconnect, VIA, MHEARD, lost-UA adoption, multi-frame deferred ack, REJ resend, reject-exception storm suppression, invalid-N(R) guard, CONV/STATUS. Stacked on aethersdr#3279 (KISS-over-TCP TNC + AetherModem UX) and aethersdr#3290 (PMS over connected-mode AX.25); this branch includes both until they merge. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Codex <noreply@openai.com>
…bmodem patch Three small fix-ups to bring aethersdr#3381 to a mergeable state. These were flagged by the AetherClaude bot review on the PR; the rebase that recovered the PMS work after aethersdr#3290's cascade-close was mechanical and didn't address the review items, so picking them up here. 1. HeardList: debounce save() + warn on persistence failure. record() previously called save() synchronously on every decoded frame — including channel chatter the operator isn't connected to. On a busy APRS frequency or shared HF channel that churns the disk per beacon for no observer-visible benefit (MHEARD doesn't refresh that fast and the right-click → Clear path is unchanged). Now record() calls scheduleSave(), which restarts a 2 s single-shot QTimer; bursts of frames collapse into one rewrite. The destructor flushes a pending save so a clean shutdown doesn't lose the most recent records. clear() stays synchronous because the operator expects the file to be empty when the menu action returns. Separately, save() now emits qCWarning(lcAx25) if QFile::open() fails — the silent-ignore made a failed write invisible. tnc_terminal_test gains LogManager + AsyncLogWriter + AppSettings in its sources so the lcAx25 symbol resolves at link time. 2. Ax25Connection: document the lost-UA fall-through invariant. The lost-UA recovery block calls enterConnected() (which resets V(R)=V(S)=V(A)=0) and then falls through to the switch where the I/RR/RNR/REJ handlers run against the freshly-reset state. That works because the peer's first post-UA-loss I-frame is N(S)=0 = freshly-reset V(R), and ackUpTo() with V(A)=V(S)=0 walks zero slots for any legal N(R). Both invariants are load-bearing — a future MAXFRAME>1 path that pre-allocates V(R) would silently drop the first I-frame as out-of-sequence. Expanded the comment so the next person touching enterConnected()'s reset block sees the coupling. 3. third_party/libmodem_core/README.aethersdr.md: track the off-by-one patch so the next upstream refresh doesn't silently revert it. bitstream.h's `try_decode_frame()` gate was relaxed from `frame_size < 18` to `< 17` in this PR (a no-PID U-frame is exactly 17 bytes; the old gate dropped every SABM/DISC/UA/DM and made connected mode impossible). README now has a "Local patches against upstream" section listing the change with a pointer back to aethersdr#3381, and the refresh-notes checklist gains "re-apply each entry" as step 4. Also flagged for upstreaming — single-byte off-by-one that bites any libmodem caller doing connected mode, not just AetherSDR. Verified locally on Arch Linux x86: - tnc_terminal_test (12 cases) green - pms_mailbox_test green - ax25_libmodem_shim_test green - Full app build clean (629/629) Principle XI.
…packet BBS (#3381) <img width="1316" height="1065" alt="Screenshot 2026-06-03 at 2 17 21 PM" src="https://github.com/user-attachments/assets/f09d274d-ec49-4dee-8d44-a2e5e737287c" /> ## Summary Adds a **Terminal** tab to AetherModem: a built-in **connected-mode AX.25 client** for calling a 1200-baud VHF packet BBS — connect, read/send messages, and disconnect — with reliable error correction over a half-duplex link. It reuses the existing data-link state machine (`Ax25Connection`) shared with the PMS mailbox rather than duplicating it. > ✅ Verified on the air against a live BBS (SJVBBS-1): reads and sends messages, retries hold, no session drops or hangs. ## Stacked on #3279 and #3290 This branch is built on top of two open PRs and **includes their commits** until they merge: 1. **#3279** — feat(modem): KISS-over-TCP TNC + AetherModem UX overhaul 2. **#3290** — feat(modem): Personal Mailbox System (PMS) over connected-mode AX.25 *(stacked on #3279)* Merge order should be **#3279 → #3290 → this PR**. The diff here will shrink to just the terminal work once the parents land; happy to rebase on request. (#3290 lives on the `jensenpat` fork, so an upstream PR can't base on it directly — hence targeting `main` with this note.) ## What's new **Terminal tab** — connected-mode AX.25 client (the calling-side counterpart of the PMS mailbox's answering side): - Commands: `CONNECT <call> [VIA <digi>,…]`, `BYE`/`DISC`, `CONV`, `STATUS`, `MHEARD`, `MYCALL`, `LOG`, `ESCAPE`, `HELP`. - Two-mode TNC model: command prompt vs. converse mode, with a configurable escape character (`~`) and a `CONV` command to return. - Monospace transcript, Up/Down command history, right-click **Clear** / **Command Mode**. - Quick-connect dropdown from a shared heard list; timestamped session logging; last-called BBS persisted across restarts. - Tunable **T1 / N2 / paclen** and a tunable **TX tail** (half-duplex turnaround); live **drop/resent** counters in the status line. - Auto-enables the modem RX tap when a connect is initiated. ## Connection / error-correction work (shared `Ax25Connection`) The client role and a series of half-duplex reliability fixes, each found by analyzing on-air captures through a new replay harness: - **Outbound `connectTo()`** with SABM/UA, N2 retries, and an optional **VIA digipeater path**. - **Lost-UA adoption** — an inbound `I`/`RR`/`RNR`/`REJ` while still connecting is treated as proof the UA was lost; adopt the link (connect went live but no data otherwise). - **T2 deferred-ack** — defer an unpolled ack so we don't key the radio mid-burst and go deaf to the rest of the peer's window (multi-frame menus were stalling). - **Silent reject-exception** — send exactly one REJ per gap, then listen, even on polled retransmits; breaks the half-duplex REJ phase-lock that kept us deaf to the retransmission we were asking for. - **REJ recovery** resends outstanding I-frames from the store (the old path resent nothing and silently desynced). - **`ackUpTo()` guard** — ignore an out-of-range N(R) instead of corrupting the send window. - **Poll-on-window-fill** so the peer acknowledges promptly. - **Per-session telemetry** — sent/resent/received/dropped I-frames, RR/REJ/RNR in & out, T1 timeouts, T2 acks, FRMR, ignored bad-N(R) — surfaced via `STATUS` and the live readout. ## Shared / refactor - **`HeardList`** — new class backing `MHEARD` and quick-connect (SSID, last-heard, last beacon; JSON-persisted). Reusable by PMS and a future digipeater. - **`FramelessWindowTitleBar`** — title-bar min/max/close buttons are no longer the dialog's default button, so pressing **Return** in a text field no longer minimizes the window on macOS (was happening in every AetherModem field). ## Tooling & tests - **`tools/ax25_session_analyze`** — replays a capture WAV through the *real* decoder **and** the *real* state machine, printing each RX frame, our reaction, V(R) transitions, and flagging out-of-sequence drops / REJ storms. This is how each protocol bug above was pinned down empirically. - **`tests/tnc_terminal_test`** — connect/converse/disconnect, VIA path, MHEARD, lost-UA adoption, multi-frame deferred ack, REJ resend, reject-exception storm suppression, invalid-N(R) guard, CONV/STATUS. PMS tests unaffected. ## Testing - `tnc_terminal_test`, `pms_mailbox_test`, `ax25_libmodem_shim_test` all green. - Full macOS app builds and links; manually verified on the air against a live BBS. 💻 Generated with Claude Code (Opus 4.8) with architecture by @jensenpat --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Codex <noreply@openai.com> Co-authored-by: Jeremy [KK7GWY] <kk7gwy@aethersdr.com>
Summary
Adds a Personal Mailbox System (PMS / PBBS) to AetherModem: a compact mailbox
that a single remote caller can connect to over
1200-baud AX.25 connected mode to read, list, and send messages, see who has
been heard, and disconnect — plus a new Mailbox config tab and an hourly
beacon. Built on top of #3279 (KISS-over-TCP TNC).
This required implementing AX.25 v2.0 connected mode (LAPB), which the modem
did not have before (it only did connectionless UI/APRS frames). The new
connected-mode layers are deliberately split out and reusable for the planned
APRS/AX.25 digipeater.
What's new
Reusable AX.25 primitives (RF-agnostic, unit-tested)
src/core/tnc/Ax25.{h,cpp}—Address(callsign/SSID encode/decode) andFrameparse/build for I, RR/RNR/REJ, SABM, DISC, DM, UA, FRMR, and UI frames(address..info, no FCS — the same convention as the KISS path and
buildTransmitAudioFromFrame). Handles the command/response C-bits and themod-8 control field.
src/core/tnc/Ax25Connection.{h,cpp}— a single-connection LAPB statemachine: accepts an inbound SABM (→ UA), tracks V(S)/V(R)/V(A), acknowledges
received I-frames with RR, segments outbound data into I-frames (≤ paclen), and
retransmits unacked I-frames on the T1 timeout up to N2 tries before
declaring link failure. Honours RR/RNR/REJ, poll/final, and DISC. One caller at
a time (extra SABMs get a polite DM). Defaults (T1 6 s, N2 8, paclen 128,
window 1) are sized for a half-duplex, PTT-keyed 1200-baud FM link — see
the connection notes below for why the window is 1.
The mailbox service
src/core/pms/PmsMailbox.{h,cpp}(one file pair, as requested) — owns theAx25Connection, greets a caller by callsign with the AetherMailbox SID andversion, runs a line-oriented command interpreter, maintains the heard
list (updated for all received frames so callers can find other PMS/BBS in
the area), and sends an optional hourly UI beacon announcing it is online
and how to connect. State persists as JSON under
~/.config/AetherSDR/pms/(messages.json,callers.json,heard.json;overridable via
AETHER_PMS_DIR). RF-agnostic: fed viaonAirFrame(), emitsframes on
transmitFrame().AetherModem Mailbox tab
CALL-SSID, e.g.KI6BCJ-10) and anoptional vanity alias the mailbox also answers on (e.g.
AETBBS; AX.25limits a callsign to 6 chars + optional
-SSID), a welcome / PTEXT line,the hourly-beacon toggle + text, and a stats row with Statistics on the
left and the last callers on the right. All settings persist in
AppSettings(AetherModemPms*keys) across restarts. Enabling the mailboxturns the modem on; outbound frames share the existing one-at-a-time PTT/DAX
keying/pacing path. The bottom of the window is a slim status bar (modem state,
gain, compact packet-activity strip).
Supported mailbox commands
KPC-3 subset (first letter or full word; case-insensitive):
H/?/HELPB/BYEI/INFOJ/JHEARDL/LISTLMR n/READ nK n/KILL nALLbulletins)S call/SP call/SEND callSB catALLfor everyone)U/USERSComposing: after
S/SP/SBthe mailbox promptsSUBJECT:, then collects bodylines until
/EXor Ctrl-Z on a line by itself. Address a message toALLfor a public/bulletin message.
AX.25 connection details to be aware of
the connectionless UI path the modem already had.
UA; an unexpected frame while disconnected gets DM. Only one caller at a
time — a second SABM from a different station is refused with DM.
(
CALL-SSID) and the optional vanity alias; the address the caller dialedis used for the whole session (UA, greeting, replies). Command/response sense is
carried in the two address C-bits (command: dest C=1, src C=0).
Received I-frames are acked with RR; out-of-sequence triggers REJ.
Unacked I-frames retransmit on T1 (6 s) up to N2 (8) tries, then the
link is declared failed (DM + disconnect). RNR pauses sending.
Sending several back-to-back keeps us transmitting (and deaf) long enough that
the caller's RR ack lands while we cannot hear it → a T1 retransmit loop. With
k=1 we send one frame, then listen for its ack before the next. This was the
fix for an observed live multi-line-reply stall (see fixes below).
setWindow()(1–7) leaves room for a future single-keyup multi-frame TX path or a
full-duplex transport.
queue (one transmission at a time), shared with the KISS TX path.
(
Ax25DecodedFrame::ax25FrameNoFcs), which is frame-type-aware (validates I/S/Uframes), so SABM/RR/I/DISC all reach the data link. (A connect-frame decode bug
was found and fixed during bring-up — see fixes below.)
src = PMS addr,dest = BEACON) every hour whileenabled, with customizable text plus a "connect to " hint.
Fixes during bring-up (post-initial-PR)
Validating against real hardware surfaced three real bugs, each fixed with a
regression test:
beb73ae). The bitstream decoder(
try_decode_frameinthird_party/libmodem_core/bitstream.h) rejected frames< 18bytes, but the shortest valid AX.25 frame with FCS is 17 (14address + 1 control + 2 FCS, no PID) — exactly every connected-mode U-frame
(SABM/DISC/UA/DM). So the mailbox could never be reached in connected mode even
with perfect audio. Gate corrected to
< 17. (UI/APRS frames carry a PID byteand are ≥ 18, which is why connectionless decode always worked.)
25c9db3). Default send windowwas 4, so a multi-I-frame reply (e.g.
LIST) went out as several back-to-backkeyups; on half-duplex the caller's ack arrived while we were still
transmitting and was missed every cycle → retransmit storm to link failure.
Fixed by defaulting window to 1 (one frame per ack).
83db57a). The offlineax25_replaytool counteda Qt signal that
processMonoFloat()never emits, so it under-reported decodes;fixed to count the return value. (Tooling only — also added
tools/ax25_replay.cpp,an offline WAV→decoder diagnostic, built on demand.)
Tests
pms_mailbox_test(Qt6::Core only, no DSP/radio) covers:Addressparse/format andFrameencode/decode round-trips (SABM, I, RR,UI(+via)), incl. the 6-char-callsign limit;
DISC→disconnected, frames for other stations ignored);
retransmit storm (regression guard for fix build(deps): Bump github/codeql-action from 3 to 4 #2);
H→ compose (SP/SUBJECT/body//EX) →L→R→J→B.ax25_libmodem_shim_testgains a SABM AFSK loopback test (regressionguard for fix #1): a real 17-byte SABM rendered through the modem decodes back as
FrameType::SABMwith the right addresses.Tests use
AETHER_PMS_DIRto isolate storage, so they are repeatable and nevertouch a live operator's mailbox.
Test status
pms_mailbox_testandax25_libmodem_shim_test: pass (incl. repeated runs).Over-the-air validation
Confirmed on real hardware — BTECH UV-Pro radio + PacketCommander (iOS
TNC) at 1200-baud 2 m FM:
INFO, prompts) — clean.LIST,READ) — drain correctly, no T1 loop.Docs
docs/MODEM.mdgains a Personal Mailbox System (PMS) section (architecture,commands, the reuse path for a future digipeater).
Test plan (for reviewers)
"Listening as -".
C <call>-<ssid>at 1200 baud → AetherMailbox greeting; tryH,L,SP,R n,J,B.💻 Generated with Claude Code (Opus 4.8) with architecture by @jensenpat