Skip to content

feat(modem): Personal Mailbox System (PMS) over connected-mode AX.25#3290

Closed
jensenpat wants to merge 5 commits into
aethersdr:aether/kiss-tnc-uxfrom
jensenpat:aether/pms-mailbox
Closed

feat(modem): Personal Mailbox System (PMS) over connected-mode AX.25#3290
jensenpat wants to merge 5 commits into
aethersdr:aether/kiss-tnc-uxfrom
jensenpat:aether/pms-mailbox

Conversation

@jensenpat
Copy link
Copy Markdown
Collaborator

@jensenpat jensenpat commented May 30, 2026

IMG_1262

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.

Validated over the air at 1200 baud against a BTECH UV-Pro + PacketCommander
(iOS TNC): connect, single-line and multi-line command replies, T1 retries on a
lossy link, and graceful back-to-back connections all confirmed. See
Over-the-air validation below.

What's new

Reusable AX.25 primitives (RF-agnostic, unit-tested)

  • src/core/tnc/Ax25.{h,cpp}Address (callsign/SSID encode/decode) and
    Frame parse/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 the
    mod-8 control field.
  • src/core/tnc/Ax25Connection.{h,cpp} — a single-connection LAPB state
    machine: 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 the
    Ax25Connection, greets a caller by callsign with the AetherMailbox SID and
    version
    , 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 via onAirFrame(), emits
    frames on transmitFrame().

AetherModem Mailbox tab

  • Enable PMS, a listen callsign (full CALL-SSID, e.g. KI6BCJ-10) and an
    optional vanity alias the mailbox also answers on (e.g. AETBBS; AX.25
    limits 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 mailbox
    turns 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):

Command Action
H / ? / HELP Command help
B / BYE Disconnect (sends sign-off then DISC)
I / INFO Mailbox info / welcome + counts
J / JHEARD Stations heard recently, newest first (find other PMS/BBS)
L / LIST List all messages (#, type, to, from, date, subject)
LM List messages addressed to the caller
R n / READ n Read message n (marks own private mail read)
K n / KILL n Delete message n (own / to-you / ALL bulletins)
S call / SP call / SEND call Send a private message
SB cat Send a bulletin (use ALL for everyone)
U / USERS Who is connected

Composing: after S/SP/SB the mailbox prompts SUBJECT:, then collects body
lines until /EX or Ctrl-Z on a line by itself. Address a message to ALL
for a public/bulletin message.

AX.25 connection details to be aware of

  • Connected mode, AX.25 v2.0, mod-8 sequence space (the 1200-baud norm), not
    the connectionless UI path the modem already had.
  • We answer, we don't originate. The PMS replies to an inbound SABM with
    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.
  • Addressing: the mailbox answers on the configured listen callsign
    (CALL-SSID) and the optional vanity alias; the address the caller dialed
    is 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).
  • Flow control / reliability: window k=1 (MAXFRAME=1), paclen 128.
    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.
    • Why window=1: on a half-duplex radio each I-frame is its own PTT keyup.
      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.
  • TX path: each frame is keyed through the existing serialized DAX/PTT pacing
    queue (one transmission at a time), shared with the KISS TX path.
  • RX path: decoded frames arrive via the libmodem decoder
    (Ax25DecodedFrame::ax25FrameNoFcs), which is frame-type-aware (validates I/S/U
    frames), 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.)
  • Beacon: a UI frame (src = PMS addr, dest = BEACON) every hour while
    enabled, 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:

  1. Connect frames silently dropped (beb73ae). The bitstream decoder
    (try_decode_frame in third_party/libmodem_core/bitstream.h) rejected frames
    < 18 bytes, but the shortest valid AX.25 frame with FCS is 17 (14
    address + 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 byte
    and are ≥ 18, which is why connectionless decode always worked.)
  2. Multi-frame replies stalled in a T1 loop (25c9db3). Default send window
    was 4, so a multi-I-frame reply (e.g. LIST) went out as several back-to-back
    keyups; 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).
  3. Diagnostic tool counter (83db57a). The offline ax25_replay tool counted
    a 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:

  • Address parse/format and Frame encode/decode round-trips (SABM, I, RR,
    UI(+via)), incl. the 6-char-callsign limit;
  • the connection handshake (SABM→UA→connected, I-frame delivery + ack,
    DISC→disconnected, frames for other stations ignored);
  • half-duplex window=1: a 3-I-frame reply drains one-frame-per-ack with no
    retransmit storm (regression guard for fix build(deps): Bump github/codeql-action from 3 to 4 #2);
  • vanity-alias dial (UA + greeting sent from the alias address);
  • a full mailbox session driven as a remote TNC would: connect → greeting →
    H → compose (SP/SUBJECT/body//EX) → LRJB.

ax25_libmodem_shim_test gains a SABM AFSK loopback test (regression
guard for fix #1): a real 17-byte SABM rendered through the modem decodes back as
FrameType::SABM with the right addresses.

Tests use AETHER_PMS_DIR to isolate storage, so they are repeatable and never
touch a live operator's mailbox.

Test status

  • pms_mailbox_test and ax25_libmodem_shim_test: pass (incl. repeated runs).
  • Full AetherSDR app builds and links clean on macOS (Ninja, RelWithDebInfo).

Over-the-air validation

Confirmed on real hardware — BTECH UV-Pro radio + PacketCommander (iOS
TNC) at 1200-baud 2 m FM:

  • Connect: caller dials the listen callsign, gets the AetherMailbox greeting.
  • Single-line command replies (INFO, prompts) — clean.
  • Multi-line command replies (LIST, READ) — drain correctly, no T1 loop.
  • Retries on a lossy link — T1 retransmit recovers rather than stalling.
  • Back-to-back connections handled gracefully (clean teardown + re-connect).

Docs

docs/MODEM.md gains a Personal Mailbox System (PMS) section (architecture,
commands, the reuse path for a future digipeater).

Test plan (for reviewers)

  • Set a listen callsign on the Mailbox tab; Enable PMS → confirm
    "Listening as -".
  • From a TNC, C <call>-<ssid> at 1200 baud → AetherMailbox greeting; try
    H, L, SP, R n, J, B.
  • Verify messages/callers/heard persist across an AetherSDR restart.
  • Enable the hourly beacon; confirm a UI beacon is keyed.
  • Confirm AX.25 UI decode/TX and the KISS TNC are unchanged.

💻 Generated with Claude Code (Opus 4.8) with architecture by @jensenpat

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>
@jensenpat jensenpat force-pushed the aether/pms-mailbox branch from 8cea523 to d1aafe0 Compare May 30, 2026 15:29
@jensenpat
Copy link
Copy Markdown
Collaborator Author

UI iteration pass (commit 1e9c4d2), verified: full app builds clean and pms_mailbox_test passes on repeated runs.

  • Listen callsign + vanity alias free-text fields (no defaults) replace the answer-SSID spinbox. The mailbox answers on either; the dialed address is used for the whole session (UA, greeting, replies). Note: AX.25 limits a callsign to 6 characters + optional -SSID, so a 7-char vanity like AETHBBS isn't a legal address — use ≤6 (e.g. AETBBS). The field placeholder/tooltip says so.
  • Statistics (left) and Last callers (right) split into two aligned panels.
  • Removed the "A single remote caller…" help paragraph.
  • Collapsed MODEM STATUS / GAIN STAGE / PACKET ACTIVITY into one slim inline status bar with a compact activity strip.

Ax25Connection gained an optional alias address; PmsMailbox swapped base-call+SSID for setListenCallsign()/setAliasCallsign(). New alias-dial unit test plus address-length checks.

…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>
@jensenpat jensenpat force-pushed the aether/pms-mailbox branch 4 times, most recently from 3172890 to 42c0e22 Compare May 30, 2026 17:30
…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>
@jensenpat jensenpat force-pushed the aether/pms-mailbox branch from 42c0e22 to beb73ae Compare May 30, 2026 17:42
@jensenpat
Copy link
Copy Markdown
Collaborator Author

Root cause found: connect frames (SABM) were silently dropped by an off-by-one length gate

Troubleshooting 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: 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 bytes (14 address + 1 control + 2 FCS, no PID/info) — which is exactly every connected-mode U-frame: SABM, DISC, UA, DM. So the modem silently dropped every connect/disconnect/ack, and a PMS/BBS could never be reached in connected mode even with perfect audio. UI/APRS frames carry a PID byte (≥18 bytes), which is why connectionless decode always worked. Fix: gate < 18< 17.

How it was found / tooling added:

  • tools/ax25_replay.cpp — replays a captured mono-float32 WAV through the decoder (sweeps both tone polarities), printing decoded frames + reject counters.
  • testSabmConnectFrameLoopbackDecodes — builds a real SABM, runs it through the AFSK modem, asserts it decodes back as FrameType::SABM. Regression guard.
  • PmsMailbox::onAirFrame now logs each decoded frame with the listen/alias address-match decision (decode-vs-mismatch at a glance on aether.ax25).

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.

jensenpat and others added 2 commits May 30, 2026 13:16
…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>
@jensenpat jensenpat force-pushed the aether/pms-mailbox branch from b698de8 to 25c9db3 Compare May 30, 2026 20:53
@jensenpat
Copy link
Copy Markdown
Collaborator Author

✅ Validated over the air

Tested end-to-end on real hardware — BTECH UV-Pro + PacketCommander (iOS TNC), 1200-baud 2 m FM:

  • Connect → AetherMailbox greeting by callsign.
  • Single-line replies (INFO, prompts) — clean.
  • Multi-line replies (LIST, READ) — drain correctly, no T1 retransmit loop.
  • Retries on a lossy link — T1 retransmit recovers rather than stalling.
  • Back-to-back connections — graceful teardown and re-connect.

Fixes landed since this PR opened (each with a regression test)

Commit Fix
beb73ae Decode connect frames — try_decode_frame < 18< 17 byte gate was silently dropping every 17-byte U-frame (SABM/DISC/UA/DM), so connected mode could never be reached even with perfect audio.
25c9db3 Half-duplex window=1 — multi-frame replies were blasting back-to-back PTT keyups; the caller's RR ack arrived while we were transmitting (deaf), causing a T1 retransmit loop. One frame per ack fixes it.
83db57a ax25_replay diagnostic counted an unemitted Qt signal; now counts the decode return value. (Added tools/ax25_replay.cpp, an offline WAV→decoder diagnostic.)

The PR description has been updated with the window=1 rationale, the listen+vanity-alias callsign UI, and the OTA results. pms_mailbox_test + ax25_libmodem_shim_test pass; app builds clean on macOS.

@ten9876
Copy link
Copy Markdown
Collaborator

ten9876 commented Jun 6, 2026

@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.

ten9876 pushed a commit to jensenpat/AetherSDR that referenced this pull request Jun 6, 2026
…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>
ten9876 added a commit to jensenpat/AetherSDR that referenced this pull request Jun 6, 2026
…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.
ten9876 added a commit that referenced this pull request Jun 6, 2026
…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>
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