From a0f8295ea4a55d6f1b33e24ce99aea8581440341 Mon Sep 17 00:00:00 2001 From: jensenpat Date: Sat, 30 May 2026 01:02:53 -0700 Subject: [PATCH 1/5] feat(modem): Personal Mailbox System (PMS) over connected-mode AX.25 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 #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 --- CMakeLists.txt | 14 + docs/MODEM.md | 59 +- src/core/pms/PmsMailbox.cpp | 788 +++++++++++++++++++++++++++ src/core/pms/PmsMailbox.h | 186 +++++++ src/core/tnc/Ax25.cpp | 304 +++++++++++ src/core/tnc/Ax25.h | 101 ++++ src/core/tnc/Ax25Connection.cpp | 327 +++++++++++ src/core/tnc/Ax25Connection.h | 120 ++++ src/gui/Ax25HfPacketDecodeDialog.cpp | 284 ++++++++++ src/gui/Ax25HfPacketDecodeDialog.h | 20 + tests/pms_mailbox_test.cpp | 265 +++++++++ 11 files changed, 2466 insertions(+), 2 deletions(-) create mode 100644 src/core/pms/PmsMailbox.cpp create mode 100644 src/core/pms/PmsMailbox.h create mode 100644 src/core/tnc/Ax25.cpp create mode 100644 src/core/tnc/Ax25.h create mode 100644 src/core/tnc/Ax25Connection.cpp create mode 100644 src/core/tnc/Ax25Connection.h create mode 100644 tests/pms_mailbox_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 0d39e43a..ffa13ba4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -575,8 +575,11 @@ set(CORE_SOURCES src/core/MemoryRecallPolicy.cpp src/core/tnc/AetherAx25LibmodemShim.cpp src/core/tnc/Ax25FrameFormatter.cpp + src/core/tnc/Ax25.cpp + src/core/tnc/Ax25Connection.cpp src/core/tnc/KissFraming.cpp src/core/tnc/KissTncServer.cpp + src/core/pms/PmsMailbox.cpp ) if(APPLE) @@ -1917,6 +1920,17 @@ target_include_directories(ax25_libmodem_shim_test PRIVATE src) target_link_libraries(ax25_libmodem_shim_test PRIVATE Qt6::Core aether_libmodem_core) add_test(NAME ax25_libmodem_shim_test COMMAND ax25_libmodem_shim_test) +add_executable(pms_mailbox_test + tests/pms_mailbox_test.cpp + src/core/tnc/Ax25.cpp + src/core/tnc/Ax25Connection.cpp + src/core/pms/PmsMailbox.cpp + src/core/AppSettings.cpp +) +target_include_directories(pms_mailbox_test PRIVATE src) +target_link_libraries(pms_mailbox_test PRIVATE Qt6::Core) +add_test(NAME pms_mailbox_test COMMAND pms_mailbox_test) + add_executable(cwx_panel_test tests/cwx_panel_test.cpp src/gui/CwxPanel.cpp diff --git a/docs/MODEM.md b/docs/MODEM.md index bb3c6cb3..ff59bdb3 100644 --- a/docs/MODEM.md +++ b/docs/MODEM.md @@ -202,6 +202,53 @@ problem can be triaged as client-side (connect/parse/backlog) vs RF-side (decode/level/gate). The TNC STATUS panel shows listening port, client count, and RX/TX frame counters. +## Personal Mailbox System (PMS) + +The **Mailbox** tab turns AetherModem into a compact, Kantronics-KPC-3-style +Personal Mailbox System (PBBS). A single remote caller can connect over +**1200-baud AX.25 connected mode** and read, list, and send messages, see who +has been heard, then disconnect. + +**Connected-mode data link.** Unlike the KISS/UI paths (connectionless), the PMS +needs AX.25 v2.0 connected mode (LAPB, mod-8). Two reusable, RF-agnostic, +unit-tested layers provide it: + +- `src/core/tnc/Ax25.{h,cpp}` — frame primitives: callsign/SSID `Address` + 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 + `buildTransmitAudioFromFrame`). +- `src/core/tnc/Ax25Connection.{h,cpp}` — a single-connection state machine: + accepts an inbound SABM (→ UA), tracks V(S)/V(R)/V(A), acknowledges 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. + Defaults (T1 6 s, N2 8, paclen 128, window 4) are sized for 1200-baud FM with + PTT overhead, so a lossy link recovers via REJ/T1 rather than dropping. + +**The mailbox service** is `src/core/pms/PmsMailbox.{h,cpp}` (one file pair). It +owns the `Ax25Connection`, greets a caller by callsign with the AetherMailbox SID +and version, runs a line-oriented command interpreter, and persists state as JSON +under `~/.config/AetherSDR/pms/` (`messages.json`, `callers.json`, +`heard.json`). Decoded frames are fed in via `onAirFrame()`; everything it emits +on `transmitFrame()` is keyed through the existing one-at-a-time TX queue. The +**heard list** is updated for *all* received frames (not just mailbox traffic) so +callers can discover other PMS/BBS stations nearby, and an optional **hourly UI +beacon** announces the mailbox is online and how to connect. + +**Commands** (Kantronics subset; first letter or full word): +`H(elp)`, `B(ye)`, `I(nfo)`, `J(heard)`, `L(ist)`, `LM` (list mine), +`R(ead) n`, `K(ill) n`, `S(end)`/`SP call`, `SB cat`, `U(sers)`. A message is +entered after `SUBJECT:` and terminated with `/EX` or Ctrl-Z on its own line. +Use `ALL` as the recipient for a public message. + +The **Mailbox** config tab exposes Enable PMS, the answer SSID (the station +callsign comes from the radio), a welcome/PTEXT line, the hourly-beacon toggle +and text, the last five callers, and live stats (message count, callers logged, +stations heard, free disk). All settings persist in `AppSettings` +(`AetherModemPms*` keys) across restarts; enabling the PMS turns the modem on. + +These layers are intentionally split so the planned APRS/AX.25 **digipeater** can +reuse `Ax25`/`Ax25Connection` and the heard list directly. + ## Open Work The remaining missed packets are mostly AX.25-looking candidates that fail FCS. That means the decoder is often finding packet structure but still has symbol/bit errors before CRC. @@ -216,5 +263,13 @@ Next work should focus on: - validating over-the-air AetherModem TX level, timing, and FCS decode with a second receiver -Out of scope remains APRS-IS, maps, digipeating, and connected-mode AX.25. -(KISS-over-TCP is now implemented — see the KISS TNC section above.) +Out of scope remains APRS-IS and maps. (KISS-over-TCP, connected-mode AX.25, and +a Personal Mailbox System are now implemented — see the sections above. A future +APRS/AX.25 digipeater can reuse the new `Ax25`/`Ax25Connection` primitives.) + +PMS follow-ups worth tracking: + +- On-air validation of connected-mode RX/TX at 1200 baud with a second TNC + (the protocol layer is unit-tested; RF round-trip needs a real radio). +- Multi-connect (the current PMS answers one caller at a time, by design). +- A local operator terminal tab to use/test the mailbox without a radio. diff --git a/src/core/pms/PmsMailbox.cpp b/src/core/pms/PmsMailbox.cpp new file mode 100644 index 00000000..83902b17 --- /dev/null +++ b/src/core/pms/PmsMailbox.cpp @@ -0,0 +1,788 @@ +#include "core/pms/PmsMailbox.h" + +#include "core/AppSettings.h" +#include "core/tnc/Ax25Connection.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace AetherSDR { + +using ax25::Address; +using ax25::Frame; +using ax25::FrameType; + +namespace { + +QString lineEnding() { return QStringLiteral("\r"); } + +bool sameCall(const QString& a, const QString& b) +{ + return a.compare(b, Qt::CaseInsensitive) == 0; +} + +} // namespace + +PmsMailbox::PmsMailbox(QObject* parent) + : QObject(parent) +{ + m_link = new Ax25Connection(this); + m_link->setRetryTimeoutMs(6000); + m_link->setMaxRetries(8); + m_link->setPaclen(128); + + connect(m_link, &Ax25Connection::sendFrame, this, &PmsMailbox::transmitFrame); + connect(m_link, &Ax25Connection::activity, this, &PmsMailbox::activity); + connect(m_link, &Ax25Connection::connected, this, &PmsMailbox::onLinkConnected); + connect(m_link, &Ax25Connection::disconnected, this, &PmsMailbox::onLinkDisconnected); + connect(m_link, &Ax25Connection::dataReceived, this, &PmsMailbox::onLinkData); + + m_beaconTimer = new QTimer(this); + connect(m_beaconTimer, &QTimer::timeout, this, &PmsMailbox::sendBeaconNow); +} + +PmsMailbox::~PmsMailbox() +{ + if (m_loaded) + saveHeard(); +} + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +Address PmsMailbox::localAddress() const +{ + Address a; + a.call = m_baseCall.trimmed().toUpper(); + a.ssid = m_ssid; + return a; +} + +void PmsMailbox::setEnabled(bool on) +{ + if (m_enabled == on) + return; + m_enabled = on; + if (on) { + if (!m_loaded) + loadAll(); + m_link->setLocalAddress(localAddress()); + if (m_beaconEnabled) + setBeaconIntervalMinutes(m_beaconIntervalMin); // (re)starts the timer + emit activity(QStringLiteral("PMS enabled as %1.").arg(localAddress().toString())); + } else { + m_link->reset(); + m_beaconTimer->stop(); + saveHeard(); + emit activity(QStringLiteral("PMS disabled.")); + } + emit stateChanged(); +} + +void PmsMailbox::setLocalCallsign(const QString& baseCall) +{ + const QString c = baseCall.trimmed().toUpper(); + if (c.isEmpty() || c == m_baseCall) + return; + m_baseCall = c; + m_link->setLocalAddress(localAddress()); + emit stateChanged(); +} + +void PmsMailbox::setSsid(int ssid) +{ + ssid = qBound(0, ssid, 15); + if (ssid == m_ssid) + return; + m_ssid = ssid; + m_link->setLocalAddress(localAddress()); + emit stateChanged(); +} + +void PmsMailbox::setBeaconEnabled(bool on) +{ + m_beaconEnabled = on; + if (m_enabled && on) + setBeaconIntervalMinutes(m_beaconIntervalMin); + else + m_beaconTimer->stop(); +} + +void PmsMailbox::setBeaconIntervalMinutes(int minutes) +{ + m_beaconIntervalMin = qBound(1, minutes, 24 * 60); + if (m_enabled && m_beaconEnabled) + m_beaconTimer->start(m_beaconIntervalMin * 60 * 1000); +} + +void PmsMailbox::setRetryTimeoutMs(int t1) { m_link->setRetryTimeoutMs(t1); } +void PmsMailbox::setMaxRetries(int n2) { m_link->setMaxRetries(n2); } +void PmsMailbox::setPaclen(int bytes) { m_link->setPaclen(bytes); } + +// --------------------------------------------------------------------------- +// Stats +// --------------------------------------------------------------------------- + +QStringList PmsMailbox::lastCallers(int n) const +{ + QStringList out; + for (int i = m_callers.size() - 1; i >= 0 && out.size() < n; --i) { + out << QStringLiteral("%1 %2") + .arg(m_callers.at(i).call, + m_callers.at(i).utc.toString(QStringLiteral("yyyy-MM-dd HH:mm"))); + } + return out; +} + +QStringList PmsMailbox::heardSummary(int n) const +{ + QVector sorted = m_heard; + std::sort(sorted.begin(), sorted.end(), [](const Heard& a, const Heard& b) { + return a.utc > b.utc; + }); + QStringList out; + for (int i = 0; i < sorted.size() && i < n; ++i) { + out << QStringLiteral("%1 %2") + .arg(sorted.at(i).call.leftJustified(9), + sorted.at(i).utc.toString(QStringLiteral("yyyy-MM-dd HH:mm"))); + } + return out; +} + +qint64 PmsMailbox::freeDiskBytes() const +{ + return QStorageInfo(storageDir()).bytesAvailable(); +} + +QString PmsMailbox::storageDir() const +{ + // AETHER_PMS_DIR overrides the location (used by tests for isolation; also a + // handy ops hook for pointing the mailbox store elsewhere). + const QByteArray override = qgetenv("AETHER_PMS_DIR"); + if (!override.isEmpty()) + return QString::fromUtf8(override); + const QString base = QFileInfo(AppSettings::instance().filePath()).absolutePath(); + return base + QStringLiteral("/pms"); +} + +bool PmsMailbox::isCallerConnected() const { return m_connected; } +QString PmsMailbox::connectedCaller() const +{ + return m_connected ? m_caller.toString() : QString(); +} + +// --------------------------------------------------------------------------- +// Frame intake +// --------------------------------------------------------------------------- + +void PmsMailbox::onAirFrame(const QByteArray& rawNoFcs) +{ + auto frame = Frame::decode(rawNoFcs); + if (!frame) + return; + + // Don't log our own transmissions in the heard list. + if (frame->src != localAddress()) + recordHeard(*frame); + + if (m_enabled && frame->dest == localAddress()) + m_link->onFrameReceived(*frame); +} + +void PmsMailbox::disconnectCaller() +{ + if (m_connected) + m_link->disconnect(); +} + +void PmsMailbox::recordHeard(const Frame& frame) +{ + if (!m_loaded) + loadAll(); + const QString call = frame.src.toString(); + if (call.isEmpty()) + return; + + const QString via = frame.via.isEmpty() + ? QString() + : [&] { + QStringList v; + for (const Address& a : frame.via) + v << a.toString(); + return v.join(QLatin1Char(',')); + }(); + + bool isNew = true; + for (Heard& h : m_heard) { + if (sameCall(h.call, call)) { + h.utc = QDateTime::currentDateTimeUtc(); + h.dest = frame.dest.toString(); + h.via = via; + ++h.count; + isNew = false; + break; + } + } + if (isNew) { + Heard h; + h.call = call; + h.dest = frame.dest.toString(); + h.via = via; + h.utc = QDateTime::currentDateTimeUtc(); + m_heard.append(h); + // Cap the heard list to a sane size (drop the oldest). + if (m_heard.size() > 200) { + std::sort(m_heard.begin(), m_heard.end(), + [](const Heard& a, const Heard& b) { return a.utc > b.utc; }); + m_heard.resize(200); + } + saveHeard(); + emit stateChanged(); + } +} + +void PmsMailbox::recordCaller(const Address& peer) +{ + Caller c; + c.call = peer.toString(); + c.utc = QDateTime::currentDateTimeUtc(); + m_callers.append(c); + if (m_callers.size() > 500) + m_callers.remove(0, m_callers.size() - 500); + saveCallers(); +} + +// --------------------------------------------------------------------------- +// Connection lifecycle +// --------------------------------------------------------------------------- + +void PmsMailbox::onLinkConnected(const Address& peer) +{ + m_connected = true; + m_caller = peer; + m_lineBuffer.clear(); + m_compose = Compose::None; + m_draftLines.clear(); + recordCaller(peer); + m_pendingOut.clear(); + sendGreeting(peer); + flushReplies(); + emit stateChanged(); +} + +void PmsMailbox::onLinkDisconnected(const Address& peer, bool byPeer) +{ + Q_UNUSED(peer); + Q_UNUSED(byPeer); + m_connected = false; + m_caller = Address{}; + m_lineBuffer.clear(); + m_pendingOut.clear(); + m_compose = Compose::None; + m_draftLines.clear(); + saveHeard(); + emit stateChanged(); +} + +void PmsMailbox::onLinkData(const QByteArray& data) +{ + m_lineBuffer += QString::fromLatin1(data); + // Treat CR and LF as line separators; collapse a CRLF pair. + m_lineBuffer.replace(QLatin1Char('\n'), QLatin1Char('\r')); + int idx; + while ((idx = m_lineBuffer.indexOf(QLatin1Char('\r'))) >= 0) { + const QString line = m_lineBuffer.left(idx); + m_lineBuffer.remove(0, idx + 1); + processLine(line); + } + // Ctrl-Z (end of message) may arrive without a trailing CR. + if (m_compose == Compose::Body && m_lineBuffer.contains(QChar(0x1a))) { + m_lineBuffer.remove(QChar(0x1a)); + finishCompose(true); + } + flushReplies(); +} + +// --------------------------------------------------------------------------- +// Output helpers +// --------------------------------------------------------------------------- + +void PmsMailbox::reply(const QString& text) +{ + // Queue the line(s); a whole command's output is handed to the data link in + // one go by flushReplies() so it coalesces into as few I-frames (and thus + // PTT keyings) as possible. Embedded newlines become separate CR lines. + const QStringList lines = text.split(QLatin1Char('\n')); + for (const QString& line : lines) + m_pendingOut += line + lineEnding(); +} + +void PmsMailbox::flushReplies() +{ + if (m_pendingOut.isEmpty()) + return; + if (m_connected) + m_link->sendData(m_pendingOut.toLatin1()); + m_pendingOut.clear(); +} + +void PmsMailbox::sendGreeting(const Address& peer) +{ + reply(QStringLiteral("[AetherMailbox-%1]").arg(m_version)); + if (!m_welcome.trimmed().isEmpty()) + reply(m_welcome.trimmed()); + reply(QStringLiteral("%1 message(s) on file.").arg(m_messages.size())); + reply(QStringLiteral("Hello %1, welcome to the %2 AetherMailbox.") + .arg(peer.call, localAddress().toString())); + sendPrompt(); +} + +void PmsMailbox::sendPrompt() +{ + reply(QStringLiteral("ENTER COMMAND: B,H,I,J,K,L,R,S,U >")); +} + +// --------------------------------------------------------------------------- +// Command dispatch +// --------------------------------------------------------------------------- + +void PmsMailbox::processLine(const QString& line) +{ + if (m_compose != Compose::None) { + if (m_compose == Compose::Subject) { + m_draft.subject = line.trimmed(); + m_compose = Compose::Body; + reply(QStringLiteral( + "ENTER MESSAGE %1 - END WITH /EX OR CTRL-Z ON A LINE BY ITSELF:") + .arg(m_nextId)); + return; + } + // Body + const QString trimmed = line.trimmed(); + if (trimmed.compare(QStringLiteral("/EX"), Qt::CaseInsensitive) == 0 + || line.contains(QChar(0x1a))) { + finishCompose(true); + return; + } + m_draftLines.append(line); + return; + } + handleCommand(line); +} + +void PmsMailbox::handleCommand(const QString& line) +{ + const QString trimmed = line.trimmed(); + if (trimmed.isEmpty()) { + sendPrompt(); + return; + } + + const int sp = trimmed.indexOf(QLatin1Char(' ')); + const QString cmd = (sp < 0 ? trimmed : trimmed.left(sp)).toUpper(); + const QString args = (sp < 0 ? QString() : trimmed.mid(sp + 1).trimmed()); + + bool promptAfter = true; + if (cmd == QLatin1String("B") || cmd == QLatin1String("BYE")) { + reply(QStringLiteral("73! Disconnecting.")); + flushReplies(); // get the sign-off out before we leave Connected state + m_link->disconnect(); + promptAfter = false; + } else if (cmd == QLatin1String("H") || cmd == QLatin1String("?") + || cmd == QLatin1String("HELP")) { + cmdHelp(); + } else if (cmd == QLatin1String("I") || cmd == QLatin1String("INFO")) { + cmdInfo(); + } else if (cmd == QLatin1String("J") || cmd == QLatin1String("JHEARD")) { + cmdJheard(args); + } else if (cmd == QLatin1String("K") || cmd == QLatin1String("KILL")) { + cmdKill(args); + } else if (cmd == QLatin1String("LM")) { + cmdList(args, /*mineOnly=*/true); + } else if (cmd == QLatin1String("L") || cmd == QLatin1String("LIST")) { + cmdList(args, /*mineOnly=*/false); + } else if (cmd == QLatin1String("R") || cmd == QLatin1String("READ")) { + cmdRead(args); + } else if (cmd == QLatin1String("SB")) { + cmdSendBegin(args, QLatin1Char('B')); + promptAfter = false; // compose drives its own prompts + } else if (cmd == QLatin1String("S") || cmd == QLatin1String("SP") + || cmd == QLatin1String("SEND")) { + cmdSendBegin(args, QLatin1Char('P')); + promptAfter = false; + } else if (cmd == QLatin1String("U") || cmd == QLatin1String("USERS")) { + cmdUsers(); + } else { + reply(QStringLiteral("? Unknown command '%1'. Type H for help.").arg(cmd)); + } + + if (promptAfter && m_connected && m_compose == Compose::None) + sendPrompt(); +} + +void PmsMailbox::cmdHelp() +{ + reply(QStringLiteral( + "AetherMailbox commands:\n" + " B(ye) Disconnect from the mailbox\n" + " H(elp) This help\n" + " I(nfo) Mailbox info / welcome\n" + " J(heard) Stations heard recently (find other PMS/BBS)\n" + " K(ill) n Delete message number n\n" + " L(ist) List all messages\n" + " LM List messages addressed to you\n" + " R(ead) n Read message number n\n" + " S(end) call Send a private message (also SP call)\n" + " SB cat Send a bulletin (use ALL for everyone)\n" + " U(sers) Show who is connected\n" + "End a message you are entering with /EX or Ctrl-Z on its own line.")); +} + +void PmsMailbox::cmdInfo() +{ + reply(QStringLiteral("AetherMailbox %1 at %2.") + .arg(m_version, localAddress().toString())); + if (!m_welcome.trimmed().isEmpty()) + reply(m_welcome.trimmed()); + reply(QStringLiteral("%1 message(s), %2 station(s) heard.") + .arg(m_messages.size()) + .arg(m_heard.size())); +} + +void PmsMailbox::cmdList(const QString& args, bool mineOnly) +{ + Q_UNUSED(args); + if (m_messages.isEmpty()) { + reply(QStringLiteral("No messages.")); + return; + } + reply(QStringLiteral("MSG# ST TO FROM DATE SUBJECT")); + // Newest first. + for (int i = m_messages.size() - 1; i >= 0; --i) { + const Message& m = m_messages.at(i); + if (mineOnly && !sameCall(m.to, m_caller.toString()) && !sameCall(m.to, m_caller.call)) + continue; + reply(QStringLiteral("%1 %2%3 %4 %5 %6 %7") + .arg(QString::number(m.id).rightJustified(4)) + .arg(m.type) + .arg(m.read ? QLatin1Char('Y') : QLatin1Char('N')) + .arg(m.to.leftJustified(8).left(8), + m.from.leftJustified(8).left(8), + m.utc.toString(QStringLiteral("MM/dd HH:mm")), + m.subject)); + } +} + +void PmsMailbox::cmdRead(const QString& args) +{ + bool ok = false; + const int n = args.trimmed().toInt(&ok); + if (!ok) { + reply(QStringLiteral("USAGE: R ")); + return; + } + for (Message& m : m_messages) { + if (m.id != n) + continue; + if (!callerMayAccess(m)) { + reply(QStringLiteral("Message %1 is private; not authorized.").arg(n)); + return; + } + reply(QStringLiteral("MSG #%1 %2 TO: %3 FROM: %4 %5") + .arg(m.id) + .arg(m.type) + .arg(m.to, m.from, m.utc.toString(QStringLiteral("yyyy-MM-dd HH:mm")) + QStringLiteral("Z"))); + reply(QStringLiteral("SUBJECT: %1").arg(m.subject)); + reply(m.body.isEmpty() ? QStringLiteral("(no text)") : m.body); + reply(QStringLiteral("---")); + if (!m.read && (sameCall(m.to, m_caller.toString()) || sameCall(m.to, m_caller.call))) { + m.read = true; + saveMessages(); + } + return; + } + reply(QStringLiteral("Message %1 not found.").arg(n)); +} + +void PmsMailbox::cmdKill(const QString& args) +{ + bool ok = false; + const int n = args.trimmed().toInt(&ok); + if (!ok) { + reply(QStringLiteral("USAGE: K ")); + return; + } + for (int i = 0; i < m_messages.size(); ++i) { + if (m_messages.at(i).id != n) + continue; + const Message& m = m_messages.at(i); + const bool mayKill = sameCall(m.from, m_caller.toString()) + || sameCall(m.from, m_caller.call) + || sameCall(m.to, m_caller.toString()) + || sameCall(m.to, m_caller.call) + || sameCall(m.to, QStringLiteral("ALL")); + if (!mayKill) { + reply(QStringLiteral("Not authorized to kill message %1.").arg(n)); + return; + } + m_messages.remove(i); + saveMessages(); + reply(QStringLiteral("Message %1 killed.").arg(n)); + emit stateChanged(); + return; + } + reply(QStringLiteral("Message %1 not found.").arg(n)); +} + +void PmsMailbox::cmdSendBegin(const QString& args, QChar type) +{ + QString to = args.trimmed(); + if (type == QLatin1Char('P')) { + // First token is the destination callsign. + const int sp = to.indexOf(QLatin1Char(' ')); + if (sp >= 0) + to = to.left(sp); + if (to.isEmpty()) { + reply(QStringLiteral("USAGE: S (or S ALL for everyone)")); + sendPrompt(); + return; + } + } else { // Bulletin + if (to.isEmpty()) + to = QStringLiteral("ALL"); + const int sp = to.indexOf(QLatin1Char(' ')); + if (sp >= 0) + to = to.left(sp); + } + + m_draft = Message{}; + m_draft.type = type; + m_draft.to = to.toUpper(); + m_draft.from = m_caller.toString(); + m_draftLines.clear(); + m_compose = Compose::Subject; + reply(QStringLiteral("SUBJECT:")); +} + +void PmsMailbox::finishCompose(bool save) +{ + if (save) { + m_draft.id = m_nextId++; + m_draft.utc = QDateTime::currentDateTimeUtc(); + m_draft.body = m_draftLines.join(QStringLiteral("\n")); + m_draft.read = false; + m_messages.append(m_draft); + saveMessages(); + reply(QStringLiteral("MESSAGE %1 SAVED.").arg(m_draft.id)); + emit stateChanged(); + } else { + reply(QStringLiteral("Message aborted.")); + } + m_compose = Compose::None; + m_draftLines.clear(); + sendPrompt(); +} + +void PmsMailbox::cmdJheard(const QString& args) +{ + Q_UNUSED(args); + if (m_heard.isEmpty()) { + reply(QStringLiteral("Nothing heard yet.")); + return; + } + QVector sorted = m_heard; + std::sort(sorted.begin(), sorted.end(), + [](const Heard& a, const Heard& b) { return a.utc > b.utc; }); + reply(QStringLiteral("CALLSIGN LAST HEARD (UTC) VIA")); + const int limit = std::min(sorted.size(), 25); + for (int i = 0; i < limit; ++i) { + const Heard& h = sorted.at(i); + reply(QStringLiteral("%1 %2 %3") + .arg(h.call.leftJustified(9), + h.utc.toString(QStringLiteral("MM/dd HH:mm")), + h.via)); + } +} + +void PmsMailbox::cmdUsers() +{ + reply(QStringLiteral("Connected: %1").arg(m_caller.toString())); +} + +bool PmsMailbox::callerMayAccess(const Message& msg) const +{ + if (msg.type == QLatin1Char('B')) + return true; + if (sameCall(msg.to, QStringLiteral("ALL"))) + return true; + if (sameCall(msg.to, m_caller.toString()) || sameCall(msg.to, m_caller.call)) + return true; + if (sameCall(msg.from, m_caller.toString()) || sameCall(msg.from, m_caller.call)) + return true; + return false; +} + +// --------------------------------------------------------------------------- +// Beacon +// --------------------------------------------------------------------------- + +void PmsMailbox::sendBeaconNow() +{ + if (!m_enabled || !m_beaconEnabled) + return; + Address dest; + dest.call = m_beaconDest.trimmed().toUpper().isEmpty() + ? QStringLiteral("BEACON") + : m_beaconDest.trimmed().toUpper(); + dest.ssid = 0; + + const QString text = QStringLiteral("%1 (connect to %2)") + .arg(m_beaconText.trimmed(), localAddress().toString()); + const Frame frame = Frame::makeUI(dest, localAddress(), {}, text.toLatin1()); + emit transmitFrame(frame.encode()); + emit activity(QStringLiteral("PMS beacon sent: %1").arg(text)); +} + +// --------------------------------------------------------------------------- +// Persistence +// --------------------------------------------------------------------------- + +QString PmsMailbox::messagesPath() const { return storageDir() + QStringLiteral("/messages.json"); } +QString PmsMailbox::callersPath() const { return storageDir() + QStringLiteral("/callers.json"); } +QString PmsMailbox::heardPath() const { return storageDir() + QStringLiteral("/heard.json"); } + +void PmsMailbox::ensureStorageDir() const +{ + QDir().mkpath(storageDir()); +} + +void PmsMailbox::loadAll() +{ + m_loaded = true; + m_messages.clear(); + m_callers.clear(); + m_heard.clear(); + m_nextId = 1; + + // Messages + QFile mf(messagesPath()); + if (mf.open(QIODevice::ReadOnly)) { + const QJsonObject root = QJsonDocument::fromJson(mf.readAll()).object(); + m_nextId = root.value(QStringLiteral("nextId")).toInt(1); + for (const QJsonValue& v : root.value(QStringLiteral("messages")).toArray()) { + const QJsonObject o = v.toObject(); + Message m; + m.id = o.value(QStringLiteral("id")).toInt(); + m.type = o.value(QStringLiteral("type")).toString(QStringLiteral("P")).at(0); + m.to = o.value(QStringLiteral("to")).toString(); + m.from = o.value(QStringLiteral("from")).toString(); + m.subject = o.value(QStringLiteral("subject")).toString(); + m.body = o.value(QStringLiteral("body")).toString(); + m.utc = QDateTime::fromString(o.value(QStringLiteral("utc")).toString(), Qt::ISODate); + m.read = o.value(QStringLiteral("read")).toBool(); + m_messages.append(m); + m_nextId = std::max(m_nextId, m.id + 1); + } + } + + // Callers + QFile cf(callersPath()); + if (cf.open(QIODevice::ReadOnly)) { + const QJsonObject root = QJsonDocument::fromJson(cf.readAll()).object(); + for (const QJsonValue& v : root.value(QStringLiteral("callers")).toArray()) { + const QJsonObject o = v.toObject(); + Caller c; + c.call = o.value(QStringLiteral("call")).toString(); + c.utc = QDateTime::fromString(o.value(QStringLiteral("utc")).toString(), Qt::ISODate); + m_callers.append(c); + } + } + + // Heard + QFile hf(heardPath()); + if (hf.open(QIODevice::ReadOnly)) { + const QJsonObject root = QJsonDocument::fromJson(hf.readAll()).object(); + for (const QJsonValue& v : root.value(QStringLiteral("heard")).toArray()) { + const QJsonObject o = v.toObject(); + Heard h; + h.call = o.value(QStringLiteral("call")).toString(); + h.dest = o.value(QStringLiteral("dest")).toString(); + h.via = o.value(QStringLiteral("via")).toString(); + h.utc = QDateTime::fromString(o.value(QStringLiteral("utc")).toString(), Qt::ISODate); + h.count = o.value(QStringLiteral("count")).toInt(1); + m_heard.append(h); + } + } +} + +void PmsMailbox::saveMessages() const +{ + ensureStorageDir(); + QJsonArray arr; + for (const Message& m : m_messages) { + QJsonObject o; + o.insert(QStringLiteral("id"), m.id); + o.insert(QStringLiteral("type"), QString(m.type)); + o.insert(QStringLiteral("to"), m.to); + o.insert(QStringLiteral("from"), m.from); + o.insert(QStringLiteral("subject"), m.subject); + o.insert(QStringLiteral("body"), m.body); + o.insert(QStringLiteral("utc"), m.utc.toString(Qt::ISODate)); + o.insert(QStringLiteral("read"), m.read); + arr.append(o); + } + QJsonObject root; + root.insert(QStringLiteral("nextId"), m_nextId); + root.insert(QStringLiteral("messages"), arr); + QFile f(messagesPath()); + if (f.open(QIODevice::WriteOnly | QIODevice::Truncate)) + f.write(QJsonDocument(root).toJson()); +} + +void PmsMailbox::saveCallers() const +{ + ensureStorageDir(); + QJsonArray arr; + for (const Caller& c : m_callers) { + QJsonObject o; + o.insert(QStringLiteral("call"), c.call); + o.insert(QStringLiteral("utc"), c.utc.toString(Qt::ISODate)); + arr.append(o); + } + QJsonObject root; + root.insert(QStringLiteral("callers"), arr); + QFile f(callersPath()); + if (f.open(QIODevice::WriteOnly | QIODevice::Truncate)) + f.write(QJsonDocument(root).toJson()); +} + +void PmsMailbox::saveHeard() const +{ + ensureStorageDir(); + QJsonArray arr; + for (const Heard& h : m_heard) { + QJsonObject o; + o.insert(QStringLiteral("call"), h.call); + o.insert(QStringLiteral("dest"), h.dest); + o.insert(QStringLiteral("via"), h.via); + o.insert(QStringLiteral("utc"), h.utc.toString(Qt::ISODate)); + o.insert(QStringLiteral("count"), h.count); + arr.append(o); + } + QJsonObject root; + root.insert(QStringLiteral("heard"), arr); + QFile f(heardPath()); + if (f.open(QIODevice::WriteOnly | QIODevice::Truncate)) + f.write(QJsonDocument(root).toJson()); +} + +} // namespace AetherSDR diff --git a/src/core/pms/PmsMailbox.h b/src/core/pms/PmsMailbox.h new file mode 100644 index 00000000..689577e9 --- /dev/null +++ b/src/core/pms/PmsMailbox.h @@ -0,0 +1,186 @@ +#pragma once + +#include "core/tnc/Ax25.h" + +#include +#include +#include +#include +#include +#include + +class QTimer; + +namespace AetherSDR { + +class Ax25Connection; + +// The Personal Mailbox System (PMS / PBBS) service: a compact, Kantronics-style +// AX.25 mailbox that a single remote caller can connect to at 1200 baud and +// read / list / send messages, see who has been heard, and disconnect. Messages, +// callers, and the heard list persist to JSON under the AetherSDR settings dir. +// +// This object owns an Ax25Connection (the connected-mode data link) and turns +// reassembled line input into mailbox command responses. It is RF-agnostic: feed +// it every decoded frame via onAirFrame() and key whatever it emits on +// transmitFrame(). The heard list and UI-beacon logic are deliberately split out +// so the future APRS/AX.25 digipeater can reuse the same plumbing. +class PmsMailbox : public QObject { + Q_OBJECT + +public: + struct Message { + int id{0}; + QChar type{QLatin1Char('P')}; // 'P' private, 'B' bulletin + QString to; + QString from; + QString subject; + QString body; + QDateTime utc; + bool read{false}; + }; + + struct Caller { + QString call; + QDateTime utc; + }; + + struct Heard { + QString call; + QString dest; + QString via; + QDateTime utc; + int count{1}; + }; + + explicit PmsMailbox(QObject* parent = nullptr); + ~PmsMailbox() override; + + // ---- Configuration (the GUI persists these via AppSettings) ------------- + void setEnabled(bool on); + bool isEnabled() const { return m_enabled; } + void setLocalCallsign(const QString& baseCall); // station callsign (no SSID) + QString localCallsign() const { return m_baseCall; } + void setSsid(int ssid); + int ssid() const { return m_ssid; } + ax25::Address localAddress() const; + void setVersionString(const QString& version) { m_version = version; } + + void setWelcomeText(const QString& text) { m_welcome = text; } + QString welcomeText() const { return m_welcome; } + + void setBeaconEnabled(bool on); + bool beaconEnabled() const { return m_beaconEnabled; } + void setBeaconText(const QString& text) { m_beaconText = text; } + QString beaconText() const { return m_beaconText; } + void setBeaconIntervalMinutes(int minutes); + int beaconIntervalMinutes() const { return m_beaconIntervalMin; } + void setBeaconDestination(const QString& dest) { m_beaconDest = dest; } + + // Data-link tunables forwarded to the Ax25Connection. + void setRetryTimeoutMs(int t1); + void setMaxRetries(int n2); + void setPaclen(int bytes); + + // ---- Stats for the GUI ------------------------------------------------- + int messageCount() const { return m_messages.size(); } + int callerCount() const { return m_callers.size(); } + QStringList lastCallers(int n = 5) const; + QStringList heardSummary(int n = 20) const; + qint64 freeDiskBytes() const; + QString storageDir() const; + bool isCallerConnected() const; + QString connectedCaller() const; + +public slots: + // Feed every decoded AX.25 frame (address..info, no FCS) here. The heard list + // is updated for all frames; frames addressed to our PMS are handled by the + // data link. Safe to call when disabled (only heard tracking happens). + void onAirFrame(const QByteArray& rawNoFcs); + + // Force-disconnect the current caller (graceful DISC). + void disconnectCaller(); + + // Send a beacon immediately (also called by the hourly timer). + void sendBeaconNow(); + +signals: + // A raw AX.25 frame (address..info, no FCS) to key on the air. + void transmitFrame(const QByteArray& rawNoFcs); + // Human-readable activity for the AetherModem log. + void activity(const QString& message); + // Connection/state/stats changed — the GUI should refresh its Mailbox panel. + void stateChanged(); + +private: + void onLinkConnected(const ax25::Address& peer); + void onLinkDisconnected(const ax25::Address& peer, bool byPeer); + void onLinkData(const QByteArray& data); + + void recordHeard(const ax25::Frame& frame); + void recordCaller(const ax25::Address& peer); + + void reply(const QString& text); // queue CR-terminated line(s) to the caller + void flushReplies(); // hand queued output to the data link at once + void sendGreeting(const ax25::Address& peer); + void sendPrompt(); + void processLine(const QString& line); + void handleCommand(const QString& line); + + // Command handlers. + void cmdHelp(); + void cmdList(const QString& args, bool mineOnly); + void cmdRead(const QString& args); + void cmdKill(const QString& args); + void cmdSendBegin(const QString& args, QChar type); + void cmdJheard(const QString& args); + void cmdUsers(); + void cmdInfo(); + void finishCompose(bool save); + + bool callerMayAccess(const Message& msg) const; + + // Persistence. + QString messagesPath() const; + QString callersPath() const; + QString heardPath() const; + void ensureStorageDir() const; + void loadAll(); + void saveMessages() const; + void saveCallers() const; + void saveHeard() const; + + Ax25Connection* m_link{nullptr}; + QTimer* m_beaconTimer{nullptr}; + + bool m_enabled{false}; + QString m_baseCall{QStringLiteral("NOCALL")}; + int m_ssid{1}; + QString m_version{QStringLiteral("0.0")}; + QString m_welcome; + bool m_beaconEnabled{false}; + QString m_beaconText{QStringLiteral("AetherMailbox online - connect for messages")}; + QString m_beaconDest{QStringLiteral("BEACON")}; + int m_beaconIntervalMin{60}; + + QVector m_messages; + int m_nextId{1}; + QVector m_callers; + QVector m_heard; + + // Per-connection session state (single caller). + bool m_connected{false}; + ax25::Address m_caller; + QString m_lineBuffer; // inbound, awaiting a CR + QString m_pendingOut; // outbound, coalesced until flushReplies() + + // Multi-line compose state. + enum class Compose { None, Subject, Body }; + Compose m_compose{Compose::None}; + Message m_draft; + QStringList m_draftLines; + + bool m_loaded{false}; +}; + +} // namespace AetherSDR diff --git a/src/core/tnc/Ax25.cpp b/src/core/tnc/Ax25.cpp new file mode 100644 index 00000000..0c170ec1 --- /dev/null +++ b/src/core/tnc/Ax25.cpp @@ -0,0 +1,304 @@ +#include "core/tnc/Ax25.h" + +namespace AetherSDR::ax25 { + +namespace { + +// U-frame control octets with the P/F bit (0x10) cleared. The P/F bit is OR'd +// in separately. These are the canonical AX.25 v2.x values. +constexpr quint8 kPollFinalBit = 0x10; +constexpr quint8 kCtrlUI = 0x03; +constexpr quint8 kCtrlDM = 0x0F; +constexpr quint8 kCtrlSABM = 0x2F; +constexpr quint8 kCtrlDISC = 0x43; +constexpr quint8 kCtrlUA = 0x63; +constexpr quint8 kCtrlFRMR = 0x87; + +// S-frame supervisory bits (bits 2-3). +constexpr quint8 kSupRR = 0x00; +constexpr quint8 kSupRNR = 0x04; +constexpr quint8 kSupREJ = 0x08; + +// Reserved SSID-octet bits 5-6, conventionally set to 1. +constexpr quint8 kSsidReserved = 0x60; + +void appendAddress(QByteArray& out, const Address& addr, bool last, bool cOrHBit) +{ + QString call = addr.call.trimmed().toUpper(); + for (int i = 0; i < 6; ++i) { + const char ch = (i < call.size()) ? call.at(i).toLatin1() : ' '; + out.append(static_cast(static_cast(ch) << 1)); + } + quint8 ssidByte = kSsidReserved + | static_cast((addr.ssid & 0x0F) << 1) + | (last ? 0x01 : 0x00) + | (cOrHBit ? 0x80 : 0x00); + out.append(static_cast(ssidByte)); +} + +std::optional
readAddress(const QByteArray& frame, int offset, bool& lastOut) +{ + if (offset + 7 > frame.size()) + return std::nullopt; + + Address addr; + for (int i = 0; i < 6; ++i) { + const char ch = static_cast( + static_cast(frame.at(offset + i)) >> 1); + if (ch != ' ') + addr.call.append(QLatin1Char(ch)); + } + const quint8 ssidByte = static_cast(frame.at(offset + 6)); + addr.ssid = (ssidByte >> 1) & 0x0F; + addr.hasBeenRepeated = (ssidByte & 0x80) != 0; + addr.commandResponse = (ssidByte & 0x80) != 0; + lastOut = (ssidByte & 0x01) != 0; + if (addr.call.isEmpty()) + return std::nullopt; + return addr; +} + +} // namespace + +QString Address::toString() const +{ + const QString base = call.trimmed().toUpper(); + if (ssid == 0) + return base; + return QStringLiteral("%1-%2").arg(base).arg(ssid); +} + +std::optional
Address::parse(const QString& text) +{ + const QString trimmed = text.trimmed().toUpper(); + if (trimmed.isEmpty()) + return std::nullopt; + + Address addr; + const int dash = trimmed.indexOf(QLatin1Char('-')); + if (dash < 0) { + addr.call = trimmed; + addr.ssid = 0; + } else { + addr.call = trimmed.left(dash); + bool ok = false; + addr.ssid = trimmed.mid(dash + 1).toInt(&ok); + if (!ok) + return std::nullopt; + } + if (addr.call.isEmpty() || addr.call.size() > 6 || addr.ssid < 0 || addr.ssid > 15) + return std::nullopt; + return addr; +} + +QString frameTypeName(FrameType type) +{ + switch (type) { + case FrameType::I: return QStringLiteral("I"); + case FrameType::RR: return QStringLiteral("RR"); + case FrameType::RNR: return QStringLiteral("RNR"); + case FrameType::REJ: return QStringLiteral("REJ"); + case FrameType::SABM: return QStringLiteral("SABM"); + case FrameType::DISC: return QStringLiteral("DISC"); + case FrameType::DM: return QStringLiteral("DM"); + case FrameType::UA: return QStringLiteral("UA"); + case FrameType::FRMR: return QStringLiteral("FRMR"); + case FrameType::UI: return QStringLiteral("UI"); + case FrameType::Unknown: break; + } + return QStringLiteral("?"); +} + +QByteArray Frame::encode() const +{ + QByteArray out; + out.reserve(16 + via.size() * 7 + info.size()); + + const bool hasVia = !via.isEmpty(); + // Command/response sense lives in the two address C bits (AX.25 v2.x): + // command frame -> dest C=1, src C=0; response frame -> dest C=0, src C=1. + appendAddress(out, dest, /*last=*/false, /*cOrH=*/command); + appendAddress(out, src, /*last=*/!hasVia, /*cOrH=*/!command); + for (int i = 0; i < via.size(); ++i) { + const bool last = (i == via.size() - 1); + appendAddress(out, via.at(i), last, via.at(i).hasBeenRepeated); + } + + quint8 control = 0; + bool carriesPid = false; + switch (type) { + case FrameType::I: + control = static_cast((nr & 0x07) << 5) + | (pollFinal ? kPollFinalBit : 0) + | static_cast((ns & 0x07) << 1); + carriesPid = true; + break; + case FrameType::RR: + case FrameType::RNR: + case FrameType::REJ: { + const quint8 sup = (type == FrameType::RR) ? kSupRR + : (type == FrameType::RNR) ? kSupRNR : kSupREJ; + control = static_cast((nr & 0x07) << 5) + | (pollFinal ? kPollFinalBit : 0) | sup | 0x01; + break; + } + case FrameType::UI: + control = kCtrlUI | (pollFinal ? kPollFinalBit : 0); + carriesPid = true; + break; + case FrameType::SABM: control = kCtrlSABM | (pollFinal ? kPollFinalBit : 0); break; + case FrameType::DISC: control = kCtrlDISC | (pollFinal ? kPollFinalBit : 0); break; + case FrameType::DM: control = kCtrlDM | (pollFinal ? kPollFinalBit : 0); break; + case FrameType::UA: control = kCtrlUA | (pollFinal ? kPollFinalBit : 0); break; + case FrameType::FRMR: control = kCtrlFRMR | (pollFinal ? kPollFinalBit : 0); break; + case FrameType::Unknown: control = kCtrlUI; carriesPid = true; break; + } + out.append(static_cast(control)); + + if (carriesPid) { + out.append(static_cast(pid)); + out.append(info); + } else if (type == FrameType::FRMR) { + out.append(info); // FRMR carries a 3-byte reason field + } + return out; +} + +std::optional Frame::decode(const QByteArray& rawNoFcs) +{ + // Minimum: dest(7) + src(7) + control(1). + if (rawNoFcs.size() < 15) + return std::nullopt; + + Frame frame; + bool last = false; + int offset = 0; + + auto dest = readAddress(rawNoFcs, offset, last); + if (!dest) + return std::nullopt; + const bool destC = dest->commandResponse; + frame.dest = *dest; + offset += 7; + if (last) // dest must not be the last address + return std::nullopt; + + auto src = readAddress(rawNoFcs, offset, last); + if (!src) + return std::nullopt; + const bool srcC = src->commandResponse; + frame.src = *src; + offset += 7; + + int guard = 0; + while (!last && guard < 8) { + auto via = readAddress(rawNoFcs, offset, last); + if (!via) + return std::nullopt; + frame.via.append(*via); + offset += 7; + ++guard; + } + + if (offset >= rawNoFcs.size()) + return std::nullopt; + const quint8 control = static_cast(rawNoFcs.at(offset++)); + + // AX.25 v2.x command/response: command frame has dest C=1, src C=0. + frame.command = destC && !srcC ? true : (!destC && srcC ? false : true); + frame.pollFinal = (control & kPollFinalBit) != 0; + + bool carriesPid = false; + if ((control & 0x01) == 0x00) { + frame.type = FrameType::I; + frame.ns = (control >> 1) & 0x07; + frame.nr = (control >> 5) & 0x07; + carriesPid = true; + } else if ((control & 0x03) == 0x01) { + frame.nr = (control >> 5) & 0x07; + const quint8 sup = control & 0x0C; + frame.type = (sup == kSupRR) ? FrameType::RR + : (sup == kSupRNR) ? FrameType::RNR + : (sup == kSupREJ) ? FrameType::REJ : FrameType::Unknown; + } else { // U frame + const quint8 modifier = control & ~kPollFinalBit; + switch (modifier) { + case kCtrlUI: frame.type = FrameType::UI; carriesPid = true; break; + case kCtrlDM: frame.type = FrameType::DM; break; + case kCtrlSABM: frame.type = FrameType::SABM; break; + case kCtrlDISC: frame.type = FrameType::DISC; break; + case kCtrlUA: frame.type = FrameType::UA; break; + case kCtrlFRMR: + frame.type = FrameType::FRMR; + frame.info = rawNoFcs.mid(offset); + break; + default: frame.type = FrameType::Unknown; break; + } + } + + if (carriesPid) { + if (offset >= rawNoFcs.size()) + return std::nullopt; + frame.pid = static_cast(rawNoFcs.at(offset++)); + frame.info = rawNoFcs.mid(offset); + } + return frame; +} + +Frame Frame::makeU(const Address& dest, const Address& src, FrameType u, + bool pollFinal, bool command) +{ + Frame f; + f.dest = dest; + f.src = src; + f.type = u; + f.pollFinal = pollFinal; + f.command = command; + return f; +} + +Frame Frame::makeS(const Address& dest, const Address& src, FrameType s, + int nr, bool pollFinal, bool command) +{ + Frame f; + f.dest = dest; + f.src = src; + f.type = s; + f.nr = nr & 0x07; + f.pollFinal = pollFinal; + f.command = command; + return f; +} + +Frame Frame::makeI(const Address& dest, const Address& src, int ns, int nr, + bool pollFinal, const QByteArray& info, quint8 pid) +{ + Frame f; + f.dest = dest; + f.src = src; + f.type = FrameType::I; + f.ns = ns & 0x07; + f.nr = nr & 0x07; + f.pollFinal = pollFinal; + f.command = true; // I frames are always commands + f.pid = pid; + f.info = info; + return f; +} + +Frame Frame::makeUI(const Address& dest, const Address& src, + const QVector
& via, const QByteArray& info, quint8 pid) +{ + Frame f; + f.dest = dest; + f.src = src; + f.via = via; + f.type = FrameType::UI; + f.pollFinal = false; + f.command = true; + f.pid = pid; + f.info = info; + return f; +} + +} // namespace AetherSDR::ax25 diff --git a/src/core/tnc/Ax25.h b/src/core/tnc/Ax25.h new file mode 100644 index 00000000..4d679410 --- /dev/null +++ b/src/core/tnc/Ax25.h @@ -0,0 +1,101 @@ +#pragma once + +#include +#include +#include +#include + +#include + +// Low-level AX.25 v2.0 frame primitives: callsign/SSID addresses plus parse and +// build of a raw frame (address fields through the info field, WITHOUT the HDLC +// flags or the trailing 2-byte FCS — the same "no FCS" convention used by the +// KISS path and AetherAx25LibmodemShim::buildTransmitAudioFromFrame()). +// +// These are deliberately self-contained (no Qt::Network, no DSP) so they can be +// unit-tested standalone and reused by the PMS connected-mode data link, the +// beacon, and the future APRS/AX.25 digipeater. Reference: AX.25 v2.2 spec. + +namespace AetherSDR::ax25 { + +// A single AX.25 address: a 1-6 character callsign plus a 0-15 SSID. +struct Address { + QString call; + int ssid{0}; + // Address-field flag bits as they sit in the SSID octet. For the digipeater + // path we must preserve the "has-been-repeated" (H) bit on via callsigns and + // the command/response (C) bits on the dest/src addresses. + bool hasBeenRepeated{false}; // H bit (only meaningful on via addresses) + bool commandResponse{false}; // C bit (dest = command, src = response sense) + + bool isValid() const { return !call.isEmpty() && ssid >= 0 && ssid <= 15; } + + // "N0CALL" or "N0CALL-7"; SSID 0 is omitted. + QString toString() const; + bool operator==(const Address& other) const + { + return call.compare(other.call, Qt::CaseInsensitive) == 0 && ssid == other.ssid; + } + bool operator!=(const Address& other) const { return !(*this == other); } + + // Parse "N0CALL" / "N0CALL-7" (case-insensitive). Returns nullopt if the + // base callsign is empty/too long or the SSID is out of range. + static std::optional
parse(const QString& text); +}; + +// The AX.25 frame category, derived from the control octet. +enum class FrameType { + I, // Information (connected-mode data) + RR, // Receive Ready (supervisory) + RNR, // Receive Not Ready (supervisory) + REJ, // Reject (supervisory) + SABM, // Set Async Balanced Mode (connect request, mod-8) + DISC, // Disconnect + DM, // Disconnected Mode (response: "I won't connect") + UA, // Unnumbered Acknowledge + FRMR, // Frame Reject + UI, // Unnumbered Information (connectionless, e.g. APRS / beacons) + Unknown, +}; + +// A decoded / to-be-encoded AX.25 frame (mod-8 sequence space). +struct Frame { + Address dest; + Address src; + QVector
via; // digipeater path (0-8 addresses) + FrameType type{FrameType::Unknown}; + + // Sequence numbers (valid for I and S frames). 0-7. + int ns{0}; // send sequence N(S) (I frames only) + int nr{0}; // receive sequence N(R) (I and S frames) + + bool pollFinal{false}; // P/F bit + // True when this frame is a *command* (vs response). For mod-8 AX.25 v2.x the + // command/response sense lives in the two address C bits; we surface it here. + bool command{true}; + + quint8 pid{0xF0}; // protocol id (I and UI frames). 0xF0 = no layer 3. + QByteArray info; // payload (I and UI frames) + + // Build the raw on-air frame (address..info, no FCS). + QByteArray encode() const; + + // Parse a raw frame (address..info, no FCS). Returns nullopt if the address + // fields or control octet are malformed. + static std::optional decode(const QByteArray& rawNoFcs); + + // Convenience constructors for the data-link state machine. + static Frame makeU(const Address& dest, const Address& src, FrameType u, + bool pollFinal, bool command); + static Frame makeS(const Address& dest, const Address& src, FrameType s, + int nr, bool pollFinal, bool command); + static Frame makeI(const Address& dest, const Address& src, int ns, int nr, + bool pollFinal, const QByteArray& info, quint8 pid = 0xF0); + static Frame makeUI(const Address& dest, const Address& src, + const QVector
& via, const QByteArray& info, + quint8 pid = 0xF0); +}; + +QString frameTypeName(FrameType type); + +} // namespace AetherSDR::ax25 diff --git a/src/core/tnc/Ax25Connection.cpp b/src/core/tnc/Ax25Connection.cpp new file mode 100644 index 00000000..6fe8a2f5 --- /dev/null +++ b/src/core/tnc/Ax25Connection.cpp @@ -0,0 +1,327 @@ +#include "core/tnc/Ax25Connection.h" + +#include + +namespace AetherSDR { + +using ax25::Address; +using ax25::Frame; +using ax25::FrameType; + +Ax25Connection::Ax25Connection(QObject* parent) + : QObject(parent) +{ + m_t1 = new QTimer(this); + m_t1->setSingleShot(true); + connect(m_t1, &QTimer::timeout, this, &Ax25Connection::onT1Timeout); +} + +Ax25Connection::~Ax25Connection() = default; + +int Ax25Connection::outstanding() const +{ + return (m_vs - m_va + 8) % 8; +} + +void Ax25Connection::startT1() +{ + m_t1->start(m_t1Ms); +} + +void Ax25Connection::stopT1() +{ + m_t1->stop(); +} + +void Ax25Connection::transmit(const Frame& frame) +{ + emit activity(QStringLiteral("TX %1 %2>%3%4%5") + .arg(ax25::frameTypeName(frame.type), + frame.src.toString(), + frame.dest.toString(), + frame.type == FrameType::I + ? QStringLiteral(" NS=%1 NR=%2").arg(frame.ns).arg(frame.nr) + : (frame.type == FrameType::RR || frame.type == FrameType::RNR + || frame.type == FrameType::REJ) + ? QStringLiteral(" NR=%1").arg(frame.nr) + : QString(), + frame.pollFinal ? QStringLiteral(" P/F") : QString())); + emit sendFrame(frame.encode()); +} + +void Ax25Connection::sendUFrame(FrameType type, bool pollFinal, bool command) +{ + transmit(Frame::makeU(m_remote, m_local, type, pollFinal, command)); +} + +void Ax25Connection::sendSupervisory(FrameType type, bool pollFinal, bool command) +{ + transmit(Frame::makeS(m_remote, m_local, type, m_vr, pollFinal, command)); +} + +void Ax25Connection::enterConnected(const Address& peer) +{ + m_remote = peer; + m_state = State::Connected; + m_vs = m_vr = m_va = 0; + m_retryCount = 0; + m_peerBusy = false; + m_sendBuffer.clear(); + for (bool& valid : m_iFrameValid) + valid = false; + stopT1(); + emit activity(QStringLiteral("Connected to %1").arg(peer.toString())); + emit connected(peer); +} + +void Ax25Connection::enterDisconnected(bool byPeer) +{ + const Address peer = m_remote; + stopT1(); + m_state = State::Disconnected; + m_sendBuffer.clear(); + for (bool& valid : m_iFrameValid) + valid = false; + m_vs = m_vr = m_va = 0; + m_retryCount = 0; + m_peerBusy = false; + m_remote = Address{}; + emit activity(QStringLiteral("Disconnected from %1 (%2)") + .arg(peer.toString(), byPeer ? QStringLiteral("by peer") : QStringLiteral("local"))); + emit disconnected(peer, byPeer); +} + +void Ax25Connection::onFrameReceived(const Frame& frame) +{ + // Only react to frames addressed to us. + if (frame.dest != m_local) + return; + + emit activity(QStringLiteral("RX %1 %2>%3%4") + .arg(ax25::frameTypeName(frame.type), + frame.src.toString(), + frame.dest.toString(), + frame.type == FrameType::I + ? QStringLiteral(" NS=%1 NR=%2").arg(frame.ns).arg(frame.nr) + : QString())); + + switch (frame.type) { + case FrameType::SABM: { + // One caller at a time: if busy with a different peer, refuse politely. + if (m_state == State::Connected && m_remote != frame.src) { + transmit(Frame::makeU(frame.src, m_local, FrameType::DM, + frame.pollFinal, /*command=*/false)); + emit activity(QStringLiteral("Refused %1 (busy with %2)") + .arg(frame.src.toString(), m_remote.toString())); + return; + } + // Accept (new connect or reconnect). UA first, then announce. + m_remote = frame.src; + transmit(Frame::makeU(m_remote, m_local, FrameType::UA, + frame.pollFinal, /*command=*/false)); + enterConnected(frame.src); + break; + } + case FrameType::DISC: { + if (m_state != State::Disconnected && frame.src == m_remote) { + sendUFrame(FrameType::UA, frame.pollFinal, /*command=*/false); + enterDisconnected(/*byPeer=*/true); + } else { + transmit(Frame::makeU(frame.src, m_local, FrameType::DM, + frame.pollFinal, /*command=*/false)); + } + break; + } + case FrameType::UA: { + if (m_state == State::Disconnecting) + enterDisconnected(/*byPeer=*/false); + break; + } + case FrameType::DM: { + if (m_state != State::Disconnected) + enterDisconnected(/*byPeer=*/true); + break; + } + case FrameType::I: { + if (m_state != State::Connected) { + transmit(Frame::makeU(frame.src, m_local, FrameType::DM, + frame.pollFinal, /*command=*/false)); + break; + } + ackUpTo(frame.nr); + if (frame.ns == m_vr) { + // In-sequence: accept and advance V(R). + if (!frame.info.isEmpty()) + emit dataReceived(frame.info); + m_vr = (m_vr + 1) % 8; + pumpOutbound(); + // Acknowledge. A command with the poll bit demands a final response. + sendSupervisory(FrameType::RR, /*pollFinal=*/frame.pollFinal, + /*command=*/false); + } else { + // Out of sequence: ask for retransmission from V(R). + sendSupervisory(FrameType::REJ, /*pollFinal=*/frame.pollFinal, + /*command=*/false); + } + break; + } + case FrameType::RR: { + if (m_state != State::Connected) + break; + m_peerBusy = false; + ackUpTo(frame.nr); + // A command poll requires us to respond with a final. + if (frame.command && frame.pollFinal) + sendSupervisory(FrameType::RR, /*pollFinal=*/true, /*command=*/false); + pumpOutbound(); + break; + } + case FrameType::RNR: { + if (m_state != State::Connected) + break; + m_peerBusy = true; + ackUpTo(frame.nr); + if (frame.command && frame.pollFinal) + sendSupervisory(FrameType::RR, /*pollFinal=*/true, /*command=*/false); + break; + } + case FrameType::REJ: { + if (m_state != State::Connected) + break; + m_peerBusy = false; + ackUpTo(frame.nr); + // Retransmit everything from the rejected sequence number forward. + m_vs = frame.nr; + m_retryCount = 0; + pumpOutbound(); + break; + } + case FrameType::FRMR: { + // Protocol error reported by peer: re-establish by tearing down. + if (m_state != State::Disconnected) { + sendUFrame(FrameType::DM, /*pollFinal=*/false, /*command=*/false); + enterDisconnected(/*byPeer=*/true); + } + break; + } + case FrameType::UI: + case FrameType::Unknown: + break; // UI handled elsewhere (beacons / monitor); ignore here. + } +} + +void Ax25Connection::ackUpTo(int nr) +{ + // Free acknowledged I-frame slots in the range [V(A), nr). + while (m_va != nr) { + m_iFrameValid[m_va] = false; + m_sentIFrames[m_va].clear(); + m_va = (m_va + 1) % 8; + } + m_retryCount = 0; + if (outstanding() == 0) + stopT1(); + else + startT1(); +} + +void Ax25Connection::sendData(const QByteArray& data) +{ + if (m_state != State::Connected || data.isEmpty()) + return; + m_sendBuffer.append(data); + pumpOutbound(); +} + +void Ax25Connection::pumpOutbound() +{ + if (m_state != State::Connected || m_peerBusy) + return; + while (!m_sendBuffer.isEmpty() && outstanding() < kWindow) { + const QByteArray segment = m_sendBuffer.left(m_paclen); + m_sendBuffer.remove(0, segment.size()); + const int ns = m_vs; + Frame iFrame = Frame::makeI(m_remote, m_local, ns, m_vr, + /*pollFinal=*/false, segment); + m_sentIFrames[ns] = iFrame.encode(); + m_iFrameValid[ns] = true; + m_vs = (m_vs + 1) % 8; + transmit(iFrame); + startT1(); + } +} + +void Ax25Connection::retransmitUnacked() +{ + // Resend every unacknowledged I-frame, polling on the last to solicit an ack. + int seq = m_va; + int count = outstanding(); + int sent = 0; + while (count-- > 0) { + if (m_iFrameValid[seq]) { + QByteArray raw = m_sentIFrames[seq]; + // Update N(R) and set poll on the final retransmitted frame. + auto decoded = Frame::decode(raw); + if (decoded) { + decoded->nr = m_vr; + decoded->pollFinal = (count == 0); + raw = decoded->encode(); + m_sentIFrames[seq] = raw; + } + emit sendFrame(raw); + ++sent; + } + seq = (seq + 1) % 8; + } + if (sent == 0) { + // Nothing to resend; poll the peer to checkpoint. + sendSupervisory(FrameType::RR, /*pollFinal=*/true, /*command=*/true); + } + emit activity(QStringLiteral("T1 retransmit (%1 frame(s), try %2/%3)") + .arg(sent).arg(m_retryCount).arg(m_n2)); + startT1(); +} + +void Ax25Connection::onT1Timeout() +{ + if (m_state == State::Disconnected) + return; + + if (m_retryCount >= m_n2) { + emit activity(QStringLiteral("Link failure: no response after %1 retries").arg(m_n2)); + if (m_state == State::Connected || m_state == State::Disconnecting) { + sendUFrame(FrameType::DM, /*pollFinal=*/false, /*command=*/false); + enterDisconnected(/*byPeer=*/true); + } + return; + } + ++m_retryCount; + + if (m_state == State::Disconnecting) { + sendUFrame(FrameType::DISC, /*pollFinal=*/true, /*command=*/true); + startT1(); + return; + } + retransmitUnacked(); +} + +void Ax25Connection::disconnect() +{ + if (m_state == State::Disconnected) + return; + m_state = State::Disconnecting; + m_retryCount = 0; + m_sendBuffer.clear(); + sendUFrame(FrameType::DISC, /*pollFinal=*/true, /*command=*/true); + startT1(); +} + +void Ax25Connection::reset() +{ + if (m_state != State::Disconnected) + enterDisconnected(/*byPeer=*/false); + else + stopT1(); +} + +} // namespace AetherSDR diff --git a/src/core/tnc/Ax25Connection.h b/src/core/tnc/Ax25Connection.h new file mode 100644 index 00000000..551eefe1 --- /dev/null +++ b/src/core/tnc/Ax25Connection.h @@ -0,0 +1,120 @@ +#pragma once + +#include "core/tnc/Ax25.h" + +#include +#include +#include + +class QTimer; + +namespace AetherSDR { + +// A single-connection AX.25 v2.0 connected-mode (LAPB) data-link state machine, +// mod-8 sequence space. It handles exactly one peer at a time, which is all the +// Personal Mailbox System needs (one simultaneous caller). +// +// Responsibilities: +// - Accept an inbound SABM (connect request) and reply UA. +// - Track V(S)/V(R)/V(A), acknowledge received I-frames with RR. +// - Segment outbound application data into I-frames (<= paclen) and retransmit +// unacknowledged I-frames on the T1 timeout, up to N2 retries. +// - Honour RR/RNR/REJ, poll/final, and tear down on DISC or N2 exhaustion. +// +// It is transport-agnostic: it consumes already-decoded ax25::Frame objects and +// emits raw frames (address..info, no FCS) for the caller to key on the air via +// AetherAx25LibmodemShim::buildTransmitAudioFromFrame(). Timers run on the +// owning (GUI) thread. This class is reusable by the future AX.25 node/digipeater. +class Ax25Connection : public QObject { + Q_OBJECT + +public: + enum class State { + Disconnected, // no peer + Connected, // information transfer + Disconnecting // DISC sent, awaiting UA + }; + + explicit Ax25Connection(QObject* parent = nullptr); + ~Ax25Connection() override; + + // Our own address (the PMS callsign-SSID we answer to). + void setLocalAddress(const ax25::Address& local) { m_local = local; } + ax25::Address localAddress() const { return m_local; } + ax25::Address remoteAddress() const { return m_remote; } + + State state() const { return m_state; } + bool isConnected() const { return m_state == State::Connected; } + + // Tunables. Defaults are sized for 1200-baud VHF FM with PTT overhead. + void setPaclen(int bytes) { m_paclen = qBound(16, bytes, 256); } + void setMaxRetries(int n2) { m_n2 = qBound(1, n2, 20); } + void setRetryTimeoutMs(int t1) { m_t1Ms = qBound(1000, t1, 60000); } + + // Feed every decoded frame here. Frames not addressed to our local address + // (dest mismatch) are ignored, so the caller can pass all RX traffic. + void onFrameReceived(const ax25::Frame& frame); + + // Queue application data to send to the connected peer. No-op if not + // connected. Data is buffered and segmented into I-frames automatically. + void sendData(const QByteArray& data); + + // Initiate a graceful disconnect (sends DISC). + void disconnect(); + + // Drop the link immediately without sending anything (e.g. on shutdown). + void reset(); + +signals: + // A raw AX.25 frame (address..info, no FCS) is ready to transmit. + void sendFrame(const QByteArray& rawNoFcs); + + // Connection established with the given peer. + void connected(const ax25::Address& peer); + + // Connection torn down. `byPeer` is true when the peer initiated (DISC) or + // the link failed (N2 exhausted); false for a locally requested disconnect. + void disconnected(const ax25::Address& peer, bool byPeer); + + // Reassembled application data received from the peer (I-frame info fields). + void dataReceived(const QByteArray& data); + + // Human-readable protocol activity for logging. + void activity(const QString& message); + +private: + void enterConnected(const ax25::Address& peer); + void enterDisconnected(bool byPeer); + void transmit(const ax25::Frame& frame); + void sendUFrame(ax25::FrameType type, bool pollFinal, bool command); + void sendSupervisory(ax25::FrameType type, bool pollFinal, bool command); + void pumpOutbound(); // segment send buffer -> I-frames + void ackUpTo(int nr); // slide window per received N(R) + void retransmitUnacked(); // T1 expiry + void startT1(); + void stopT1(); + void onT1Timeout(); + int outstanding() const; // unacked I-frames in flight + + ax25::Address m_local; + ax25::Address m_remote; + State m_state{State::Disconnected}; + + int m_vs{0}; // V(S) next send sequence + int m_vr{0}; // V(R) next expected receive sequence + int m_va{0}; // V(A) last acknowledged send sequence + + static constexpr int kWindow = 4; // k: max outstanding I-frames (mod-8 safe) + int m_paclen{128}; + int m_n2{8}; + int m_t1Ms{6000}; + int m_retryCount{0}; + bool m_peerBusy{false}; // peer sent RNR + + QByteArray m_sendBuffer; // app data awaiting segmentation + QByteArray m_sentIFrames[8]; // by N(S), for retransmission + bool m_iFrameValid[8]{}; // slot occupied + QTimer* m_t1{nullptr}; +}; + +} // namespace AetherSDR diff --git a/src/gui/Ax25HfPacketDecodeDialog.cpp b/src/gui/Ax25HfPacketDecodeDialog.cpp index 24f5b6cd..d87b199a 100644 --- a/src/gui/Ax25HfPacketDecodeDialog.cpp +++ b/src/gui/Ax25HfPacketDecodeDialog.cpp @@ -7,6 +7,7 @@ #include "core/ThemeManager.h" #include "core/tnc/Ax25FrameFormatter.h" #include "core/tnc/KissTncServer.h" +#include "core/pms/PmsMailbox.h" #include "models/RadioModel.h" #include "models/SliceModel.h" #include "models/TransmitModel.h" @@ -51,6 +52,15 @@ constexpr auto kTncEnabledSetting = "AetherModemKissTncEnabled"; constexpr auto kTncStartOnStartupSetting = "AetherModemKissTncStartOnStartup"; constexpr auto kTncPortSetting = "AetherModemKissTncPort"; constexpr int kTncDefaultPort = 8001; + +// Personal Mailbox System (PMS) settings keys (persisted in AppSettings). +constexpr auto kPmsEnabledSetting = "AetherModemPmsEnabled"; +constexpr auto kPmsSsidSetting = "AetherModemPmsSsid"; +constexpr auto kPmsWelcomeSetting = "AetherModemPmsWelcome"; +constexpr auto kPmsBeaconEnabledSetting = "AetherModemPmsBeaconEnabled"; +constexpr auto kPmsBeaconTextSetting = "AetherModemPmsBeaconText"; +constexpr int kPmsDefaultSsid = 1; + constexpr int kAudioCaptureSeconds = 180; constexpr int kTxDaxSettleMs = 150; constexpr int kTxLeadMs = 200; @@ -510,6 +520,7 @@ Ax25HfPacketDecodeDialog::Ax25HfPacketDecodeDialog(AudioEngine* audio, m_shim = new AetherAx25LibmodemShim(this); m_kissServer = new KissTncServer(this); + m_pms = new PmsMailbox(this); m_heartbeatTimer = new QTimer(this); m_heartbeatTimer->setInterval(1000); m_txPaceTimer = new QTimer(this); @@ -525,14 +536,18 @@ Ax25HfPacketDecodeDialog::Ax25HfPacketDecodeDialog(AudioEngine* audio, tabs->setSpacing(0); m_ax25Tab = tabButton(QStringLiteral("AX.25"), true, tabsFrame); m_kissTab = tabButton(QStringLiteral("KISS TNC"), false, tabsFrame); + m_mailboxTab = tabButton(QStringLiteral("Mailbox"), false, tabsFrame); m_ax25Tab->setEnabled(true); m_kissTab->setEnabled(true); + m_mailboxTab->setEnabled(true); auto* tabGroup = new QButtonGroup(this); tabGroup->setExclusive(true); tabGroup->addButton(m_ax25Tab, 0); tabGroup->addButton(m_kissTab, 1); + tabGroup->addButton(m_mailboxTab, 2); tabs->addWidget(m_ax25Tab, 1); tabs->addWidget(m_kissTab, 1); + tabs->addWidget(m_mailboxTab, 1); root->addWidget(tabsFrame); m_tabStack = new QStackedWidget(bodyWidget()); @@ -603,6 +618,8 @@ Ax25HfPacketDecodeDialog::Ax25HfPacketDecodeDialog(AudioEngine* audio, // KISS TNC page (built lazily into the same stack). m_tabStack->addWidget(buildKissTncPage()); + // Mailbox (PMS) page. + m_tabStack->addWidget(buildMailboxPage()); auto* logFrame = panel(QStringLiteral("LogFrame"), bodyWidget()); auto* logLayout = new QVBoxLayout(logFrame); @@ -723,6 +740,13 @@ Ax25HfPacketDecodeDialog::Ax25HfPacketDecodeDialog(AudioEngine* audio, refreshTncStatus(); } }); + // RX -> Mailbox: feed every decoded frame to the PMS (heard list always; + // connected-mode handling only for frames addressed to our PMS callsign). + connect(m_shim, &AetherAx25LibmodemShim::frameDecoded, this, + [this](const Ax25DecodedFrame& frame) { + if (m_pms && !frame.ax25FrameNoFcs.isEmpty()) + m_pms->onAirFrame(frame.ax25FrameNoFcs); + }); connect(m_shim, &AetherAx25LibmodemShim::diagnosticsUpdated, this, &Ax25HfPacketDecodeDialog::updateDiagnostics); connect(m_shim, &AetherAx25LibmodemShim::statusChanged, @@ -779,6 +803,32 @@ Ax25HfPacketDecodeDialog::Ax25HfPacketDecodeDialog(AudioEngine* audio, } }); + // Mailbox (PMS) wiring. + connect(m_pms, &PmsMailbox::transmitFrame, this, [this](const QByteArray& raw) { + if (raw.isEmpty() || !m_audio || !m_radio) + return; + m_kissTxQueue.enqueue(raw); // shares the one-at-a-time keying/pacing path + maybeStartNextKissTx(); + }); + connect(m_pms, &PmsMailbox::activity, this, &Ax25HfPacketDecodeDialog::appendSystemLine); + connect(m_pms, &PmsMailbox::stateChanged, this, [this] { refreshPmsStatus(); }); + connect(m_pmsEnable, &QCheckBox::toggled, this, [this](bool on) { + setPmsEnabled(on, true); + }); + connect(m_pmsSsid, qOverload(&QSpinBox::valueChanged), this, [this](int) { + applyPmsConfigFromUi(true); + refreshPmsStatus(); + }); + connect(m_pmsWelcome, &QLineEdit::editingFinished, this, [this] { + applyPmsConfigFromUi(true); + }); + connect(m_pmsBeaconText, &QLineEdit::editingFinished, this, [this] { + applyPmsConfigFromUi(true); + }); + connect(m_pmsBeaconEnable, &QCheckBox::toggled, this, [this](bool) { + applyPmsConfigFromUi(true); + }); + appendSystemLine(QStringLiteral("AetherModem initialized.")); appendSystemLine(QStringLiteral("Enable Modem to start the RX audio tap.")); appendSystemLine(QStringLiteral("TX accepts raw payload text or full SRC>DST,path:payload syntax.")); @@ -787,6 +837,18 @@ Ax25HfPacketDecodeDialog::Ax25HfPacketDecodeDialog(AudioEngine* audio, refreshTransmitControls(); applyTncStartOnStartup(); refreshTncStatus(); + + // Restore mailbox (PMS) state and version SID. +#ifdef AETHERSDR_VERSION + m_pms->setVersionString(QString::fromLatin1(AETHERSDR_VERSION)); +#endif + applyPmsConfigFromUi(false); + const bool pmsOn = AppSettings::instance() + .value(kPmsEnabledSetting, QStringLiteral("False")).toString() + == QStringLiteral("True"); + if (pmsOn && m_pmsEnable) + m_pmsEnable->setChecked(true); // fires setPmsEnabled() via the toggled connection + refreshPmsStatus(); } Ax25HfPacketDecodeDialog::~Ax25HfPacketDecodeDialog() @@ -1803,4 +1865,226 @@ void Ax25HfPacketDecodeDialog::refreshTncStatus() } } +// --------------------------------------------------------------------------- +// Personal Mailbox System (PMS) tab +// --------------------------------------------------------------------------- + +QWidget* Ax25HfPacketDecodeDialog::buildMailboxPage() +{ + auto* page = new QWidget(m_tabStack); + auto* layout = new QVBoxLayout(page); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(10); + + auto* controlsFrame = panel(QStringLiteral("ControlsFrame"), page); + auto* controls = new QHBoxLayout(controlsFrame); + controls->setContentsMargins(16, 14, 16, 14); + controls->setSpacing(20); + + auto* mboxCell = panel(QStringLiteral("ControlCell"), controlsFrame); + auto* mboxLayout = new QVBoxLayout(mboxCell); + mboxLayout->setContentsMargins(0, 0, 20, 0); + mboxLayout->setSpacing(12); + mboxLayout->addWidget(sectionLabel(QStringLiteral("MAILBOX (PMS)"), mboxCell)); + m_pmsEnable = new QCheckBox(QStringLiteral("Enable Mailbox (PMS)"), mboxCell); + mboxLayout->addWidget(m_pmsEnable); + m_pmsBeaconEnable = new QCheckBox(QStringLiteral("Send hourly beacon"), mboxCell); + mboxLayout->addWidget(m_pmsBeaconEnable); + controls->addWidget(mboxCell, 2); + + auto* ssidCell = panel(QStringLiteral("ControlCell"), controlsFrame); + auto* ssidLayout = new QVBoxLayout(ssidCell); + ssidLayout->setContentsMargins(0, 0, 20, 0); + ssidLayout->setSpacing(12); + ssidLayout->addWidget(sectionLabel(QStringLiteral("ANSWER SSID"), ssidCell)); + m_pmsSsid = new QSpinBox(ssidCell); + m_pmsSsid->setRange(0, 15); + m_pmsSsid->setValue(kPmsDefaultSsid); + m_pmsSsid->setMaximumWidth(120); + ssidLayout->addWidget(m_pmsSsid); + controls->addWidget(ssidCell, 1); + controls->addStretch(1); + layout->addWidget(controlsFrame); + + auto* welcomeFrame = panel(QStringLiteral("ControlsFrame"), page); + auto* welcomeLayout = new QVBoxLayout(welcomeFrame); + welcomeLayout->setContentsMargins(16, 12, 16, 12); + welcomeLayout->setSpacing(8); + welcomeLayout->addWidget(sectionLabel(QStringLiteral("WELCOME / PTEXT"), welcomeFrame)); + m_pmsWelcome = new QLineEdit(welcomeFrame); + m_pmsWelcome->setPlaceholderText( + QStringLiteral("Shown to callers after they connect (optional).")); + welcomeLayout->addWidget(m_pmsWelcome); + welcomeLayout->addWidget(sectionLabel(QStringLiteral("BEACON TEXT"), welcomeFrame)); + m_pmsBeaconText = new QLineEdit(welcomeFrame); + m_pmsBeaconText->setPlaceholderText( + QStringLiteral("Hourly AX.25 beacon announcing the mailbox is online.")); + welcomeLayout->addWidget(m_pmsBeaconText); + layout->addWidget(welcomeFrame); + + auto* statusFrame = statusPanel(QStringLiteral("MAILBOX STATUS"), + &m_pmsStatusDot, &m_pmsStatusValue, page); + layout->addWidget(statusFrame); + + auto* callersFrame = panel(QStringLiteral("StatusFrame"), page); + auto* callersLayout = new QVBoxLayout(callersFrame); + callersLayout->setContentsMargins(16, 12, 16, 12); + callersLayout->setSpacing(8); + callersLayout->addWidget(sectionLabel(QStringLiteral("LAST CALLERS"), callersFrame)); + m_pmsCallersValue = new QLabel(callersFrame); + m_pmsCallersValue->setObjectName(QStringLiteral("StatusValue")); + m_pmsCallersValue->setWordWrap(true); + callersLayout->addWidget(m_pmsCallersValue); + callersLayout->addWidget(sectionLabel(QStringLiteral("STATISTICS"), callersFrame)); + m_pmsStatsValue = new QLabel(callersFrame); + m_pmsStatsValue->setObjectName(QStringLiteral("StatusValue")); + m_pmsStatsValue->setWordWrap(true); + callersLayout->addWidget(m_pmsStatsValue); + layout->addWidget(callersFrame); + + auto* help = new QLabel( + QStringLiteral("A single remote caller can connect to - over " + "1200-baud AX.25 and read, list, and send messages, list who has " + "been heard, then disconnect. The station callsign comes from the " + "radio; set the SSID the mailbox answers on. Enabling the mailbox " + "turns the modem on. Settings persist across restarts."), + page); + help->setObjectName(QStringLiteral("StatusValue")); + help->setWordWrap(true); + layout->addWidget(help); + layout->addStretch(1); + + // Seed control values from settings (before signals are wired in the ctor). + m_pmsSsid->setValue(AppSettings::instance() + .value(kPmsSsidSetting, QString::number(kPmsDefaultSsid)).toInt()); + m_pmsWelcome->setText(AppSettings::instance() + .value(kPmsWelcomeSetting, QString()).toString()); + m_pmsBeaconText->setText(AppSettings::instance() + .value(kPmsBeaconTextSetting, + QStringLiteral("AetherMailbox online - connect for messages")).toString()); + m_pmsBeaconEnable->setChecked(AppSettings::instance() + .value(kPmsBeaconEnabledSetting, QStringLiteral("False")).toString() + == QStringLiteral("True")); + + return page; +} + +void Ax25HfPacketDecodeDialog::applyPmsConfigFromUi(bool persist) +{ + if (!m_pms) + return; + const QString call = m_radio ? m_radio->callsign().trimmed().toUpper() : QString(); + if (!call.isEmpty()) + m_pms->setLocalCallsign(call); + if (m_pmsSsid) + m_pms->setSsid(m_pmsSsid->value()); + if (m_pmsWelcome) + m_pms->setWelcomeText(m_pmsWelcome->text()); + if (m_pmsBeaconText) + m_pms->setBeaconText(m_pmsBeaconText->text()); + if (m_pmsBeaconEnable) + m_pms->setBeaconEnabled(m_pmsBeaconEnable->isChecked()); + + if (persist) { + auto& s = AppSettings::instance(); + if (m_pmsSsid) + s.setValue(kPmsSsidSetting, QString::number(m_pmsSsid->value())); + if (m_pmsWelcome) + s.setValue(kPmsWelcomeSetting, m_pmsWelcome->text()); + if (m_pmsBeaconText) + s.setValue(kPmsBeaconTextSetting, m_pmsBeaconText->text()); + if (m_pmsBeaconEnable) + s.setValue(kPmsBeaconEnabledSetting, + m_pmsBeaconEnable->isChecked() ? QStringLiteral("True") + : QStringLiteral("False")); + s.save(); + } +} + +void Ax25HfPacketDecodeDialog::setPmsEnabled(bool enabled, bool persist) +{ + if (persist) { + AppSettings::instance().setValue(kPmsEnabledSetting, + enabled ? QStringLiteral("True") : QStringLiteral("False")); + AppSettings::instance().save(); + } + + if (enabled) { + // The mailbox needs the modem RX tap running to receive callers. + if (m_enableDecode && !m_enableDecode->isChecked()) { + appendSystemLine(QStringLiteral("Enabling the modem for the mailbox (PMS).")); + m_enableDecode->setChecked(true); + } + const QString call = m_radio ? m_radio->callsign().trimmed().toUpper() : QString(); + if (call.isEmpty()) { + appendSystemLine(QStringLiteral( + "Mailbox: set a station callsign on the radio before enabling the PMS.")); + if (m_pmsEnable) { + QSignalBlocker blocker(m_pmsEnable); + m_pmsEnable->setChecked(false); + } + refreshPmsStatus(); + return; + } + applyPmsConfigFromUi(false); + m_pms->setEnabled(true); + appendSystemLine(QStringLiteral("Mailbox (PMS) listening as %1.") + .arg(m_pms->localAddress().toString())); + } else { + m_pms->setEnabled(false); + appendSystemLine(QStringLiteral("Mailbox (PMS) disabled.")); + } + refreshPmsStatus(); +} + +void Ax25HfPacketDecodeDialog::refreshPmsStatus() +{ + if (!m_pmsStatusValue || !m_pms) + return; + + const bool enabled = m_pms->isEnabled(); + QString status; + if (!enabled) { + status = QStringLiteral("Disabled"); + } else if (m_pms->isCallerConnected()) { + status = QStringLiteral("Connected: %1").arg(m_pms->connectedCaller()); + } else { + status = QStringLiteral("Listening as %1").arg(m_pms->localAddress().toString()); + } + m_pmsStatusValue->setText(status); + if (m_pmsStatusDot) { + m_pmsStatusDot->setFixedSize(12, 12); + m_pmsStatusDot->setStyleSheet(enabled + ? QStringLiteral("background:#5fce66;border-radius:6px;") + : QStringLiteral("background:#8190a3;border-radius:6px;")); + } + + if (m_pmsCallersValue) { + const QStringList callers = m_pms->lastCallers(5); + m_pmsCallersValue->setText(callers.isEmpty() + ? QStringLiteral("(no callers yet)") + : callers.join(QStringLiteral("\n"))); + } + + if (m_pmsStatsValue) { + const qint64 freeBytes = m_pms->freeDiskBytes(); + auto humanBytes = [](qint64 bytes) -> QString { + const char* units[] = {"B", "KB", "MB", "GB", "TB"}; + double value = static_cast(bytes); + int unit = 0; + while (value >= 1024.0 && unit < 4) { + value /= 1024.0; + ++unit; + } + return QStringLiteral("%1 %2").arg(value, 0, 'f', 1).arg(QLatin1String(units[unit])); + }; + m_pmsStatsValue->setText(QStringLiteral( + "%1 message(s) | %2 caller(s) logged | %3 station(s) heard | %4 free") + .arg(m_pms->messageCount()) + .arg(m_pms->callerCount()) + .arg(m_pms->heardSummary(100000).size()) + .arg(humanBytes(freeBytes))); + } +} + } // namespace AetherSDR diff --git a/src/gui/Ax25HfPacketDecodeDialog.h b/src/gui/Ax25HfPacketDecodeDialog.h index cefe1e18..56fb4faf 100644 --- a/src/gui/Ax25HfPacketDecodeDialog.h +++ b/src/gui/Ax25HfPacketDecodeDialog.h @@ -25,6 +25,7 @@ namespace AetherSDR { class AudioEngine; class KissTncServer; class PacketActivityWidget; +class PmsMailbox; class RadioModel; class SliceModel; @@ -52,6 +53,12 @@ class Ax25HfPacketDecodeDialog : public PersistentDialog { void paceTransmitAudio(); void finishTransmit(bool aborted, const QString& reason); + // Personal Mailbox System (PMS) tab + service wiring. + QWidget* buildMailboxPage(); + void setPmsEnabled(bool enabled, bool persist); + void applyPmsConfigFromUi(bool persist); + void refreshPmsStatus(); + // KISS TNC tab + TCP server wiring. QWidget* buildKissTncPage(); void setTncEnabled(bool enabled, bool persist); @@ -140,6 +147,19 @@ class Ax25HfPacketDecodeDialog : public PersistentDialog { QQueue m_kissTxQueue; quint64 m_kissTxCount{0}; quint64 m_kissRxCount{0}; + + // Personal Mailbox System (PMS) service and its controls. + PmsMailbox* m_pms{nullptr}; + QAbstractButton* m_mailboxTab{nullptr}; + QCheckBox* m_pmsEnable{nullptr}; + QSpinBox* m_pmsSsid{nullptr}; + QLineEdit* m_pmsWelcome{nullptr}; + QCheckBox* m_pmsBeaconEnable{nullptr}; + QLineEdit* m_pmsBeaconText{nullptr}; + QLabel* m_pmsStatusDot{nullptr}; + QLabel* m_pmsStatusValue{nullptr}; + QLabel* m_pmsCallersValue{nullptr}; + QLabel* m_pmsStatsValue{nullptr}; }; } // namespace AetherSDR diff --git a/tests/pms_mailbox_test.cpp b/tests/pms_mailbox_test.cpp new file mode 100644 index 00000000..eac0c54f --- /dev/null +++ b/tests/pms_mailbox_test.cpp @@ -0,0 +1,265 @@ +// Unit + integration tests for the AX.25 connected-mode data link and the +// Personal Mailbox System (PMS). These exercise the protocol layer in isolation +// (no DSP / radio), driving the mailbox exactly as a remote caller's TNC would. + +#include "core/pms/PmsMailbox.h" +#include "core/tnc/Ax25.h" +#include "core/tnc/Ax25Connection.h" + +#include +#include +#include +#include +#include + +#include + +using namespace AetherSDR; +using AetherSDR::ax25::Address; +using AetherSDR::ax25::Frame; +using AetherSDR::ax25::FrameType; + +static int g_failures = 0; + +#define CHECK(cond, msg) \ + do { \ + if (!(cond)) { \ + std::fprintf(stderr, "FAIL: %s (%s:%d)\n", msg, __FILE__, __LINE__);\ + ++g_failures; \ + } \ + } while (0) + +static void testAddress() +{ + auto a = Address::parse(QStringLiteral("N0CALL-7")); + CHECK(a.has_value(), "parse N0CALL-7"); + CHECK(a && a->call == QLatin1String("N0CALL") && a->ssid == 7, "ssid parsed"); + CHECK(a && a->toString() == QLatin1String("N0CALL-7"), "toString with ssid"); + + auto b = Address::parse(QStringLiteral("w1aw")); + CHECK(b && b->call == QLatin1String("W1AW") && b->ssid == 0, "uppercased, ssid 0"); + CHECK(b && b->toString() == QLatin1String("W1AW"), "toString no ssid"); + + CHECK(!Address::parse(QStringLiteral("")).has_value(), "empty rejected"); + CHECK(!Address::parse(QStringLiteral("TOOLONGCALL")).has_value(), "overlong rejected"); +} + +static void testFrameRoundTrip() +{ + const Address dst{QStringLiteral("N0PMS"), 1, false, false}; + const Address src{QStringLiteral("K7ABC"), 0, false, false}; + + { + Frame s = Frame::makeU(dst, src, FrameType::SABM, /*pf=*/true, /*cmd=*/true); + auto d = Frame::decode(s.encode()); + CHECK(d && d->type == FrameType::SABM, "SABM type"); + CHECK(d && d->dest == dst && d->src == src, "SABM addresses"); + CHECK(d && d->pollFinal && d->command, "SABM P + command"); + } + { + Frame i = Frame::makeI(dst, src, /*ns=*/3, /*nr=*/5, /*pf=*/false, + QByteArray("hello")); + auto d = Frame::decode(i.encode()); + CHECK(d && d->type == FrameType::I, "I type"); + CHECK(d && d->ns == 3 && d->nr == 5, "I sequence numbers"); + CHECK(d && d->info == QByteArray("hello") && d->command, "I info + command"); + } + { + Frame r = Frame::makeS(dst, src, FrameType::RR, /*nr=*/2, /*pf=*/true, + /*cmd=*/false); + auto d = Frame::decode(r.encode()); + CHECK(d && d->type == FrameType::RR && d->nr == 2, "RR type + nr"); + CHECK(d && d->pollFinal && !d->command, "RR final + response"); + } + { + Frame ui = Frame::makeUI(Address{QStringLiteral("BEACON"), 0, false, false}, src, + {Address{QStringLiteral("WIDE1"), 1, false, false}}, + QByteArray("hi")); + auto d = Frame::decode(ui.encode()); + CHECK(d && d->type == FrameType::UI, "UI type"); + CHECK(d && d->via.size() == 1 && d->via.at(0).call == QLatin1String("WIDE1") + && d->via.at(0).ssid == 1, "UI via path"); + CHECK(d && d->info == QByteArray("hi"), "UI info"); + } +} + +static void testConnection() +{ + Ax25Connection conn; + const Address local{QStringLiteral("N0PMS"), 1, false, false}; + const Address peer{QStringLiteral("K7ABC"), 0, false, false}; + conn.setLocalAddress(local); + + QVector tx; + bool connected = false; + bool disconnected = false; + QByteArray rxData; + QObject::connect(&conn, &Ax25Connection::sendFrame, + [&](const QByteArray& f) { tx.append(f); }); + QObject::connect(&conn, &Ax25Connection::connected, + [&](const Address&) { connected = true; }); + QObject::connect(&conn, &Ax25Connection::disconnected, + [&](const Address&, bool) { disconnected = true; }); + QObject::connect(&conn, &Ax25Connection::dataReceived, + [&](const QByteArray& d) { rxData = d; }); + + conn.onFrameReceived(Frame::makeU(local, peer, FrameType::SABM, true, true)); + CHECK(connected, "connected after SABM"); + bool sawUA = false; + for (const QByteArray& f : tx) { + auto d = Frame::decode(f); + if (d && d->type == FrameType::UA) + sawUA = true; + } + CHECK(sawUA, "UA emitted in response to SABM"); + + // A frame for someone else must be ignored. + const Address other{QStringLiteral("N9XYZ"), 2, false, false}; + conn.onFrameReceived(Frame::makeU(other, peer, FrameType::SABM, true, true)); + + conn.onFrameReceived(Frame::makeI(local, peer, 0, 0, true, QByteArray("PING"))); + CHECK(rxData == QByteArray("PING"), "I-frame data delivered"); + + conn.onFrameReceived(Frame::makeU(local, peer, FrameType::DISC, true, true)); + CHECK(disconnected, "disconnected after DISC"); +} + +namespace { +// Drives the mailbox from the perspective of a remote caller's TNC. +struct Peer { + PmsMailbox* pms{nullptr}; + Address local; + Address peer; + QVector* allTx{nullptr}; + int consumed{0}; // index into allTx already turned into text + int peerNs{0}; + + int pmsVs() const + { + int n = 0; + for (const QByteArray& f : *allTx) { + auto d = Frame::decode(f); + if (d && d->type == FrameType::I && d->dest == peer) + ++n; + } + return n % 8; + } + + // New text the mailbox has sent since the last call. + QString drainText() + { + QByteArray t; + for (int i = consumed; i < allTx->size(); ++i) { + auto d = Frame::decode(allTx->at(i)); + if (d && d->type == FrameType::I && d->dest == peer) + t += d->info; + } + consumed = allTx->size(); + return QString::fromLatin1(t); + } + + void send(const QByteArray& line) + { + pms->onAirFrame( + Frame::makeI(local, peer, peerNs, pmsVs(), true, line).encode()); + peerNs = (peerNs + 1) % 8; + } +}; +} // namespace + +static void testMailbox() +{ + PmsMailbox pms; + pms.setVersionString(QStringLiteral("test")); + QVector tx; + QObject::connect(&pms, &PmsMailbox::transmitFrame, + [&](const QByteArray& f) { tx.append(f); }); + + pms.setLocalCallsign(QStringLiteral("N0PMS")); + pms.setSsid(1); + pms.setEnabled(true); + + const Address local{QStringLiteral("N0PMS"), 1, false, false}; + const Address peer{QStringLiteral("K7ABC"), 0, false, false}; + Peer p{&pms, local, peer, &tx, 0, 0}; + + // Connect. + pms.onAirFrame(Frame::makeU(local, peer, FrameType::SABM, true, true).encode()); + bool sawUA = false; + for (const QByteArray& f : tx) { + auto d = Frame::decode(f); + if (d && d->type == FrameType::UA) + sawUA = true; + } + CHECK(sawUA, "mailbox UA on connect"); + const QString greeting = p.drainText(); + CHECK(greeting.contains(QLatin1String("AetherMailbox")), "greeting names AetherMailbox"); + CHECK(greeting.contains(QLatin1String("K7ABC")), "greeting names the caller"); + CHECK(greeting.contains(QLatin1String("ENTER COMMAND")), "greeting shows prompt"); + + // Help. + p.send(QByteArray("H\r")); + const QString help = p.drainText(); + CHECK(help.contains(QLatin1String("B(ye)")), "help lists commands"); + + // Compose a private message to W1XYZ. + p.send(QByteArray("SP W1XYZ\r")); + CHECK(p.drainText().contains(QLatin1String("SUBJECT")), "send prompts for subject"); + p.send(QByteArray("Test subject\r")); + CHECK(p.drainText().contains(QLatin1String("ENTER MESSAGE")), "prompts for body"); + p.send(QByteArray("Line one of body\r")); + p.send(QByteArray("/EX\r")); + CHECK(p.drainText().contains(QLatin1String("SAVED")), "message saved"); + CHECK(pms.messageCount() == 1, "one message stored"); + + // List + read. + p.send(QByteArray("L\r")); + const QString list = p.drainText(); + CHECK(list.contains(QLatin1String("W1XYZ")), "list shows recipient"); + p.send(QByteArray("R 1\r")); + const QString read = p.drainText(); + CHECK(read.contains(QLatin1String("Test subject")), "read shows subject"); + CHECK(read.contains(QLatin1String("Line one of body")), "read shows body"); + + // Heard list should include the caller (we received their frames). + p.send(QByteArray("J\r")); + const QString jheard = p.drainText(); + CHECK(jheard.contains(QLatin1String("K7ABC")), "jheard lists the caller"); + + // Bye. + p.send(QByteArray("B\r")); + bool sawDisc = false; + for (const QByteArray& f : tx) { + auto d = Frame::decode(f); + if (d && d->type == FrameType::DISC) + sawDisc = true; + } + CHECK(sawDisc, "mailbox sends DISC on BYE"); +} + +int main(int argc, char** argv) +{ + // Isolate AppSettings / PMS storage from the real user config so the test is + // repeatable and never touches a live operator's mailbox. AppSettings derives + // its path from the home/config location, so redirect those before the + // singleton is first used. + const QString tmpHome = QDir::tempPath() + QStringLiteral("/aether_pms_test_home"); + QDir(tmpHome).removeRecursively(); + QDir().mkpath(tmpHome); + // PmsMailbox honours AETHER_PMS_DIR for its JSON store; point it at the clean + // temp dir so the test is repeatable and never touches a real mailbox. + qputenv("AETHER_PMS_DIR", (tmpHome + QStringLiteral("/pms")).toUtf8()); + + QCoreApplication app(argc, argv); + testAddress(); + testFrameRoundTrip(); + testConnection(); + testMailbox(); + + if (g_failures == 0) { + std::printf("All PMS mailbox tests passed.\n"); + return 0; + } + std::fprintf(stderr, "%d PMS mailbox test(s) failed.\n", g_failures); + return 1; +} From 22ef45ba14c12f38c91d2624943d89cd2784bfec Mon Sep 17 00:00:00 2001 From: jensenpat Date: Sat, 30 May 2026 08:23:59 -0700 Subject: [PATCH 2/5] =?UTF-8?q?feat(modem):=20PMS=20UI=20pass=20=E2=80=94?= =?UTF-8?q?=20listen+alias=20callsigns,=20slim=20status=20bar,=20stats/cal?= =?UTF-8?q?lers=20split?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/MODEM.md | 15 ++- src/core/pms/PmsMailbox.cpp | 51 +++++--- src/core/pms/PmsMailbox.h | 18 ++- src/core/tnc/Ax25Connection.cpp | 23 +++- src/core/tnc/Ax25Connection.h | 14 ++- src/gui/Ax25HfPacketDecodeDialog.cpp | 181 +++++++++++++++++---------- src/gui/Ax25HfPacketDecodeDialog.h | 3 +- tests/pms_mailbox_test.cpp | 51 +++++++- 8 files changed, 249 insertions(+), 107 deletions(-) diff --git a/docs/MODEM.md b/docs/MODEM.md index ff59bdb3..0a26e2a6 100644 --- a/docs/MODEM.md +++ b/docs/MODEM.md @@ -240,11 +240,16 @@ beacon** announces the mailbox is online and how to connect. entered after `SUBJECT:` and terminated with `/EX` or Ctrl-Z on its own line. Use `ALL` as the recipient for a public message. -The **Mailbox** config tab exposes Enable PMS, the answer SSID (the station -callsign comes from the radio), a welcome/PTEXT line, the hourly-beacon toggle -and text, the last five callers, and live stats (message count, callers logged, -stations heard, free disk). All settings persist in `AppSettings` -(`AetherModemPms*` keys) across restarts; enabling the PMS turns the modem on. +The **Mailbox** config tab exposes Enable PMS, the **listen callsign** the +mailbox answers on (full `CALL-SSID`, e.g. `KI6BCJ-10`), an optional **vanity +alias** it also answers on (e.g. `AETBBS` — AX.25 limits a callsign to 6 +characters plus an optional `-SSID`), a welcome/PTEXT line, the +hourly-beacon toggle and text, plus a stats row with **Statistics** on the left +and the **last callers** on the right. When a caller dials the alias, the whole +session (UA, greeting, every reply) uses the alias address. All settings persist +in `AppSettings` (`AetherModemPms*` keys) across restarts; enabling the PMS turns +the modem on. The bottom of the window is a slim status bar showing modem state, +gain, and a compact packet-activity strip. These layers are intentionally split so the planned APRS/AX.25 **digipeater** can reuse `Ax25`/`Ax25Connection` and the heard list directly. diff --git a/src/core/pms/PmsMailbox.cpp b/src/core/pms/PmsMailbox.cpp index 83902b17..4fbf9ec0 100644 --- a/src/core/pms/PmsMailbox.cpp +++ b/src/core/pms/PmsMailbox.cpp @@ -61,10 +61,11 @@ PmsMailbox::~PmsMailbox() Address PmsMailbox::localAddress() const { - Address a; - a.call = m_baseCall.trimmed().toUpper(); - a.ssid = m_ssid; - return a; + // Mid-session, surface the address the caller actually dialed; otherwise the + // configured primary listen address. + if (m_connected) + return m_link->localAddress(); + return m_listen; } void PmsMailbox::setEnabled(bool on) @@ -75,10 +76,14 @@ void PmsMailbox::setEnabled(bool on) if (on) { if (!m_loaded) loadAll(); - m_link->setLocalAddress(localAddress()); + m_link->setLocalAddress(m_listen); + m_link->setAliasAddress(m_alias); if (m_beaconEnabled) setBeaconIntervalMinutes(m_beaconIntervalMin); // (re)starts the timer - emit activity(QStringLiteral("PMS enabled as %1.").arg(localAddress().toString())); + emit activity(QStringLiteral("PMS enabled as %1%2.") + .arg(m_listen.toString(), + m_alias.isValid() ? QStringLiteral(" (alias %1)").arg(m_alias.toString()) + : QString())); } else { m_link->reset(); m_beaconTimer->stop(); @@ -88,23 +93,27 @@ void PmsMailbox::setEnabled(bool on) emit stateChanged(); } -void PmsMailbox::setLocalCallsign(const QString& baseCall) +void PmsMailbox::setListenCallsign(const QString& callWithSsid) { - const QString c = baseCall.trimmed().toUpper(); - if (c.isEmpty() || c == m_baseCall) + const auto parsed = ax25::Address::parse(callWithSsid); + const ax25::Address addr = parsed.value_or(ax25::Address{}); + if (addr == m_listen && addr.isValid() == m_listen.isValid()) return; - m_baseCall = c; - m_link->setLocalAddress(localAddress()); + m_listen = addr; + m_link->setLocalAddress(m_listen); emit stateChanged(); } -void PmsMailbox::setSsid(int ssid) +void PmsMailbox::setAliasCallsign(const QString& callWithSsid) { - ssid = qBound(0, ssid, 15); - if (ssid == m_ssid) + const QString trimmed = callWithSsid.trimmed(); + const ax25::Address addr = trimmed.isEmpty() + ? ax25::Address{} + : ax25::Address::parse(trimmed).value_or(ax25::Address{}); + if (addr == m_alias && addr.isValid() == m_alias.isValid()) return; - m_ssid = ssid; - m_link->setLocalAddress(localAddress()); + m_alias = addr; + m_link->setAliasAddress(m_alias); emit stateChanged(); } @@ -190,11 +199,15 @@ void PmsMailbox::onAirFrame(const QByteArray& rawNoFcs) if (!frame) return; - // Don't log our own transmissions in the heard list. - if (frame->src != localAddress()) + // Don't log our own transmissions (primary or alias) in the heard list. + const bool isUs = (m_listen.isValid() && frame->src == m_listen) + || (m_alias.isValid() && frame->src == m_alias); + if (!isUs) recordHeard(*frame); - if (m_enabled && frame->dest == localAddress()) + // Let the data link decide if the frame is addressed to us; it matches both + // the primary listen address and the optional vanity alias. + if (m_enabled) m_link->onFrameReceived(*frame); } diff --git a/src/core/pms/PmsMailbox.h b/src/core/pms/PmsMailbox.h index 689577e9..6b18dff6 100644 --- a/src/core/pms/PmsMailbox.h +++ b/src/core/pms/PmsMailbox.h @@ -59,10 +59,16 @@ class PmsMailbox : public QObject { // ---- Configuration (the GUI persists these via AppSettings) ------------- void setEnabled(bool on); bool isEnabled() const { return m_enabled; } - void setLocalCallsign(const QString& baseCall); // station callsign (no SSID) - QString localCallsign() const { return m_baseCall; } - void setSsid(int ssid); - int ssid() const { return m_ssid; } + // Full listen callsign-SSID the mailbox answers on, e.g. "KI6BCJ-10". + // Invalid/empty text leaves the mailbox without a primary address. + void setListenCallsign(const QString& callWithSsid); + QString listenCallsign() const { return m_listen.isValid() ? m_listen.toString() : QString(); } + // Optional vanity/alias callsign-SSID also answered, e.g. "AETHBBS". + // Empty text clears it. + void setAliasCallsign(const QString& callWithSsid); + QString aliasCallsign() const { return m_alias.isValid() ? m_alias.toString() : QString(); } + bool hasValidAddress() const { return m_listen.isValid(); } + // The configured primary address (or, mid-session, the one the caller dialed). ax25::Address localAddress() const; void setVersionString(const QString& version) { m_version = version; } @@ -154,8 +160,8 @@ public slots: QTimer* m_beaconTimer{nullptr}; bool m_enabled{false}; - QString m_baseCall{QStringLiteral("NOCALL")}; - int m_ssid{1}; + ax25::Address m_listen; // primary listen address (invalid until configured) + ax25::Address m_alias; // optional vanity/alias address (invalid = none) QString m_version{QStringLiteral("0.0")}; QString m_welcome; bool m_beaconEnabled{false}; diff --git a/src/core/tnc/Ax25Connection.cpp b/src/core/tnc/Ax25Connection.cpp index 6fe8a2f5..75db33e2 100644 --- a/src/core/tnc/Ax25Connection.cpp +++ b/src/core/tnc/Ax25Connection.cpp @@ -18,6 +18,13 @@ Ax25Connection::Ax25Connection(QObject* parent) Ax25Connection::~Ax25Connection() = default; +void Ax25Connection::setLocalAddress(const Address& local) +{ + m_primary = local; + if (m_state == State::Disconnected) + m_local = local; // idle: match/answer on the primary until a caller dials +} + int Ax25Connection::outstanding() const { return (m_vs - m_va + 8) % 8; @@ -86,6 +93,7 @@ void Ax25Connection::enterDisconnected(bool byPeer) m_retryCount = 0; m_peerBusy = false; m_remote = Address{}; + m_local = m_primary; // back to answering on either address when idle emit activity(QStringLiteral("Disconnected from %1 (%2)") .arg(peer.toString(), byPeer ? QStringLiteral("by peer") : QStringLiteral("local"))); emit disconnected(peer, byPeer); @@ -93,9 +101,20 @@ void Ax25Connection::enterDisconnected(bool byPeer) void Ax25Connection::onFrameReceived(const Frame& frame) { - // Only react to frames addressed to us. - if (frame.dest != m_local) + // Only react to frames addressed to us. While idle we answer on either the + // primary or the (optional) vanity alias; latch onto whichever the caller + // dialed so every response in the session uses that address. While in a + // session, only the dialed address matches. + if (m_state == State::Disconnected) { + if (frame.dest == m_primary) + m_local = m_primary; + else if (m_alias.isValid() && frame.dest == m_alias) + m_local = m_alias; + else + return; + } else if (frame.dest != m_local) { return; + } emit activity(QStringLiteral("RX %1 %2>%3%4") .arg(ax25::frameTypeName(frame.type), diff --git a/src/core/tnc/Ax25Connection.h b/src/core/tnc/Ax25Connection.h index 551eefe1..ae4e4895 100644 --- a/src/core/tnc/Ax25Connection.h +++ b/src/core/tnc/Ax25Connection.h @@ -38,8 +38,14 @@ class Ax25Connection : public QObject { explicit Ax25Connection(QObject* parent = nullptr); ~Ax25Connection() override; - // Our own address (the PMS callsign-SSID we answer to). - void setLocalAddress(const ax25::Address& local) { m_local = local; } + // Our own address (the primary callsign-SSID we answer to). When idle this + // also resets the active session address. + void setLocalAddress(const ax25::Address& local); + // An optional secondary "vanity" address we also answer to (e.g. AETHBBS). + // Pass an invalid Address to clear it. + void setAliasAddress(const ax25::Address& alias) { m_alias = alias; } + // The address currently in use for this session — the one the caller dialed + // (primary or alias). Equals the primary when idle. ax25::Address localAddress() const { return m_local; } ax25::Address remoteAddress() const { return m_remote; } @@ -96,7 +102,9 @@ class Ax25Connection : public QObject { void onT1Timeout(); int outstanding() const; // unacked I-frames in flight - ax25::Address m_local; + ax25::Address m_primary; // configured primary listen address + ax25::Address m_alias; // optional configured vanity/alias address + ax25::Address m_local; // active session address (the one the caller dialed) ax25::Address m_remote; State m_state{State::Disconnected}; diff --git a/src/gui/Ax25HfPacketDecodeDialog.cpp b/src/gui/Ax25HfPacketDecodeDialog.cpp index d87b199a..56830c0f 100644 --- a/src/gui/Ax25HfPacketDecodeDialog.cpp +++ b/src/gui/Ax25HfPacketDecodeDialog.cpp @@ -55,11 +55,11 @@ constexpr int kTncDefaultPort = 8001; // Personal Mailbox System (PMS) settings keys (persisted in AppSettings). constexpr auto kPmsEnabledSetting = "AetherModemPmsEnabled"; -constexpr auto kPmsSsidSetting = "AetherModemPmsSsid"; +constexpr auto kPmsListenCallSetting = "AetherModemPmsListenCallsign"; +constexpr auto kPmsAliasCallSetting = "AetherModemPmsAliasCallsign"; constexpr auto kPmsWelcomeSetting = "AetherModemPmsWelcome"; constexpr auto kPmsBeaconEnabledSetting = "AetherModemPmsBeaconEnabled"; constexpr auto kPmsBeaconTextSetting = "AetherModemPmsBeaconText"; -constexpr int kPmsDefaultSsid = 1; constexpr int kAudioCaptureSeconds = 180; constexpr int kTxDaxSettleMs = 150; @@ -660,32 +660,54 @@ Ax25HfPacketDecodeDialog::Ax25HfPacketDecodeDialog(AudioEngine* audio, root->addWidget(actionRowFrame); actionRowFrame->setVisible(false); - auto* statusRow = new QHBoxLayout; - statusRow->setSpacing(8); - statusRow->addWidget(statusPanel(QStringLiteral("MODEM STATUS"), - &m_modemStatusDot, - &m_modemStatusValue, - bodyWidget()), 1); - statusRow->addWidget(statusPanel(QStringLiteral("GAIN STAGE"), - &m_gainStageDot, - &m_gainStageValue, - bodyWidget()), 1); - - // PACKET ACTIVITY: label stacked above the graphic so it lines up with the - // MODEM STATUS and GAIN STAGE panel labels to its left. - auto* activityFrame = panel(QStringLiteral("StatusFrame"), bodyWidget()); - auto* activityLayout = new QVBoxLayout(activityFrame); - activityLayout->setContentsMargins(16, 12, 16, 12); - activityLayout->setSpacing(10); - m_packetActivityTitle = sectionLabel(QStringLiteral("PACKET ACTIVITY"), activityFrame); - activityLayout->addWidget(m_packetActivityTitle); - m_packetActivity = new PacketActivityWidget(activityFrame); + // Slim status bar: MODEM STATUS, GAIN STAGE and PACKET ACTIVITY inline in a + // single thin strip rather than three tall stacked panels. + auto* statusBar = panel(QStringLiteral("StatusFrame"), bodyWidget()); + auto* statusBarLayout = new QHBoxLayout(statusBar); + statusBarLayout->setContentsMargins(14, 6, 14, 6); + statusBarLayout->setSpacing(10); + + auto statusBarSeparator = [&]() -> QLabel* { + auto* sep = new QLabel(QStringLiteral("│"), statusBar); + sep->setStyleSheet(QStringLiteral("color:#233246;")); + return sep; + }; + + m_modemStatusDot = new QLabel(statusBar); + m_modemStatusDot->setObjectName(QStringLiteral("StatusDot")); + statusBarLayout->addWidget(m_modemStatusDot); + auto* modemTag = sectionLabel(QStringLiteral("MODEM"), statusBar); + statusBarLayout->addWidget(modemTag); + m_modemStatusValue = new QLabel(statusBar); + m_modemStatusValue->setObjectName(QStringLiteral("StatusValue")); + statusBarLayout->addWidget(m_modemStatusValue); + + statusBarLayout->addWidget(statusBarSeparator()); + + m_gainStageDot = new QLabel(statusBar); + m_gainStageDot->setObjectName(QStringLiteral("StatusDot")); + m_gainStageDot->setVisible(false); // gain has no dedicated indicator dot + auto* gainTag = sectionLabel(QStringLiteral("GAIN"), statusBar); + statusBarLayout->addWidget(gainTag); + m_gainStageValue = new QLabel(statusBar); + m_gainStageValue->setObjectName(QStringLiteral("StatusValue")); + statusBarLayout->addWidget(m_gainStageValue); + + statusBarLayout->addStretch(1); + + m_packetActivityTitle = sectionLabel(QStringLiteral("ACTIVITY"), statusBar); + statusBarLayout->addWidget(m_packetActivityTitle); + m_packetActivity = new PacketActivityWidget(statusBar); + m_packetActivity->setMinimumHeight(18); + m_packetActivity->setMaximumHeight(20); + m_packetActivity->setMinimumWidth(180); + m_packetActivity->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); m_packetActivity->setClickHandler([this] { setDiagnosticsDebugEnabled(!m_diagnosticsDebugEnabled, true); }); - activityLayout->addWidget(m_packetActivity, 1); - statusRow->addWidget(activityFrame, 2); - root->addLayout(statusRow); + statusBarLayout->addWidget(m_packetActivity); + + root->addWidget(statusBar); const Ax25ModemProfile savedProfile = profileFromSettingsValue( AppSettings::instance().value(kPacketDecoderProfileSetting, QStringLiteral("Hf300")).toString()); @@ -815,7 +837,11 @@ Ax25HfPacketDecodeDialog::Ax25HfPacketDecodeDialog(AudioEngine* audio, connect(m_pmsEnable, &QCheckBox::toggled, this, [this](bool on) { setPmsEnabled(on, true); }); - connect(m_pmsSsid, qOverload(&QSpinBox::valueChanged), this, [this](int) { + connect(m_pmsListenCall, &QLineEdit::editingFinished, this, [this] { + applyPmsConfigFromUi(true); + refreshPmsStatus(); + }); + connect(m_pmsAliasCall, &QLineEdit::editingFinished, this, [this] { applyPmsConfigFromUi(true); refreshPmsStatus(); }); @@ -1890,19 +1916,29 @@ QWidget* Ax25HfPacketDecodeDialog::buildMailboxPage() mboxLayout->addWidget(m_pmsEnable); m_pmsBeaconEnable = new QCheckBox(QStringLiteral("Send hourly beacon"), mboxCell); mboxLayout->addWidget(m_pmsBeaconEnable); - controls->addWidget(mboxCell, 2); - - auto* ssidCell = panel(QStringLiteral("ControlCell"), controlsFrame); - auto* ssidLayout = new QVBoxLayout(ssidCell); - ssidLayout->setContentsMargins(0, 0, 20, 0); - ssidLayout->setSpacing(12); - ssidLayout->addWidget(sectionLabel(QStringLiteral("ANSWER SSID"), ssidCell)); - m_pmsSsid = new QSpinBox(ssidCell); - m_pmsSsid->setRange(0, 15); - m_pmsSsid->setValue(kPmsDefaultSsid); - m_pmsSsid->setMaximumWidth(120); - ssidLayout->addWidget(m_pmsSsid); - controls->addWidget(ssidCell, 1); + controls->addWidget(mboxCell, 1); + + auto* callCell = panel(QStringLiteral("ControlCell"), controlsFrame); + auto* callLayout = new QVBoxLayout(callCell); + callLayout->setContentsMargins(0, 0, 20, 0); + callLayout->setSpacing(12); + callLayout->addWidget(sectionLabel(QStringLiteral("LISTEN CALLSIGN"), callCell)); + m_pmsListenCall = new QLineEdit(callCell); + m_pmsListenCall->setPlaceholderText(QStringLiteral("e.g. KI6BCJ-10")); + m_pmsListenCall->setToolTip(QStringLiteral( + "Full callsign-SSID the mailbox answers on. AX.25 limits a callsign to " + "6 characters plus an optional -SSID (0-15).")); + m_pmsListenCall->setMaximumWidth(220); + callLayout->addWidget(m_pmsListenCall); + callLayout->addWidget(sectionLabel(QStringLiteral("VANITY ALIAS (OPTIONAL)"), callCell)); + m_pmsAliasCall = new QLineEdit(callCell); + m_pmsAliasCall->setPlaceholderText(QStringLiteral("e.g. AETBBS (max 6 chars)")); + m_pmsAliasCall->setToolTip(QStringLiteral( + "Optional second callsign the mailbox also answers on. AX.25 limits a " + "callsign to 6 characters plus an optional -SSID.")); + m_pmsAliasCall->setMaximumWidth(220); + callLayout->addWidget(m_pmsAliasCall); + controls->addWidget(callCell, 1); controls->addStretch(1); layout->addWidget(controlsFrame); @@ -1926,6 +1962,23 @@ QWidget* Ax25HfPacketDecodeDialog::buildMailboxPage() &m_pmsStatusDot, &m_pmsStatusValue, page); layout->addWidget(statusFrame); + // Statistics on the left, Last Callers on the right — each its own panel so + // the row fills the width evenly. + auto* infoRow = new QHBoxLayout; + infoRow->setSpacing(8); + + auto* statsFrame = panel(QStringLiteral("StatusFrame"), page); + auto* statsLayout = new QVBoxLayout(statsFrame); + statsLayout->setContentsMargins(16, 12, 16, 12); + statsLayout->setSpacing(8); + statsLayout->addWidget(sectionLabel(QStringLiteral("STATISTICS"), statsFrame)); + m_pmsStatsValue = new QLabel(statsFrame); + m_pmsStatsValue->setObjectName(QStringLiteral("StatusValue")); + m_pmsStatsValue->setWordWrap(true); + m_pmsStatsValue->setAlignment(Qt::AlignTop | Qt::AlignLeft); + statsLayout->addWidget(m_pmsStatsValue, 1); + infoRow->addWidget(statsFrame, 1); + auto* callersFrame = panel(QStringLiteral("StatusFrame"), page); auto* callersLayout = new QVBoxLayout(callersFrame); callersLayout->setContentsMargins(16, 12, 16, 12); @@ -1934,29 +1987,19 @@ QWidget* Ax25HfPacketDecodeDialog::buildMailboxPage() m_pmsCallersValue = new QLabel(callersFrame); m_pmsCallersValue->setObjectName(QStringLiteral("StatusValue")); m_pmsCallersValue->setWordWrap(true); - callersLayout->addWidget(m_pmsCallersValue); - callersLayout->addWidget(sectionLabel(QStringLiteral("STATISTICS"), callersFrame)); - m_pmsStatsValue = new QLabel(callersFrame); - m_pmsStatsValue->setObjectName(QStringLiteral("StatusValue")); - m_pmsStatsValue->setWordWrap(true); - callersLayout->addWidget(m_pmsStatsValue); - layout->addWidget(callersFrame); + m_pmsCallersValue->setAlignment(Qt::AlignTop | Qt::AlignLeft); + callersLayout->addWidget(m_pmsCallersValue, 1); + infoRow->addWidget(callersFrame, 1); - auto* help = new QLabel( - QStringLiteral("A single remote caller can connect to - over " - "1200-baud AX.25 and read, list, and send messages, list who has " - "been heard, then disconnect. The station callsign comes from the " - "radio; set the SSID the mailbox answers on. Enabling the mailbox " - "turns the modem on. Settings persist across restarts."), - page); - help->setObjectName(QStringLiteral("StatusValue")); - help->setWordWrap(true); - layout->addWidget(help); + layout->addLayout(infoRow); layout->addStretch(1); // Seed control values from settings (before signals are wired in the ctor). - m_pmsSsid->setValue(AppSettings::instance() - .value(kPmsSsidSetting, QString::number(kPmsDefaultSsid)).toInt()); + // No defaults for the callsign fields — the operator must set them. + m_pmsListenCall->setText(AppSettings::instance() + .value(kPmsListenCallSetting, QString()).toString()); + m_pmsAliasCall->setText(AppSettings::instance() + .value(kPmsAliasCallSetting, QString()).toString()); m_pmsWelcome->setText(AppSettings::instance() .value(kPmsWelcomeSetting, QString()).toString()); m_pmsBeaconText->setText(AppSettings::instance() @@ -1973,11 +2016,10 @@ void Ax25HfPacketDecodeDialog::applyPmsConfigFromUi(bool persist) { if (!m_pms) return; - const QString call = m_radio ? m_radio->callsign().trimmed().toUpper() : QString(); - if (!call.isEmpty()) - m_pms->setLocalCallsign(call); - if (m_pmsSsid) - m_pms->setSsid(m_pmsSsid->value()); + if (m_pmsListenCall) + m_pms->setListenCallsign(m_pmsListenCall->text()); + if (m_pmsAliasCall) + m_pms->setAliasCallsign(m_pmsAliasCall->text()); if (m_pmsWelcome) m_pms->setWelcomeText(m_pmsWelcome->text()); if (m_pmsBeaconText) @@ -1987,8 +2029,10 @@ void Ax25HfPacketDecodeDialog::applyPmsConfigFromUi(bool persist) if (persist) { auto& s = AppSettings::instance(); - if (m_pmsSsid) - s.setValue(kPmsSsidSetting, QString::number(m_pmsSsid->value())); + if (m_pmsListenCall) + s.setValue(kPmsListenCallSetting, m_pmsListenCall->text().trimmed().toUpper()); + if (m_pmsAliasCall) + s.setValue(kPmsAliasCallSetting, m_pmsAliasCall->text().trimmed().toUpper()); if (m_pmsWelcome) s.setValue(kPmsWelcomeSetting, m_pmsWelcome->text()); if (m_pmsBeaconText) @@ -2015,10 +2059,10 @@ void Ax25HfPacketDecodeDialog::setPmsEnabled(bool enabled, bool persist) appendSystemLine(QStringLiteral("Enabling the modem for the mailbox (PMS).")); m_enableDecode->setChecked(true); } - const QString call = m_radio ? m_radio->callsign().trimmed().toUpper() : QString(); - if (call.isEmpty()) { + applyPmsConfigFromUi(false); + if (!m_pms->hasValidAddress()) { appendSystemLine(QStringLiteral( - "Mailbox: set a station callsign on the radio before enabling the PMS.")); + "Mailbox: enter a valid listen callsign (e.g. KI6BCJ-10) before enabling the PMS.")); if (m_pmsEnable) { QSignalBlocker blocker(m_pmsEnable); m_pmsEnable->setChecked(false); @@ -2026,7 +2070,6 @@ void Ax25HfPacketDecodeDialog::setPmsEnabled(bool enabled, bool persist) refreshPmsStatus(); return; } - applyPmsConfigFromUi(false); m_pms->setEnabled(true); appendSystemLine(QStringLiteral("Mailbox (PMS) listening as %1.") .arg(m_pms->localAddress().toString())); diff --git a/src/gui/Ax25HfPacketDecodeDialog.h b/src/gui/Ax25HfPacketDecodeDialog.h index 56fb4faf..3123797b 100644 --- a/src/gui/Ax25HfPacketDecodeDialog.h +++ b/src/gui/Ax25HfPacketDecodeDialog.h @@ -152,7 +152,8 @@ class Ax25HfPacketDecodeDialog : public PersistentDialog { PmsMailbox* m_pms{nullptr}; QAbstractButton* m_mailboxTab{nullptr}; QCheckBox* m_pmsEnable{nullptr}; - QSpinBox* m_pmsSsid{nullptr}; + QLineEdit* m_pmsListenCall{nullptr}; + QLineEdit* m_pmsAliasCall{nullptr}; QLineEdit* m_pmsWelcome{nullptr}; QCheckBox* m_pmsBeaconEnable{nullptr}; QLineEdit* m_pmsBeaconText{nullptr}; diff --git a/tests/pms_mailbox_test.cpp b/tests/pms_mailbox_test.cpp index eac0c54f..f3964a78 100644 --- a/tests/pms_mailbox_test.cpp +++ b/tests/pms_mailbox_test.cpp @@ -42,6 +42,10 @@ static void testAddress() CHECK(!Address::parse(QStringLiteral("")).has_value(), "empty rejected"); CHECK(!Address::parse(QStringLiteral("TOOLONGCALL")).has_value(), "overlong rejected"); + // AX.25 limits the base callsign to 6 characters, so a 7-char vanity such as + // "AETHBBS" is not a legal address — callers must use <= 6 (e.g. "AETBBS"). + CHECK(!Address::parse(QStringLiteral("AETHBBS")).has_value(), "7-char alias rejected"); + CHECK(Address::parse(QStringLiteral("AETBBS")).has_value(), "6-char alias accepted"); } static void testFrameRoundTrip() @@ -175,8 +179,8 @@ static void testMailbox() QObject::connect(&pms, &PmsMailbox::transmitFrame, [&](const QByteArray& f) { tx.append(f); }); - pms.setLocalCallsign(QStringLiteral("N0PMS")); - pms.setSsid(1); + pms.setListenCallsign(QStringLiteral("N0PMS-1")); + pms.setAliasCallsign(QStringLiteral("AETBBS")); // AX.25 callsigns are <= 6 chars pms.setEnabled(true); const Address local{QStringLiteral("N0PMS"), 1, false, false}; @@ -237,6 +241,48 @@ static void testMailbox() CHECK(sawDisc, "mailbox sends DISC on BYE"); } +// A caller dialing the vanity alias should connect, and every reply (incl. the +// UA and greeting) must come from the alias address, not the primary. +static void testAliasDial() +{ + PmsMailbox pms; + pms.setVersionString(QStringLiteral("test")); + QVector tx; + QObject::connect(&pms, &PmsMailbox::transmitFrame, + [&](const QByteArray& f) { tx.append(f); }); + + pms.setListenCallsign(QStringLiteral("N0PMS-1")); + pms.setAliasCallsign(QStringLiteral("AETBBS")); // AX.25 callsigns are <= 6 chars + pms.setEnabled(true); + + const Address alias{QStringLiteral("AETBBS"), 0, false, false}; + const Address peer{QStringLiteral("K7ABC"), 0, false, false}; + + pms.onAirFrame(Frame::makeU(alias, peer, FrameType::SABM, true, true).encode()); + + bool sawAliasUA = false; + bool greetingFromAlias = false; + for (const QByteArray& f : tx) { + auto d = Frame::decode(f); + if (!d || d->dest != peer) + continue; + if (d->type == FrameType::UA && d->src == alias) + sawAliasUA = true; + if (d->type == FrameType::I && d->src == alias) + greetingFromAlias = true; + } + CHECK(sawAliasUA, "alias dial: UA answered from the alias address"); + CHECK(greetingFromAlias, "alias dial: greeting sent from the alias address"); + CHECK(pms.connectedCaller() == QLatin1String("K7ABC"), "alias dial: caller connected"); + + // A frame to the primary while idle would also be accepted, but a frame to a + // third, unrelated callsign must be ignored. + const Address other{QStringLiteral("N9ZZZ"), 5, false, false}; + const int txBefore = tx.size(); + pms.onAirFrame(Frame::makeU(other, peer, FrameType::SABM, true, true).encode()); + CHECK(tx.size() == txBefore, "frames to an unrelated callsign are ignored"); +} + int main(int argc, char** argv) { // Isolate AppSettings / PMS storage from the real user config so the test is @@ -255,6 +301,7 @@ int main(int argc, char** argv) testFrameRoundTrip(); testConnection(); testMailbox(); + testAliasDial(); if (g_failures == 0) { std::printf("All PMS mailbox tests passed.\n"); From beb73ae8499c31b62614fc577d3febcc31543d13 Mon Sep 17 00:00:00 2001 From: jensenpat Date: Sat, 30 May 2026 10:13:20 -0700 Subject: [PATCH 3/5] =?UTF-8?q?fix(modem):=20decode=20AX.25=20connect=20fr?= =?UTF-8?q?ames=20(SABM/DISC/UA/DM)=20=E2=80=94=20off-by-one=20length=20ga?= =?UTF-8?q?te?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CMakeLists.txt | 15 ++ src/core/pms/PmsMailbox.cpp | 23 ++++ src/core/tnc/AetherAx25LibmodemShim.cpp | 9 +- tests/ax25_libmodem_shim_test.cpp | 58 ++++++++ third_party/libmodem_core/bitstream.h | 6 +- tools/ax25_replay.cpp | 175 ++++++++++++++++++++++++ 6 files changed, 282 insertions(+), 4 deletions(-) create mode 100644 tools/ax25_replay.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index ffa13ba4..a8ebfce1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1907,6 +1907,7 @@ add_executable(ax25_libmodem_shim_test tests/ax25_libmodem_shim_test.cpp src/core/tnc/AetherAx25LibmodemShim.cpp src/core/tnc/Ax25FrameFormatter.cpp + src/core/tnc/Ax25.cpp src/core/tnc/KissFraming.cpp # LogManager.cpp provides lcAx25 (the shim's qCDebug category, #2763); # LogManager.cpp depends on AsyncLogWriter via the m_writer member, so @@ -1920,6 +1921,20 @@ target_include_directories(ax25_libmodem_shim_test PRIVATE src) target_link_libraries(ax25_libmodem_shim_test PRIVATE Qt6::Core aether_libmodem_core) add_test(NAME ax25_libmodem_shim_test COMMAND ax25_libmodem_shim_test) +# Offline AX.25 decode diagnostic: replays a captured WAV through the decoder. +# Not a ctest (needs an input file); built on demand for troubleshooting. +add_executable(ax25_replay EXCLUDE_FROM_ALL + tools/ax25_replay.cpp + src/core/tnc/AetherAx25LibmodemShim.cpp + src/core/tnc/Ax25FrameFormatter.cpp + src/core/tnc/KissFraming.cpp + src/core/LogManager.cpp + src/core/AsyncLogWriter.cpp + src/core/AppSettings.cpp +) +target_include_directories(ax25_replay PRIVATE src) +target_link_libraries(ax25_replay PRIVATE Qt6::Core aether_libmodem_core) + add_executable(pms_mailbox_test tests/pms_mailbox_test.cpp src/core/tnc/Ax25.cpp diff --git a/src/core/pms/PmsMailbox.cpp b/src/core/pms/PmsMailbox.cpp index 4fbf9ec0..039f752f 100644 --- a/src/core/pms/PmsMailbox.cpp +++ b/src/core/pms/PmsMailbox.cpp @@ -205,6 +205,29 @@ void PmsMailbox::onAirFrame(const QByteArray& rawNoFcs) if (!isUs) recordHeard(*frame); + // Diagnostic: every decoded frame, with the address-match decision spelled + // out. This is the key instrument for connect troubleshooting — it tells us + // whether a SABM was decoded at all and whether its dest matched the listen + // or alias address (decode problem vs address mismatch vs not-for-us). + if (m_enabled) { + const bool destMatch = (m_listen.isValid() && frame->dest == m_listen) + || (m_alias.isValid() && frame->dest == m_alias); + QString which = QStringLiteral("none"); + if (m_listen.isValid() && frame->dest == m_listen) + which = QStringLiteral("listen"); + else if (m_alias.isValid() && frame->dest == m_alias) + which = QStringLiteral("alias"); + emit activity(QStringLiteral( + "PMS RX %1 %2>%3 — dest match=%4 (%5); listen=%6 alias=%7") + .arg(ax25::frameTypeName(frame->type), + frame->src.toString(), + frame->dest.toString(), + destMatch ? QStringLiteral("yes") : QStringLiteral("no"), + which, + m_listen.isValid() ? m_listen.toString() : QStringLiteral("(unset)"), + m_alias.isValid() ? m_alias.toString() : QStringLiteral("(none)"))); + } + // Let the data link decide if the frame is addressed to us; it matches both // the primary listen address and the optional vanity alias. if (m_enabled) diff --git a/src/core/tnc/AetherAx25LibmodemShim.cpp b/src/core/tnc/AetherAx25LibmodemShim.cpp index 873e02ab..c4fa843b 100644 --- a/src/core/tnc/AetherAx25LibmodemShim.cpp +++ b/src/core/tnc/AetherAx25LibmodemShim.cpp @@ -752,10 +752,13 @@ struct AetherAx25LibmodemShim::Impl { lastRejectFrameBits = static_cast(lane.bitstreamState.frame_size_bits); lastRejectFrameBytes = static_cast(frameBytesSize); lastRejectPreviewHex = framePreviewHex(lane.candidateFrameBytes, frameBytesSize); - lastRejectActualFcs = frameBytesSize >= 18 ? fcsToString(actualFcs) : QString(); - lastRejectExpectedFcs = frameBytesSize >= 18 ? fcsToString(expectedFcs) : QString(); + lastRejectActualFcs = frameBytesSize >= 17 ? fcsToString(actualFcs) : QString(); + lastRejectExpectedFcs = frameBytesSize >= 17 ? fcsToString(expectedFcs) : QString(); - if (frameBytesSize < 18) { + // Minimum valid AX.25 frame is 17 bytes (14 address + 1 control + 2 FCS); + // a no-PID U-frame (SABM/DISC/UA/DM) sits exactly at 17. Anything shorter + // is a noise-triggered flag pair. + if (frameBytesSize < 17) { ++totalRejectTooShort; lastRejectReason = QStringLiteral("too-short"); return false; diff --git a/tests/ax25_libmodem_shim_test.cpp b/tests/ax25_libmodem_shim_test.cpp index 8f3932e1..fc57b2f4 100644 --- a/tests/ax25_libmodem_shim_test.cpp +++ b/tests/ax25_libmodem_shim_test.cpp @@ -1,4 +1,5 @@ #include "core/tnc/AetherAx25LibmodemShim.h" +#include "core/tnc/Ax25.h" #include "core/tnc/KissFraming.h" #include "bitstream.h" @@ -458,6 +459,62 @@ void testSyntheticVhf1200AfskLoopbackDecodes() frame.payloadText == QStringLiteral("!4742.00N/12217.00W>2m APRS via AetherModem 1200 baud")); } +// A connect request (SABM) is the SHORTEST possible AX.25 frame: dest(7) + +// src(7) + control(1) = 15 bytes, no PID/info, = 17 bytes with FCS. That is the +// worst case for the decoder's minimum-length gate, and it is exactly what a TNC +// sends to open a PMS session. +// +// Regression guard: the bitstream decoder previously rejected frames < 18 bytes, +// which silently dropped every no-PID U-frame (SABM/DISC/UA/DM) at exactly 17 — +// so a PMS/BBS could never be reached in connected mode even with perfect audio. +// The gate is now < 17. This test fails if that off-by-one ever returns. +void testSabmConnectFrameLoopbackDecodes() +{ + using AetherSDR::ax25::Address; + using AetherSDR::ax25::Frame; + using AetherSDR::ax25::FrameType; + + const auto cfg = ax25DemodConfigForProfile(Ax25ModemProfile::Vhf1200); + AetherAx25LibmodemShim shim; + shim.configure(cfg); + + const Address dest{QStringLiteral("KI6BCJ"), 10, false, false}; + const Address src{QStringLiteral("KI6BCJ"), 7, false, false}; + const Frame sabm = Frame::makeU(dest, src, FrameType::SABM, + /*pollFinal=*/true, /*command=*/true); + const QByteArray sabmNoFcs = sabm.encode(); + report("SABM encodes to the minimum 15-byte frame", sabmNoFcs.size() == 15); + + const auto tx = shim.buildTransmitAudioFromFrame(sabmNoFcs); + report("SABM TX builds audio", tx.ok && !tx.stereoFloat32Pcm.isEmpty()); + if (!tx.ok) + return; + + // One-shot feed, same as the other synthetic loopback tests — the SABM now + // decodes directly once the < 17 length gate is correct. + const auto monoTx = monoFromStereoFloat32(tx.stereoFloat32Pcm); + const auto frames = shim.processMonoFloat(monoTx.data(), + static_cast(monoTx.size()), + cfg.sampleRate); + report("SABM loopback emits one frame", frames.size() == 1); + if (frames.isEmpty()) + return; + const auto& frame = frames.first(); + report("SABM loopback FCS accepted", frame.fcsOk); + report("SABM loopback is not a UI frame", !frame.isUiFrame); + // Control 0x2F (SABM) with the poll bit (0x10) set = 0x3F. + report("SABM loopback control byte is SABM+P", frame.control == 0x3F); + report("SABM loopback destination", frame.destination == QStringLiteral("KI6BCJ-10")); + report("SABM loopback source", frame.source == QStringLiteral("KI6BCJ-7")); + + // And confirm our own decoder parses the recovered on-air bytes back to a + // SABM, i.e. the full path a caller's connect takes into the PMS data link. + const auto parsed = Frame::decode(frame.ax25FrameNoFcs); + report("SABM loopback re-parses as a Frame", parsed.has_value()); + if (parsed) + report("SABM loopback parses as FrameType::SABM", parsed->type == FrameType::SABM); +} + void testChunkedVhf1200ReplayDecodes() { const auto cfg = ax25DemodConfigForProfile(Ax25ModemProfile::Vhf1200); @@ -868,6 +925,7 @@ int main(int argc, char** argv) testKnownGoodBitstreamDecodes(); testSyntheticHf300AfskLoopbackDecodes(); testSyntheticVhf1200AfskLoopbackDecodes(); + testSabmConnectFrameLoopbackDecodes(); testChunkedVhf1200ReplayDecodes(); testTransmitRawPayloadBuildsLoopbackAudio(); testTransmitMonitorSyntaxBuildsLoopbackAudio(); diff --git a/third_party/libmodem_core/bitstream.h b/third_party/libmodem_core/bitstream.h index de940ca9..a6193bec 100644 --- a/third_party/libmodem_core/bitstream.h +++ b/third_party/libmodem_core/bitstream.h @@ -2357,7 +2357,11 @@ LIBMODEM_INLINE std::tuple try_decode_frame(RandomIt { size_t frame_size = std::distance(frame_it_first, frame_it_last); - if (frame_size < 18) + // Minimum valid AX.25 frame WITH FCS = 15 (14 address + 1 control) + 2 FCS + // = 17 bytes. A no-PID U-frame (SABM/DISC/UA/DM — the connected-mode control + // frames) sits at exactly 17; the old < 18 silently dropped every one, so a + // station could never be reached in connected mode. Must be < 17, not < 18. + if (frame_size < 17) { return { path, data, false }; } diff --git a/tools/ax25_replay.cpp b/tools/ax25_replay.cpp new file mode 100644 index 00000000..1d7acd22 --- /dev/null +++ b/tools/ax25_replay.cpp @@ -0,0 +1,175 @@ +// ax25_replay — offline AX.25 decode diagnostic. +// +// Loads a mono float32 WAV (e.g. an AetherModem "Capture 3m" recording) and +// replays it through the AetherAx25LibmodemShim decoder, printing every decoded +// frame plus the final reject diagnostics. This lets us debug an on-air decode +// failure from a captured .wav with no radio in the loop. +// +// Usage: ax25_replay [baud] +// baud: 300 (HF) or 1200 (VHF). Default 1200. + +#include "core/tnc/AetherAx25LibmodemShim.h" + +#include +#include +#include +#include + +#include +#include +#include + +using namespace AetherSDR; + +namespace { + +quint16 readLe16(const char* b) +{ + return static_cast(static_cast(b[0])) + | (static_cast(static_cast(b[1])) << 8); +} + +quint32 readLe32(const char* b) +{ + return static_cast(static_cast(b[0])) + | (static_cast(static_cast(b[1])) << 8) + | (static_cast(static_cast(b[2])) << 16) + | (static_cast(static_cast(b[3])) << 24); +} + +bool loadWav(const QString& path, std::vector& samples, int& sampleRate, QString& err) +{ + QFile f(path); + if (!f.open(QIODevice::ReadOnly)) { + err = QStringLiteral("cannot open %1").arg(path); + return false; + } + const QByteArray bytes = f.readAll(); + if (bytes.size() < 44 || bytes.mid(0, 4) != "RIFF" || bytes.mid(8, 4) != "WAVE") { + err = QStringLiteral("not a RIFF/WAVE file"); + return false; + } + quint16 format = 0, channels = 0, bits = 0; + const char* data = nullptr; + qsizetype dataBytes = 0; + qsizetype pos = 12; + while (pos + 8 <= bytes.size()) { + const QByteArray id = bytes.mid(pos, 4); + const quint32 sz = readLe32(bytes.constData() + pos + 4); + pos += 8; + if (pos + static_cast(sz) > bytes.size()) + break; + if (id == "fmt " && sz >= 16) { + const char* fmt = bytes.constData() + pos; + format = readLe16(fmt); + channels = readLe16(fmt + 2); + sampleRate = static_cast(readLe32(fmt + 4)); + bits = readLe16(fmt + 14); + } else if (id == "data") { + data = bytes.constData() + pos; + dataBytes = static_cast(sz); + } + pos += static_cast(sz); + if (sz & 1u) + ++pos; + } + if (format != 3 || channels != 1 || bits != 32) { + err = QStringLiteral("expected mono float32 WAV (got fmt=%1 ch=%2 bits=%3)") + .arg(format).arg(channels).arg(bits); + return false; + } + samples.resize(static_cast(dataBytes / 4)); + std::memcpy(samples.data(), data, static_cast(dataBytes)); + return true; +} + +QString hex(const QByteArray& b) +{ + return QString::fromLatin1(b.toHex(' ').toUpper()); +} + +} // namespace + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + if (argc < 2) { + std::fprintf(stderr, "usage: %s [baud=1200]\n", argv[0]); + return 2; + } + const QString path = QString::fromLocal8Bit(argv[1]); + const int baud = (argc >= 3) ? QString::fromLocal8Bit(argv[2]).toInt() : 1200; + + std::vector samples; + int sampleRate = 0; + QString err; + if (!loadWav(path, samples, sampleRate, err)) { + std::fprintf(stderr, "load failed: %s\n", err.toLocal8Bit().constData()); + return 1; + } + std::printf("Loaded %zu samples @ %d Hz (%.1f s)\n", + samples.size(), sampleRate, + sampleRate > 0 ? double(samples.size()) / sampleRate : 0.0); + + const Ax25ModemProfile profile = + (baud == 300) ? Ax25ModemProfile::Hf300 : Ax25ModemProfile::Vhf1200; + + // Sweep both tone polarities so a mark/space inversion (common between + // different radios/TNCs) shows up immediately. + int grandTotal = 0; + for (Ax25TonePolarity polarity : {Ax25TonePolarity::Normal, Ax25TonePolarity::Inverted}) { + AetherAx25LibmodemShim shim; // NOLINT — block intentionally indented one level + + shim.configure(ax25DemodConfigForProfile(profile, polarity)); + std::printf("\n##### Decoder: %s #####\n", shim.demodDescription().toLocal8Bit().constData()); + + int decoded = 0; + QObject::connect(&shim, &AetherAx25LibmodemShim::frameDecoded, + [&](const Ax25DecodedFrame& f) { + ++decoded; + const char* kind = f.isUiFrame ? "UI" + : (f.control == 0x2f || f.control == 0x3f) ? "SABM" + : (f.control == 0x6f || f.control == 0x73) ? "UA" + : "CTRL"; + std::printf(" FRAME #%d %s %s > %s%s ctrl=0x%02X pid=0x%02X len=%d\n", + decoded, kind, + f.source.toLocal8Bit().constData(), + f.destination.toLocal8Bit().constData(), + f.path.isEmpty() ? "" : (" via " + f.path.join(',')).toLocal8Bit().constData(), + f.control, f.pid, int(f.payload.size())); + if (!f.payloadText.isEmpty()) + std::printf(" text: %s\n", f.payloadText.toLocal8Bit().constData()); + if (!f.ax25FrameNoFcs.isEmpty()) + std::printf(" bytes(noFcs): %s\n", hex(f.ax25FrameNoFcs).toLocal8Bit().constData()); + }); + + // Feed in realistic chunks so windowed diagnostics behave like the live tap. + const int chunk = sampleRate / 10; // ~100 ms + for (size_t off = 0; off < samples.size(); off += chunk) { + const int n = int(std::min(chunk, samples.size() - off)); + shim.processMonoFloat(samples.data() + off, n, sampleRate); + } + + const Ax25DecoderDiagnostics d = shim.diagnosticsSnapshot(); + std::printf("\n=== RESULT ===\n"); + std::printf("decoded frames : %d\n", decoded); + std::printf("hdlc starts : %llu\n", (unsigned long long)d.hdlcFrameStarts); + std::printf("hdlc candidates: %llu\n", (unsigned long long)d.hdlcFrameCandidates); + std::printf("ax25-like : %llu\n", (unsigned long long)d.plausibleAx25Candidates); + std::printf("accepted : %llu\n", (unsigned long long)d.framesAccepted); + std::printf("rejected : %llu (short=%llu badFcs=%llu malformed=%llu)\n", + (unsigned long long)d.decodeRejected, + (unsigned long long)d.rejectTooShort, + (unsigned long long)d.rejectBadFcs, + (unsigned long long)d.rejectMalformed); + std::printf("last reject : %s (bytes=%d bits=%d fcs=%s/%s)\n", + d.lastRejectReason.toLocal8Bit().constData(), + d.lastRejectFrameBytes, d.lastRejectFrameBits, + d.lastRejectActualFcs.toLocal8Bit().constData(), + d.lastRejectExpectedFcs.toLocal8Bit().constData()); + grandTotal += decoded; + } // end polarity sweep + + std::printf("\n=== TOTAL decoded across both polarities: %d ===\n", grandTotal); + return grandTotal > 0 ? 0 : 3; +} From 83db57a4f7cba4a097b3cefad1444fd013023e38 Mon Sep 17 00:00:00 2001 From: jensenpat Date: Sat, 30 May 2026 13:16:02 -0700 Subject: [PATCH 4/5] fix(modem): ax25_replay counts decoded-frame return value, not the unemitted signal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- tools/ax25_replay.cpp | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/tools/ax25_replay.cpp b/tools/ax25_replay.cpp index 1d7acd22..02b0fbf3 100644 --- a/tools/ax25_replay.cpp +++ b/tools/ax25_replay.cpp @@ -124,30 +124,39 @@ int main(int argc, char** argv) std::printf("\n##### Decoder: %s #####\n", shim.demodDescription().toLocal8Bit().constData()); int decoded = 0; - QObject::connect(&shim, &AetherAx25LibmodemShim::frameDecoded, - [&](const Ax25DecodedFrame& f) { + auto reportFrame = [&](const Ax25DecodedFrame& f) { ++decoded; + const quint8 m = f.control & ~quint8(0x10); // strip P/F const char* kind = f.isUiFrame ? "UI" - : (f.control == 0x2f || f.control == 0x3f) ? "SABM" - : (f.control == 0x6f || f.control == 0x73) ? "UA" - : "CTRL"; - std::printf(" FRAME #%d %s %s > %s%s ctrl=0x%02X pid=0x%02X len=%d\n", + : (m == 0x2F) ? "SABM" + : (m == 0x43) ? "DISC" + : (m == 0x0F) ? "DM" + : (m == 0x63) ? "UA" + : (m == 0x87) ? "FRMR" + : ((f.control & 0x01) == 0) ? "I" + : "S"; + std::printf(" FRAME #%d %-4s %s > %s%s ctrl=0x%02X pid=0x%02X fcsOk=%d len=%d\n", decoded, kind, f.source.toLocal8Bit().constData(), f.destination.toLocal8Bit().constData(), f.path.isEmpty() ? "" : (" via " + f.path.join(',')).toLocal8Bit().constData(), - f.control, f.pid, int(f.payload.size())); + f.control, f.pid, f.fcsOk ? 1 : 0, int(f.payload.size())); if (!f.payloadText.isEmpty()) std::printf(" text: %s\n", f.payloadText.toLocal8Bit().constData()); if (!f.ax25FrameNoFcs.isEmpty()) std::printf(" bytes(noFcs): %s\n", hex(f.ax25FrameNoFcs).toLocal8Bit().constData()); - }); + }; - // Feed in realistic chunks so windowed diagnostics behave like the live tap. - const int chunk = sampleRate / 10; // ~100 ms + // Count the RETURN value of processMonoFloat (the authoritative decoded-frame + // path). NOTE: the frameDecoded *signal* is only emitted by feedAudio(), not + // processMonoFloat() — counting the signal here would always report 0 even + // when frames decode. (That bug originally masked the real SABM decodes.) + const int chunk = sampleRate / 10; // ~100 ms, like the live tap for (size_t off = 0; off < samples.size(); off += chunk) { const int n = int(std::min(chunk, samples.size() - off)); - shim.processMonoFloat(samples.data() + off, n, sampleRate); + const auto frames = shim.processMonoFloat(samples.data() + off, n, sampleRate); + for (const auto& f : frames) + reportFrame(f); } const Ax25DecoderDiagnostics d = shim.diagnosticsSnapshot(); From 25c9db32d885db1012c424b642528ae2b4a55d94 Mon Sep 17 00:00:00 2001 From: jensenpat Date: Sat, 30 May 2026 13:48:29 -0700 Subject: [PATCH 5/5] fix(modem): PMS half-duplex window=1 so multi-frame replies don't stall in a T1 loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/core/tnc/Ax25Connection.cpp | 2 +- src/core/tnc/Ax25Connection.h | 11 ++++- tests/pms_mailbox_test.cpp | 83 ++++++++++++++++++++++++++++++--- 3 files changed, 88 insertions(+), 8 deletions(-) diff --git a/src/core/tnc/Ax25Connection.cpp b/src/core/tnc/Ax25Connection.cpp index 75db33e2..a967a4cf 100644 --- a/src/core/tnc/Ax25Connection.cpp +++ b/src/core/tnc/Ax25Connection.cpp @@ -256,7 +256,7 @@ void Ax25Connection::pumpOutbound() { if (m_state != State::Connected || m_peerBusy) return; - while (!m_sendBuffer.isEmpty() && outstanding() < kWindow) { + while (!m_sendBuffer.isEmpty() && outstanding() < m_window) { const QByteArray segment = m_sendBuffer.left(m_paclen); m_sendBuffer.remove(0, segment.size()); const int ns = m_vs; diff --git a/src/core/tnc/Ax25Connection.h b/src/core/tnc/Ax25Connection.h index ae4e4895..84345230 100644 --- a/src/core/tnc/Ax25Connection.h +++ b/src/core/tnc/Ax25Connection.h @@ -56,6 +56,15 @@ class Ax25Connection : public QObject { void setPaclen(int bytes) { m_paclen = qBound(16, bytes, 256); } void setMaxRetries(int n2) { m_n2 = qBound(1, n2, 20); } void setRetryTimeoutMs(int t1) { m_t1Ms = qBound(1000, t1, 60000); } + // Window k: max unacknowledged I-frames in flight (mod-8 caps it at 7). + // Default 1 (MAXFRAME=1). On a HALF-DUPLEX radio link each I-frame is its own + // PTT keyup; sending several back-to-back keeps us transmitting (and deaf) + // long enough that the peer's acknowledgement lands while we cannot hear it, + // which stalls into a T1 retransmit loop. k=1 sends one frame, then listens + // for its ack before the next — the pattern that works reliably here. A + // future single-keyup multi-frame TX path (or a full-duplex transport) can + // safely raise this. + void setWindow(int k) { m_window = qBound(1, k, 7); } // Feed every decoded frame here. Frames not addressed to our local address // (dest mismatch) are ignored, so the caller can pass all RX traffic. @@ -112,7 +121,7 @@ class Ax25Connection : public QObject { int m_vr{0}; // V(R) next expected receive sequence int m_va{0}; // V(A) last acknowledged send sequence - static constexpr int kWindow = 4; // k: max outstanding I-frames (mod-8 safe) + int m_window{1}; // k: max outstanding I-frames; see setWindow() (half-duplex) int m_paclen{128}; int m_n2{8}; int m_t1Ms{6000}; diff --git a/tests/pms_mailbox_test.cpp b/tests/pms_mailbox_test.cpp index f3964a78..81bc3920 100644 --- a/tests/pms_mailbox_test.cpp +++ b/tests/pms_mailbox_test.cpp @@ -128,6 +128,60 @@ static void testConnection() CHECK(disconnected, "disconnected after DISC"); } +// Regression: on a half-duplex radio link the send window must be 1 — only one +// unacknowledged I-frame in flight at a time. A multi-frame reply must drain +// one-frame-per-ack, NOT blast several back-to-back. (Observed live 2026-05-30: +// a 3-frame LIST reply went out as 3 back-to-back PTT keyups; the peer's ack +// arrived while we were still transmitting, so we never heard it and stalled +// into a T1 retransmit loop until link failure. A single-frame INFO reply with a +// clean listen window after it worked fine.) +static void testHalfDuplexWindowOneDrainsMultiFrame() +{ + Ax25Connection conn; + const Address local{QStringLiteral("N0PMS"), 1, false, false}; + const Address peer{QStringLiteral("K7ABC"), 0, false, false}; + conn.setLocalAddress(local); + conn.setPaclen(128); + + QVector tx; + QObject::connect(&conn, &Ax25Connection::sendFrame, + [&](const QByteArray& f) { tx.append(f); }); + + conn.onFrameReceived(Frame::makeU(local, peer, FrameType::SABM, true, true)); + tx.clear(); // drop the UA; we only care about I-frames below + + auto iFrameNs = [&]() { + QVector ns; // N(S) of every I-frame emitted so far + for (const QByteArray& f : tx) { + auto d = Frame::decode(f); + if (d && d->type == FrameType::I) + ns.append(d->ns); + } + return ns; + }; + + // A reply that needs three I-frames (128 + 128 + 44 bytes at paclen 128). + conn.sendData(QByteArray(300, 'X')); + CHECK(iFrameNs().size() == 1, "window=1: only one I-frame in flight initially"); + CHECK(iFrameNs().value(0) == 0, "first I-frame is N(S)=0"); + + // Peer acks the first (standalone RR N(R)=1) -> the next frame may go. + conn.onFrameReceived(Frame::makeS(local, peer, FrameType::RR, 1, false, false)); + CHECK(iFrameNs().size() == 2, "second I-frame goes only after the first is acked"); + CHECK(iFrameNs().value(1) == 1, "second I-frame is N(S)=1"); + + conn.onFrameReceived(Frame::makeS(local, peer, FrameType::RR, 2, false, false)); + CHECK(iFrameNs().size() == 3, "third I-frame goes only after the second is acked"); + CHECK(iFrameNs().value(2) == 2, "third I-frame is N(S)=2"); + + // Final ack drains the window with no retransmit storm. + conn.onFrameReceived(Frame::makeS(local, peer, FrameType::RR, 3, false, false)); + const QVector all = iFrameNs(); + CHECK(all.size() == 3, "exactly three I-frames sent, none retransmitted"); + CHECK(all.count(0) == 1 && all.count(1) == 1 && all.count(2) == 1, + "each I-frame transmitted exactly once (no duplicate/retransmit storm)"); +} + namespace { // Drives the mailbox from the perspective of a remote caller's TNC. struct Peer { @@ -137,6 +191,7 @@ struct Peer { QVector* allTx{nullptr}; int consumed{0}; // index into allTx already turned into text int peerNs{0}; + int pmsRx{0}; // count of in-sequence I-frames received from the mailbox int pmsVs() const { @@ -149,16 +204,31 @@ struct Peer { return n % 8; } - // New text the mailbox has sent since the last call. + // New text the mailbox has sent since the last call. Acknowledges each + // mailbox I-frame with an RR and loops, so a multi-frame reply fully drains + // under the half-duplex window=1 (one frame per ack) exactly as a real TNC + // would — without this, only the first I-frame of each reply would ever be + // emitted. QString drainText() { QByteArray t; - for (int i = consumed; i < allTx->size(); ++i) { - auto d = Frame::decode(allTx->at(i)); - if (d && d->type == FrameType::I && d->dest == peer) - t += d->info; + for (;;) { + int newFrames = 0; + for (; consumed < allTx->size(); ++consumed) { + auto d = Frame::decode(allTx->at(consumed)); + if (d && d->type == FrameType::I && d->dest == peer) { + t += d->info; + ++pmsRx; + ++newFrames; + } + } + if (newFrames == 0) + break; + // Ack everything received so far; this opens the mailbox's send + // window so it emits the next I-frame (collected on the next pass). + pms->onAirFrame( + Frame::makeS(local, peer, FrameType::RR, pmsRx % 8, false, false).encode()); } - consumed = allTx->size(); return QString::fromLatin1(t); } @@ -300,6 +370,7 @@ int main(int argc, char** argv) testAddress(); testFrameRoundTrip(); testConnection(); + testHalfDuplexWindowOneDrainsMultiFrame(); testMailbox(); testAliasDial();