From 0a406daf1fbe175f6e23f72048129af10f7f6a21 Mon Sep 17 00:00:00 2001 From: jensenpat Date: Sat, 30 May 2026 01:02:53 -0700 Subject: [PATCH 1/8] 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 | 286 ++++++++++ src/gui/Ax25HfPacketDecodeDialog.h | 20 + tests/pms_mailbox_test.cpp | 265 +++++++++ 11 files changed, 2468 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 d3635f82..a3575ea1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -577,8 +577,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) @@ -1951,6 +1954,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 f20008c1..7fa460fb 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" @@ -64,6 +65,17 @@ constexpr int kMaxKissTxQueueDepth = 64; // ride out an ATU tune or a long voice transmission, short enough that a // stuck-PTT radio doesn't permanently jam the queue. constexpr int kMaxKissTxBusyRetries = 60; + +// Personal Mailbox System (PMS) settings keys. +// TODO(Principle V): migrate these to a nested-JSON blob alongside TncSettings +// before this becomes the established pattern. Filed as a follow-up to issue #3424. +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; @@ -589,6 +601,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); @@ -604,14 +617,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()); @@ -682,6 +699,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); @@ -802,6 +821,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, @@ -855,6 +881,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.")); @@ -863,6 +915,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() @@ -1918,4 +1982,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 ef733473..4cdc3b92 100644 --- a/src/gui/Ax25HfPacketDecodeDialog.h +++ b/src/gui/Ax25HfPacketDecodeDialog.h @@ -26,6 +26,7 @@ namespace AetherSDR { class AudioEngine; class KissTncServer; class PacketActivityWidget; +class PmsMailbox; class RadioModel; class SliceModel; @@ -95,6 +96,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); @@ -188,6 +195,19 @@ class Ax25HfPacketDecodeDialog : public PersistentDialog { int m_kissTxBusyRetries{0}; 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 de5825fd4038b7d313b22b11b781df2bd0af240c Mon Sep 17 00:00:00 2001 From: jensenpat Date: Sat, 30 May 2026 08:23:59 -0700 Subject: [PATCH 2/8] =?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 7fa460fb..926d6489 100644 --- a/src/gui/Ax25HfPacketDecodeDialog.cpp +++ b/src/gui/Ax25HfPacketDecodeDialog.cpp @@ -70,11 +70,11 @@ constexpr int kMaxKissTxBusyRetries = 60; // TODO(Principle V): migrate these to a nested-JSON blob alongside TncSettings // before this becomes the established pattern. Filed as a follow-up to issue #3424. 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; @@ -741,32 +741,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()); @@ -893,7 +915,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(); }); @@ -2007,19 +2033,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); @@ -2043,6 +2079,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); @@ -2051,29 +2104,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() @@ -2090,11 +2133,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) @@ -2104,8 +2146,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) @@ -2132,10 +2176,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); @@ -2143,7 +2187,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 4cdc3b92..c6eed9b2 100644 --- a/src/gui/Ax25HfPacketDecodeDialog.h +++ b/src/gui/Ax25HfPacketDecodeDialog.h @@ -200,7 +200,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 e48d5000f387986b2987c488eaf4682385f65b58 Mon Sep 17 00:00:00 2001 From: jensenpat Date: Sat, 30 May 2026 10:13:20 -0700 Subject: [PATCH 3/8] =?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 a3575ea1..b8b11bb5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1941,6 +1941,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 @@ -1954,6 +1955,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 42d316000abbe83e498eaf7446fc6d41a1664108 Mon Sep 17 00:00:00 2001 From: jensenpat Date: Sat, 30 May 2026 13:16:02 -0700 Subject: [PATCH 4/8] 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 8b0a28bcd16ac9b98e1c13569d902816c37ddf09 Mon Sep 17 00:00:00 2001 From: jensenpat Date: Sat, 30 May 2026 13:48:29 -0700 Subject: [PATCH 5/8] 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(); From 307ebbf3568bd8a0458d195a31408d63c610f70b Mon Sep 17 00:00:00 2001 From: jensenpat Date: Wed, 3 Jun 2026 14:34:49 -0700 Subject: [PATCH 6/8] =?UTF-8?q?feat(modem):=20built-in=20TNC=20terminal=20?= =?UTF-8?q?=E2=80=94=20connected-mode=20AX.25=20client=20for=201200-baud?= =?UTF-8?q?=20packet=20BBS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "Terminal" tab to AetherModem: a connected-mode AX.25 client that calls a VHF packet BBS, reads/sends messages, and disconnects, sharing the data-link machinery (Ax25Connection) with the PMS mailbox rather than duplicating it. Connection state machine (client role added to the shared Ax25Connection): - Outbound connectTo() with SABM/UA handshake, N2 retries, optional VIA digipeater path. - Lost-UA adoption: an inbound I/RR/RNR/REJ received while still connecting is treated as proof the UA was lost, and the link is adopted. - T2 deferred-ack: on a half-duplex link, defer an unpolled ack so we don't key the radio mid-burst and go deaf to the rest of the peer's window. - Silent reject-exception: send exactly one REJ per sequence gap, then listen — even on polled retransmits — to break the half-duplex REJ phase-lock that stalled multi-frame replies (e.g. a long BBS help menu). - REJ recovery resends the outstanding I-frames from the store (the old path rewound V(S) and called pumpOutbound() on an already-drained buffer, so it resent nothing and the link silently desynced). - ackUpTo() ignores an out-of-range N(R) instead of corrupting the send window. - Poll on the window-filling I-frame so the peer acknowledges promptly. - Per-session telemetry counters (I sent/resent/rcvd/dropped, RR/REJ/RNR in & out, T1 timeouts, T2 acks, FRMR, ignored bad-N(R)). Terminal UI + commands: - CONNECT [VIA digi...], BYE/DISC, CONV, STATUS, MHEARD, MYCALL, LOG, ESCAPE, HELP. - Monospace transcript, Up/Down command history, right-click Clear / Command Mode. - Quick-connect dropdown from a shared HeardList; timestamped session logging; last-called BBS persisted across restarts. - Tunable T1 / N2 / paclen and a tunable TX tail (half-duplex turnaround); live drop/resent readout in the status line. - Auto-enables the modem RX tap when a connect is initiated. Shared / refactor: - New HeardList class backing MHEARD and quick-connect (reusable by PMS and a future digipeater). - FramelessWindowTitleBar: min/max/close buttons are no longer the dialog's default button, so pressing Return in a text field no longer minimizes the window (macOS) in any AetherModem field. Tooling / tests: - tools/ax25_session_analyze: replays a capture WAV through the real decoder AND the real state machine to surface sequencing / timer / retry gaps. - tests/tnc_terminal_test: connect/converse/disconnect, VIA, MHEARD, lost-UA adoption, multi-frame deferred ack, REJ resend, reject-exception storm suppression, invalid-N(R) guard, CONV/STATUS. Stacked on #3279 (KISS-over-TCP TNC + AetherModem UX) and #3290 (PMS over connected-mode AX.25); this branch includes both until they merge. Co-Authored-By: Claude Opus 4.8 Co-authored-by: Codex --- CMakeLists.txt | 27 ++ src/core/tnc/Ax25Connection.cpp | 224 +++++++++-- src/core/tnc/Ax25Connection.h | 74 +++- src/core/tnc/HeardList.cpp | 177 +++++++++ src/core/tnc/HeardList.h | 67 ++++ src/core/tnc/TncTerminal.cpp | 529 ++++++++++++++++++++++++++ src/core/tnc/TncTerminal.h | 169 +++++++++ src/gui/Ax25HfPacketDecodeDialog.cpp | 513 ++++++++++++++++++++++++- src/gui/Ax25HfPacketDecodeDialog.h | 49 +++ src/gui/FramelessWindowTitleBar.cpp | 12 + tests/tnc_terminal_test.cpp | 534 +++++++++++++++++++++++++++ tools/ax25_session_analyze.cpp | 187 ++++++++++ 12 files changed, 2532 insertions(+), 30 deletions(-) create mode 100644 src/core/tnc/HeardList.cpp create mode 100644 src/core/tnc/HeardList.h create mode 100644 src/core/tnc/TncTerminal.cpp create mode 100644 src/core/tnc/TncTerminal.h create mode 100644 tests/tnc_terminal_test.cpp create mode 100644 tools/ax25_session_analyze.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index b8b11bb5..d315da99 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -579,8 +579,10 @@ set(CORE_SOURCES src/core/tnc/Ax25FrameFormatter.cpp src/core/tnc/Ax25.cpp src/core/tnc/Ax25Connection.cpp + src/core/tnc/HeardList.cpp src/core/tnc/KissFraming.cpp src/core/tnc/KissTncServer.cpp + src/core/tnc/TncTerminal.cpp src/core/pms/PmsMailbox.cpp ) @@ -1969,6 +1971,20 @@ add_executable(ax25_replay EXCLUDE_FROM_ALL target_include_directories(ax25_replay PRIVATE src) target_link_libraries(ax25_replay PRIVATE Qt6::Core aether_libmodem_core) +add_executable(ax25_session_analyze EXCLUDE_FROM_ALL + tools/ax25_session_analyze.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/LogManager.cpp + src/core/AsyncLogWriter.cpp + src/core/AppSettings.cpp +) +target_include_directories(ax25_session_analyze PRIVATE src) +target_link_libraries(ax25_session_analyze PRIVATE Qt6::Core aether_libmodem_core) + add_executable(pms_mailbox_test tests/pms_mailbox_test.cpp src/core/tnc/Ax25.cpp @@ -1980,6 +1996,17 @@ 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(tnc_terminal_test + tests/tnc_terminal_test.cpp + src/core/tnc/Ax25.cpp + src/core/tnc/Ax25Connection.cpp + src/core/tnc/HeardList.cpp + src/core/tnc/TncTerminal.cpp +) +target_include_directories(tnc_terminal_test PRIVATE src) +target_link_libraries(tnc_terminal_test PRIVATE Qt6::Core) +add_test(NAME tnc_terminal_test COMMAND tnc_terminal_test) + add_executable(cwx_panel_test tests/cwx_panel_test.cpp src/gui/CwxPanel.cpp diff --git a/src/core/tnc/Ax25Connection.cpp b/src/core/tnc/Ax25Connection.cpp index a967a4cf..7788ae62 100644 --- a/src/core/tnc/Ax25Connection.cpp +++ b/src/core/tnc/Ax25Connection.cpp @@ -14,6 +14,10 @@ Ax25Connection::Ax25Connection(QObject* parent) m_t1 = new QTimer(this); m_t1->setSingleShot(true); connect(m_t1, &QTimer::timeout, this, &Ax25Connection::onT1Timeout); + + m_t2 = new QTimer(this); + m_t2->setSingleShot(true); + connect(m_t2, &QTimer::timeout, this, &Ax25Connection::onT2Timeout); } Ax25Connection::~Ax25Connection() = default; @@ -40,8 +44,39 @@ void Ax25Connection::stopT1() m_t1->stop(); } -void Ax25Connection::transmit(const Frame& frame) +void Ax25Connection::startT2() +{ + m_t2->start(m_t2Ms); +} + +void Ax25Connection::stopT2() +{ + m_t2->stop(); +} + +void Ax25Connection::onT2Timeout() +{ + // The peer's burst has gone idle; send the single coalesced acknowledgement. + if (m_state == State::Connected && m_ackPending) { + ++m_stats.t2Acks; + sendAck(/*pollFinal=*/false); + } +} + +void Ax25Connection::sendAck(bool pollFinal) +{ + stopT2(); + m_ackPending = false; + sendSupervisory(FrameType::RR, pollFinal, /*command=*/false); +} + +QByteArray Ax25Connection::transmit(Frame frame) { + if (frame.type == FrameType::I) + ++m_stats.iSent; + else if (frame.type == FrameType::REJ) + ++m_stats.rejSent; + frame.via = m_via; // attach the digipeater path (H=0, not yet repeated) emit activity(QStringLiteral("TX %1 %2>%3%4%5") .arg(ax25::frameTypeName(frame.type), frame.src.toString(), @@ -53,7 +88,9 @@ void Ax25Connection::transmit(const Frame& frame) ? QStringLiteral(" NR=%1").arg(frame.nr) : QString(), frame.pollFinal ? QStringLiteral(" P/F") : QString())); - emit sendFrame(frame.encode()); + const QByteArray raw = frame.encode(); + emit sendFrame(raw); + return raw; } void Ax25Connection::sendUFrame(FrameType type, bool pollFinal, bool command) @@ -73,10 +110,14 @@ void Ax25Connection::enterConnected(const Address& peer) m_vs = m_vr = m_va = 0; m_retryCount = 0; m_peerBusy = false; + m_ackPending = false; + m_rejectSent = false; + m_stats = Stats{}; // fresh telemetry for this session m_sendBuffer.clear(); for (bool& valid : m_iFrameValid) valid = false; stopT1(); + stopT2(); emit activity(QStringLiteral("Connected to %1").arg(peer.toString())); emit connected(peer); } @@ -85,6 +126,7 @@ void Ax25Connection::enterDisconnected(bool byPeer) { const Address peer = m_remote; stopT1(); + stopT2(); m_state = State::Disconnected; m_sendBuffer.clear(); for (bool& valid : m_iFrameValid) @@ -92,13 +134,39 @@ void Ax25Connection::enterDisconnected(bool byPeer) m_vs = m_vr = m_va = 0; m_retryCount = 0; m_peerBusy = false; + m_ackPending = false; + m_rejectSent = false; m_remote = Address{}; + m_via.clear(); 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); } +void Ax25Connection::connectTo(const Address& peer, const QVector
& via) +{ + if (m_state != State::Disconnected || !peer.isValid()) + return; + m_via.clear(); + for (Address hop : via) { // freshly-keyed: none repeated yet + hop.hasBeenRepeated = false; + m_via.append(hop); + } + m_local = m_primary; // outbound calls always go out under our primary address + m_remote = peer; + m_state = State::Connecting; + m_vs = m_vr = m_va = 0; + m_retryCount = 0; + m_peerBusy = false; + m_sendBuffer.clear(); + for (bool& valid : m_iFrameValid) + valid = false; + emit activity(QStringLiteral("Connecting to %1").arg(peer.toString())); + sendUFrame(FrameType::SABM, /*pollFinal=*/true, /*command=*/true); + startT1(); +} + void Ax25Connection::onFrameReceived(const Frame& frame) { // Only react to frames addressed to us. While idle we answer on either the @@ -124,6 +192,23 @@ void Ax25Connection::onFrameReceived(const Frame& frame) ? QStringLiteral(" NS=%1 NR=%2").arg(frame.ns).arg(frame.nr) : QString())); + // Lost-UA recovery. We sent a SABM and are still awaiting its UA, but the + // peer is already exchanging connected-mode frames with us (I / RR / RNR / + // REJ) — proof it accepted our connect and our UA was simply lost on the + // air. Adopt the link now and let the frame be handled normally below, + // instead of stalling in SABM retransmits. Each duplicate SABM resets the + // peer's link state (and its prompt), so on a marginal half-duplex path this + // is the difference between a working session and a connect that goes live + // but never passes data. (UA and DM are handled explicitly in the switch.) + if (m_state == State::Connecting && frame.src == m_remote + && (frame.type == FrameType::I || frame.type == FrameType::RR + || frame.type == FrameType::RNR || frame.type == FrameType::REJ)) { + emit activity(QStringLiteral("Adopting link to %1 (UA lost; peer already connected)") + .arg(m_remote.toString())); + stopT1(); + enterConnected(m_remote); + } + switch (frame.type) { case FrameType::SABM: { // One caller at a time: if busy with a different peer, refuse politely. @@ -152,13 +237,23 @@ void Ax25Connection::onFrameReceived(const Frame& frame) break; } case FrameType::UA: { - if (m_state == State::Disconnecting) + if (m_state == State::Connecting && frame.src == m_remote) { + stopT1(); + enterConnected(m_remote); // our SABM was accepted + } else if (m_state == State::Disconnecting) { enterDisconnected(/*byPeer=*/false); + } break; } case FrameType::DM: { - if (m_state != State::Disconnected) + if (m_state == State::Connecting && frame.src == m_remote) { + // The peer refused our connect request. + emit activity(QStringLiteral("Connect refused by %1 (DM)").arg(m_remote.toString())); + emit connectFailed(m_remote, QStringLiteral("refused (DM)")); + enterDisconnected(/*byPeer=*/true); + } else if (m_state != State::Disconnected) { enterDisconnected(/*byPeer=*/true); + } break; } case FrameType::I: { @@ -169,53 +264,97 @@ void Ax25Connection::onFrameReceived(const Frame& frame) } ackUpTo(frame.nr); if (frame.ns == m_vr) { - // In-sequence: accept and advance V(R). + // In-sequence: accept and advance V(R). Clears any reject exception. + ++m_stats.iRcvd; + m_rejectSent = false; if (!frame.info.isEmpty()) emit dataReceived(frame.info); m_vr = (m_vr + 1) % 8; + m_ackPending = true; + // If we have data to send, it piggybacks the ack (N(R)) for free. pumpOutbound(); - // Acknowledge. A command with the poll bit demands a final response. - sendSupervisory(FrameType::RR, /*pollFinal=*/frame.pollFinal, - /*command=*/false); + if (m_ackPending) { + if (frame.pollFinal) { + // The peer polled us: it has stopped transmitting and is + // waiting for a final, so ack immediately. + sendAck(/*pollFinal=*/true); + } else { + // Unpolled mid-burst frame: defer the ack so we don't key the + // radio (and go deaf) while the peer is still sending the rest + // of its window. T2 sends the coalesced RR once the burst ends. + startT2(); + } + } } else { - // Out of sequence: ask for retransmission from V(R). - sendSupervisory(FrameType::REJ, /*pollFinal=*/frame.pollFinal, - /*command=*/false); + // Out of sequence. Send REJ exactly ONCE per gap (reject exception), + // then discard further out-of-sequence frames SILENTLY — even polled + // ones. This is the crucial half-duplex behaviour: answering every + // polled retransmit makes the peer retransmit immediately, and on our + // slow radio turnaround that retransmission lands while we are still + // keyed/switching and we miss the very frame we need (observed live + // with SJVBBS-1: a 4-REJ phase-lock that never recovered NS=1). By + // staying quiet we let the peer's own T1 retransmit arrive while we + // are actually listening. We do echo the poll/final on the single REJ + // so the peer still gets one prompt response. + stopT2(); + m_ackPending = false; + ++m_stats.iDropped; + if (!m_rejectSent) { + m_rejectSent = true; + sendSupervisory(FrameType::REJ, /*pollFinal=*/frame.pollFinal, + /*command=*/false); + } } break; } case FrameType::RR: { if (m_state != State::Connected) break; + ++m_stats.rrRcvd; m_peerBusy = false; ackUpTo(frame.nr); - // A command poll requires us to respond with a final. + // Answer a command poll with a final. If we're missing a frame (reject + // exception), re-request it with a REJ rather than a plain RR, so a peer + // that only resends on an explicit REJ knows to retransmit the gap. if (frame.command && frame.pollFinal) - sendSupervisory(FrameType::RR, /*pollFinal=*/true, /*command=*/false); + sendSupervisory(m_rejectSent ? FrameType::REJ : FrameType::RR, + /*pollFinal=*/true, /*command=*/false); pumpOutbound(); break; } case FrameType::RNR: { if (m_state != State::Connected) break; + ++m_stats.rnrRcvd; m_peerBusy = true; ackUpTo(frame.nr); if (frame.command && frame.pollFinal) - sendSupervisory(FrameType::RR, /*pollFinal=*/true, /*command=*/false); + sendSupervisory(m_rejectSent ? FrameType::REJ : FrameType::RR, + /*pollFinal=*/true, /*command=*/false); break; } case FrameType::REJ: { if (m_state != State::Connected) break; + ++m_stats.rejRcvd; m_peerBusy = false; + // Confirm the frames the peer DID receive (everything before N(R)). ackUpTo(frame.nr); - // Retransmit everything from the rejected sequence number forward. - m_vs = frame.nr; m_retryCount = 0; - pumpOutbound(); + // Resend the still-outstanding I-frames [V(A), V(S)) from our store. + // NOTE: we must NOT do `m_vs = frame.nr; pumpOutbound();` — the frames' + // payload was already consumed from m_sendBuffer when first sent, so + // pumpOutbound() would resend nothing, the link would silently desync + // (the peer keeps REJ-ing, never receives the frame, and finally drops + // us), and rewinding V(S) would also discard the unacked frames. + if (outstanding() > 0) + retransmitUnacked(); // replays [V(A), V(S)) from m_sentIFrames[] + else + pumpOutbound(); // nothing outstanding: push any new data break; } case FrameType::FRMR: { + ++m_stats.frmrRcvd; // Protocol error reported by peer: re-establish by tearing down. if (m_state != State::Disconnected) { sendUFrame(FrameType::DM, /*pollFinal=*/false, /*command=*/false); @@ -231,6 +370,19 @@ void Ax25Connection::onFrameReceived(const Frame& frame) void Ax25Connection::ackUpTo(int nr) { + nr &= 7; + // A valid N(R) acknowledges somewhere in [V(A), V(S)]. Reject an N(R) that + // claims frames we never sent — applying it would walk V(A) past V(S) around + // the mod-8 ring and corrupt the send window (observed: a peer REJ/RR with a + // stale N(R) inflated `outstanding()` and triggered bogus retransmits). AX.25 + // treats this as a link error; we conservatively ignore it. + const int toAck = (nr - m_va + 8) % 8; + if (toAck > outstanding()) { + ++m_stats.invalidNr; + emit activity(QStringLiteral("Ignoring invalid N(R)=%1 (V(A)=%2 V(S)=%3)") + .arg(nr).arg(m_va).arg(m_vs)); + return; + } // Free acknowledged I-frame slots in the range [V(A), nr). while (m_va != nr) { m_iFrameValid[m_va] = false; @@ -260,12 +412,22 @@ void Ax25Connection::pumpOutbound() const QByteArray segment = m_sendBuffer.left(m_paclen); m_sendBuffer.remove(0, segment.size()); const int ns = m_vs; + // Poll on the frame that fills the window or empties our buffer (for the + // default window=1, that's every frame). P=1 obliges the half-duplex peer + // to acknowledge immediately rather than deferring its ack — without it, + // a peer that batches acks leaves our frame unacknowledged until T1 and we + // retransmit needlessly (the observed multi-second command stalls). + const bool poll = m_sendBuffer.isEmpty() || (outstanding() + 1) >= m_window; Frame iFrame = Frame::makeI(m_remote, m_local, ns, m_vr, - /*pollFinal=*/false, segment); - m_sentIFrames[ns] = iFrame.encode(); + /*pollFinal=*/poll, segment); m_iFrameValid[ns] = true; m_vs = (m_vs + 1) % 8; - transmit(iFrame); + // Store the exact bytes sent (digipeater path included) for retransmit. + m_sentIFrames[ns] = transmit(iFrame); + // The I-frame carries N(R) = V(R), so it acknowledges everything we have + // received — no separate RR needed, and cancel any deferred ack. + m_ackPending = false; + stopT2(); startT1(); } } @@ -289,6 +451,7 @@ void Ax25Connection::retransmitUnacked() } emit sendFrame(raw); ++sent; + ++m_stats.iResent; } seq = (seq + 1) % 8; } @@ -305,8 +468,16 @@ void Ax25Connection::onT1Timeout() { if (m_state == State::Disconnected) return; + ++m_stats.t1Timeouts; if (m_retryCount >= m_n2) { + if (m_state == State::Connecting) { + emit activity(QStringLiteral("Connect failed: no response from %1 after %2 retries") + .arg(m_remote.toString()).arg(m_n2)); + emit connectFailed(m_remote, QStringLiteral("no response")); + enterDisconnected(/*byPeer=*/true); + return; + } 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); @@ -316,6 +487,13 @@ void Ax25Connection::onT1Timeout() } ++m_retryCount; + if (m_state == State::Connecting) { + emit activity(QStringLiteral("SABM retry %1/%2").arg(m_retryCount).arg(m_n2)); + sendUFrame(FrameType::SABM, /*pollFinal=*/true, /*command=*/true); + startT1(); + return; + } + if (m_state == State::Disconnecting) { sendUFrame(FrameType::DISC, /*pollFinal=*/true, /*command=*/true); startT1(); @@ -337,10 +515,12 @@ void Ax25Connection::disconnect() void Ax25Connection::reset() { - if (m_state != State::Disconnected) + if (m_state != State::Disconnected) { enterDisconnected(/*byPeer=*/false); - else + } else { stopT1(); + stopT2(); + } } } // namespace AetherSDR diff --git a/src/core/tnc/Ax25Connection.h b/src/core/tnc/Ax25Connection.h index 84345230..50888900 100644 --- a/src/core/tnc/Ax25Connection.h +++ b/src/core/tnc/Ax25Connection.h @@ -31,6 +31,7 @@ class Ax25Connection : public QObject { public: enum class State { Disconnected, // no peer + Connecting, // SABM sent, awaiting UA (outbound connect) Connected, // information transfer Disconnecting // DISC sent, awaiting UA }; @@ -52,10 +53,44 @@ class Ax25Connection : public QObject { State state() const { return m_state; } bool isConnected() const { return m_state == State::Connected; } + // Live data-link counters for status display. + int sendSeq() const { return m_vs; } // V(S) + int recvSeq() const { return m_vr; } // V(R) + int retries() const { return m_retryCount; } + int maxRetries() const { return m_n2; } + int unacked() const { return outstanding(); } // I-frames in flight + int sendQueueBytes() const { return int(m_sendBuffer.size()); } // unsent data + + // Per-session telemetry (reset each time a link comes up). + struct Stats { + quint32 iSent{0}; // new I-frames transmitted + quint32 iResent{0}; // I-frame retransmissions (T1 / REJ recovery) + quint32 iRcvd{0}; // in-sequence I-frames accepted + quint32 iDropped{0}; // out-of-sequence I-frames discarded + quint32 rrRcvd{0}; // RR received from peer + quint32 rnrRcvd{0}; // RNR (peer busy) received + quint32 rejRcvd{0}; // REJ received from peer + quint32 rejSent{0}; // REJ we sent + quint32 t1Timeouts{0}; // T1 expiries (no ack in time) + quint32 t2Acks{0}; // deferred (T2) acknowledgements sent + quint32 frmrRcvd{0}; // FRMR (frame reject) from peer + quint32 invalidNr{0}; // out-of-range N(R) ignored + }; + const Stats& stats() const { return m_stats; } + // 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); } + // Acknowledgement-delay timer T2 (ms). On a half-duplex link we must NOT key + // up to acknowledge an in-sequence I-frame while the peer is still mid-burst + // sending the rest of a window — doing so makes us deaf to the remaining + // frames and stalls multi-frame replies (a long BBS help menu, say). Instead + // we defer the RR ack: a polled frame (P=1, peer is now listening) is acked + // at once, an unpolled one starts T2 and the coalesced RR is sent when the + // burst goes idle. Must be < T1. + void setAckDelayMs(int t2) { m_t2Ms = qBound(200, t2, 10000); } + // 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) @@ -66,6 +101,19 @@ class Ax25Connection : public QObject { // safely raise this. void setWindow(int k) { m_window = qBound(1, k, 7); } + // Initiate an outbound connect to `peer` (the terminal "client" role): send + // SABM and await UA, retransmitting on T1 up to N2 before giving up. No-op + // unless idle. On success emits connected(); on refusal (DM) or N2 exhaustion + // emits connectFailed() then disconnected(). + // + // `via` is an optional digipeater path (0-8 hops, e.g. WIDE1-1). When set, + // every outbound frame in the session carries it so the digipeater(s) repeat + // us to the peer; returning frames are matched on dest regardless of path. + void connectTo(const ax25::Address& peer, + const QVector& via = {}); + // The digipeater path of the current/last session (empty for a direct link). + QVector viaPath() const { return m_via; } + // 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); @@ -87,6 +135,11 @@ class Ax25Connection : public QObject { // Connection established with the given peer. void connected(const ax25::Address& peer); + // An outbound connectTo() attempt failed before reaching Connected — the peer + // refused (DM) or never answered (N2 exhausted). `reason` is human-readable. + // disconnected() still follows so callers that only watch that can rely on it. + void connectFailed(const ax25::Address& peer, const QString& reason); + // 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); @@ -100,7 +153,9 @@ class Ax25Connection : public QObject { private: void enterConnected(const ax25::Address& peer); void enterDisconnected(bool byPeer); - void transmit(const ax25::Frame& frame); + // Attach the digipeater path, encode, key it on the air, and return the raw + // bytes (so I-frames can be stored for retransmission exactly as sent). + QByteArray transmit(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 @@ -109,12 +164,17 @@ class Ax25Connection : public QObject { void startT1(); void stopT1(); void onT1Timeout(); + void startT2(); // (re)arm the deferred-ack timer + void stopT2(); + void onT2Timeout(); // burst idle -> send the coalesced RR ack + void sendAck(bool pollFinal); // RR (or piggyback) for everything received int outstanding() const; // unacked I-frames in flight 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; + QVector m_via; // digipeater path for outbound frames (H=0) State m_state{State::Disconnected}; int m_vs{0}; // V(S) next send sequence @@ -125,13 +185,23 @@ class Ax25Connection : public QObject { int m_paclen{128}; int m_n2{8}; int m_t1Ms{6000}; + int m_t2Ms{2000}; // deferred-ack delay (half-duplex burst guard) int m_retryCount{0}; - bool m_peerBusy{false}; // peer sent RNR + bool m_peerBusy{false}; // peer sent RNR + bool m_ackPending{false}; // received I-frame(s) not yet acknowledged + // Reject-exception (AX.25): true once we've REJ'd a sequence gap. While set, + // further out-of-sequence I-frames are discarded WITHOUT another REJ, so we + // don't key the radio repeatedly and (on half-duplex) go deaf to the very + // retransmission we asked for. Cleared when the awaited in-sequence frame + // finally arrives. + bool m_rejectSent{false}; 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}; + QTimer* m_t2{nullptr}; + Stats m_stats; }; } // namespace AetherSDR diff --git a/src/core/tnc/HeardList.cpp b/src/core/tnc/HeardList.cpp new file mode 100644 index 00000000..a801e086 --- /dev/null +++ b/src/core/tnc/HeardList.cpp @@ -0,0 +1,177 @@ +#include "core/tnc/HeardList.h" + +#include +#include +#include +#include +#include +#include + +#include + +namespace AetherSDR { + +using ax25::Address; +using ax25::Frame; +using ax25::FrameType; + +namespace { +// A printable, single-line rendering of a UI beacon's info field (control chars +// and CR/LF collapsed to spaces) so MHEARD stays one row per station. +QString beaconText(const QByteArray& info) +{ + QString text = QString::fromLatin1(info).trimmed(); + for (QChar& c : text) { + if (c < QChar(0x20)) + c = QLatin1Char(' '); + } + return text.simplified(); +} +} // namespace + +HeardList::HeardList(QObject* parent) + : QObject(parent) +{ +} + +HeardList::~HeardList() = default; + +void HeardList::setPersistencePath(const QString& path) +{ + m_path = path; + if (!m_path.isEmpty()) + load(); +} + +void HeardList::record(const Frame& frame) +{ + if (!frame.src.isValid()) + return; + + QString via; + if (!frame.via.isEmpty()) { + QStringList v; + for (const Address& a : frame.via) + v << a.toString(); + via = v.join(QLatin1Char(',')); + } + + const QDateTime now = QDateTime::currentDateTimeUtc(); + const bool isBeacon = (frame.type == FrameType::UI) && !frame.info.isEmpty(); + + for (Station& s : m_stations) { + if (s.station == frame.src) { + s.utc = now; + s.dest = frame.dest.toString(); + s.via = via; + ++s.count; + if (isBeacon) { + s.lastBeacon = beaconText(frame.info); + s.beaconUtc = now; + } + save(); + emit changed(); + return; + } + } + + Station s; + s.station = frame.src; + s.dest = frame.dest.toString(); + s.via = via; + s.utc = now; + if (isBeacon) { + s.lastBeacon = beaconText(frame.info); + s.beaconUtc = now; + } + m_stations.append(s); + + if (m_stations.size() > m_max) { + std::sort(m_stations.begin(), m_stations.end(), + [](const Station& a, const Station& b) { return a.utc > b.utc; }); + m_stations.resize(m_max); + } + save(); + emit changed(); +} + +QVector HeardList::stations(int max) const +{ + QVector sorted = m_stations; + std::sort(sorted.begin(), sorted.end(), + [](const Station& a, const Station& b) { return a.utc > b.utc; }); + if (max >= 0 && sorted.size() > max) + sorted.resize(max); + return sorted; +} + +QStringList HeardList::summary(int n) const +{ + const QVector sorted = stations(n); + QStringList out; + for (const Station& s : sorted) { + out << QStringLiteral("%1 %2 %3") + .arg(s.station.toString().leftJustified(9), + s.utc.toString(QStringLiteral("MM/dd HH:mm")), + s.via); + } + return out; +} + +void HeardList::clear() +{ + m_stations.clear(); + save(); + emit changed(); +} + +void HeardList::load() +{ + m_stations.clear(); + QFile f(m_path); + if (!f.open(QIODevice::ReadOnly)) + return; + const QJsonObject root = QJsonDocument::fromJson(f.readAll()).object(); + for (const QJsonValue& v : root.value(QStringLiteral("heard")).toArray()) { + const QJsonObject o = v.toObject(); + auto addr = Address::parse(o.value(QStringLiteral("call")).toString()); + if (!addr) + continue; + Station s; + s.station = *addr; + s.dest = o.value(QStringLiteral("dest")).toString(); + s.via = o.value(QStringLiteral("via")).toString(); + s.lastBeacon = o.value(QStringLiteral("beacon")).toString(); + s.utc = QDateTime::fromString(o.value(QStringLiteral("utc")).toString(), Qt::ISODate); + s.beaconUtc = QDateTime::fromString( + o.value(QStringLiteral("beaconUtc")).toString(), Qt::ISODate); + s.count = o.value(QStringLiteral("count")).toInt(1); + m_stations.append(s); + } +} + +void HeardList::save() const +{ + if (m_path.isEmpty()) + return; + QDir().mkpath(QFileInfo(m_path).absolutePath()); + QJsonArray arr; + for (const Station& s : m_stations) { + QJsonObject o; + o.insert(QStringLiteral("call"), s.station.toString()); + o.insert(QStringLiteral("dest"), s.dest); + o.insert(QStringLiteral("via"), s.via); + o.insert(QStringLiteral("beacon"), s.lastBeacon); + o.insert(QStringLiteral("utc"), s.utc.toString(Qt::ISODate)); + o.insert(QStringLiteral("beaconUtc"), s.beaconUtc.toString(Qt::ISODate)); + o.insert(QStringLiteral("count"), s.count); + arr.append(o); + } + QJsonObject root; + root.insert(QStringLiteral("heard"), arr); + QFile f(m_path); + if (f.open(QIODevice::WriteOnly | QIODevice::Truncate)) + f.write(QJsonDocument(root).toJson()); +} + +} // namespace AetherSDR diff --git a/src/core/tnc/HeardList.h b/src/core/tnc/HeardList.h new file mode 100644 index 00000000..3ffeae9f --- /dev/null +++ b/src/core/tnc/HeardList.h @@ -0,0 +1,67 @@ +#pragma once + +#include "core/tnc/Ax25.h" + +#include +#include +#include +#include +#include + +namespace AetherSDR { + +// A station-heard log shared by the PMS mailbox (JHEARD) and the TNC terminal +// (MHEARD): who has been decoded on the air, when, via what path, and the text +// of the last UI beacon they sent. It is RF-agnostic — feed it every decoded +// ax25::Frame via record() — and optionally persists to JSON so the list +// survives restarts. The PMS header always anticipated this being "split out so +// the future APRS/AX.25 digipeater can reuse the same plumbing"; this is it. +class HeardList : public QObject { + Q_OBJECT + +public: + struct Station { + ax25::Address station; // source callsign incl. SSID + QString dest; // last destination heard + QString via; // last digipeater path (comma-joined), or empty + QString lastBeacon; // text of the last UI (beacon) frame, if any + QDateTime utc; // last heard (UTC) + QDateTime beaconUtc; // when lastBeacon was captured (UTC) + int count{1}; // total frames heard from this station + }; + + explicit HeardList(QObject* parent = nullptr); + ~HeardList() override; + + // Point at a JSON file to load now and persist to on every change. Pass an + // empty path for an in-memory-only list (the default). + void setPersistencePath(const QString& path); + void setMaxStations(int n) { m_max = qBound(10, n, 5000); } + + // Record a decoded frame. The src becomes/refreshes a Station entry; a UI + // frame with non-empty info also updates that station's last-beacon text. + void record(const ax25::Frame& frame); + + // Stations, most-recently-heard first, capped at `max`. + QVector stations(int max = 200) const; + + // Compact "CALL-SSID MM/dd HH:mm via" lines (PMS JHEARD compatible). + QStringList summary(int n) const; + + int size() const { return m_stations.size(); } + bool isEmpty() const { return m_stations.isEmpty(); } + void clear(); + +signals: + void changed(); + +private: + void load(); + void save() const; + + QVector m_stations; + QString m_path; + int m_max{200}; +}; + +} // namespace AetherSDR diff --git a/src/core/tnc/TncTerminal.cpp b/src/core/tnc/TncTerminal.cpp new file mode 100644 index 00000000..9bf2197a --- /dev/null +++ b/src/core/tnc/TncTerminal.cpp @@ -0,0 +1,529 @@ +#include "core/tnc/TncTerminal.h" + +#include "core/tnc/Ax25Connection.h" +#include "core/tnc/HeardList.h" + +#include +#include +#include +#include +#include +#include + +namespace AetherSDR { + +using ax25::Address; +using ax25::Frame; + +TncTerminal::TncTerminal(QObject* parent) + : QObject(parent) +{ + m_link = new Ax25Connection(this); + // Defaults sized for 1200-baud VHF FM with PTT overhead — same as the PMS. + m_link->setRetryTimeoutMs(6000); + m_link->setMaxRetries(8); + m_link->setPaclen(128); + + connect(m_link, &Ax25Connection::sendFrame, this, &TncTerminal::transmitFrame); + connect(m_link, &Ax25Connection::activity, this, &TncTerminal::onLinkActivity); + connect(m_link, &Ax25Connection::connected, this, &TncTerminal::onLinkConnected); + connect(m_link, &Ax25Connection::disconnected, this, &TncTerminal::onLinkDisconnected); + connect(m_link, &Ax25Connection::connectFailed, this, &TncTerminal::onLinkConnectFailed); + connect(m_link, &Ax25Connection::dataReceived, this, &TncTerminal::onLinkData); +} + +TncTerminal::~TncTerminal() +{ + setLogging(false); +} + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +void TncTerminal::setMyCall(const QString& callWithSsid) +{ + auto parsed = Address::parse(callWithSsid.trimmed()); + m_myCall = parsed ? *parsed : Address{}; + m_link->setLocalAddress(m_myCall); + emit stateChanged(); +} + +QString TncTerminal::myCall() const +{ + return m_myCall.isValid() ? m_myCall.toString() : QString(); +} + +bool TncTerminal::hasMyCall() const +{ + return m_myCall.isValid(); +} + +void TncTerminal::setEscapeChar(QChar c) +{ + if (!c.isNull()) + m_escape = c; +} + +void TncTerminal::setRetryTimeoutMs(int t1) { m_link->setRetryTimeoutMs(t1); } +void TncTerminal::setMaxRetries(int n2) { m_link->setMaxRetries(n2); } +void TncTerminal::setPaclen(int bytes) { m_link->setPaclen(bytes); } + +bool TncTerminal::isConnected() const +{ + return m_link->isConnected(); +} + +QString TncTerminal::peerCall() const +{ + const Address peer = m_link->remoteAddress(); + return peer.isValid() ? peer.toString() : QString(); +} + +QString TncTerminal::statusSummary() const +{ + if (m_link->isConnected()) + return QStringLiteral("Connected to %1").arg(peerCall()); + if (m_connecting) + return QStringLiteral("Connecting to %1...").arg(peerCall()); + if (!m_myCall.isValid()) + return QStringLiteral("Set MYCALL to begin"); + return QStringLiteral("Disconnected — %1 ready").arg(myCall()); +} + +namespace { +QString humanBytes(quint64 n) +{ + if (n < 1024) + return QStringLiteral("%1").arg(n); + if (n < 1024 * 1024) + return QStringLiteral("%1k").arg(n / 1024.0, 0, 'f', 1); + return QStringLiteral("%1M").arg(n / (1024.0 * 1024.0), 0, 'f', 1); +} +} // namespace + +QString TncTerminal::linkStats() const +{ + QString stats = QStringLiteral("TX %1 RX %2") + .arg(humanBytes(m_txBytes), humanBytes(m_rxBytes)); + if (m_link->state() != Ax25Connection::State::Disconnected) { + const auto& s = m_link->stats(); + stats = QStringLiteral("V(S)=%1 V(R)=%2 retry %3/%4 drop %5 resent %6 %7") + .arg(m_link->sendSeq()) + .arg(m_link->recvSeq()) + .arg(m_link->retries()) + .arg(m_link->maxRetries()) + .arg(s.iDropped) + .arg(s.iResent) + .arg(stats); + } + return stats; +} + +bool TncTerminal::isLogging() const +{ + return m_logFile != nullptr; +} + +// --------------------------------------------------------------------------- +// Input +// --------------------------------------------------------------------------- + +void TncTerminal::onAirFrame(const QByteArray& rawNoFcs) +{ + auto frame = Frame::decode(rawNoFcs); + if (frame) + m_link->onFrameReceived(*frame); +} + +void TncTerminal::submitLine(const QString& line) +{ + if (m_mode == Mode::Converse) { + // A lone escape character returns to the command prompt without dropping + // the link, exactly like a hardware TNC's command character. + if (line.size() == 1 && line.at(0) == m_escape) { + enterCommandMode(); + return; + } + sendToPeer(line); + return; + } + handleCommand(line); +} + +void TncTerminal::enterCommandMode() +{ + if (m_mode == Mode::Command) + return; + setMode(Mode::Command); + emitLine(QStringLiteral("*** Command mode (link still up — BYE to disconnect)")); +} + +void TncTerminal::disconnectLink() +{ + if (m_link->state() == Ax25Connection::State::Disconnected) { + emitLine(QStringLiteral("*** Not connected")); + return; + } + emitLine(QStringLiteral("*** Disconnecting from %1...").arg(peerCall())); + m_link->disconnect(); +} + +void TncTerminal::reset() +{ + m_connecting = false; + m_failureReported = false; + m_link->reset(); + setMode(Mode::Command); +} + +// --------------------------------------------------------------------------- +// Command mode +// --------------------------------------------------------------------------- + +void TncTerminal::handleCommand(const QString& rawLine) +{ + const QString line = rawLine.trimmed(); + if (line.isEmpty()) + return; + + const int sp = line.indexOf(QLatin1Char(' ')); + const QString verb = (sp < 0 ? line : line.left(sp)).toUpper(); + const QString args = (sp < 0 ? QString() : line.mid(sp + 1).trimmed()); + + if (verb == QLatin1String("C") || verb == QLatin1String("CONNECT")) { + if (m_link->state() != Ax25Connection::State::Disconnected) { + emitLine(QStringLiteral("*** Already %1 — BYE first") + .arg(m_link->isConnected() ? QStringLiteral("connected") + : QStringLiteral("connecting"))); + return; + } + if (!m_myCall.isValid()) { + emitLine(QStringLiteral("*** Set MYCALL first (MYCALL )")); + return; + } + // Syntax: CONNECT [VIA [,...]] (or space-separated) + QString destText = args; + QVector via; + const QStringList tokens = args.split(QRegularExpression(QStringLiteral("[\\s,]+")), + Qt::SkipEmptyParts); + if (!tokens.isEmpty()) { + destText = tokens.first(); + int i = 1; + // An explicit VIA/V keyword is optional; any extra calls are digis. + if (i < tokens.size() + && (tokens.at(i).compare(QLatin1String("VIA"), Qt::CaseInsensitive) == 0 + || tokens.at(i).compare(QLatin1String("V"), Qt::CaseInsensitive) == 0)) { + ++i; + } + for (; i < tokens.size() && via.size() < 8; ++i) { + auto hop = Address::parse(tokens.at(i)); + if (!hop) { + emitLine(QStringLiteral("*** Invalid digipeater: %1").arg(tokens.at(i))); + return; + } + via.append(*hop); + } + } + auto peer = Address::parse(destText); + if (!peer) { + emitLine(QStringLiteral("*** Usage: CONNECT [VIA [,...]]")); + return; + } + m_connecting = true; + m_failureReported = false; + QString viaText; + if (!via.isEmpty()) { + QStringList vs; + for (const auto& h : via) + vs << h.toString(); + viaText = QStringLiteral(" via %1").arg(vs.join(QLatin1Char(','))); + } + emitLine(QStringLiteral("*** Connecting to %1%2 as %3...") + .arg(peer->toString(), viaText, myCall())); + emit connectRequested(peer->toString()); // GUI ensures the modem RX tap is on + emit stateChanged(); + m_link->connectTo(*peer, via); + return; + } + + if (verb == QLatin1String("D") || verb == QLatin1String("DISC") + || verb == QLatin1String("DISCONNECT") || verb == QLatin1String("B") + || verb == QLatin1String("BYE")) { + disconnectLink(); + return; + } + + if (verb == QLatin1String("CONV") || verb == QLatin1String("CONVERSE") + || verb == QLatin1String("K")) { + if (!m_link->isConnected()) { + emitLine(QStringLiteral("*** Not connected — CONNECT first")); + return; + } + setMode(Mode::Converse); + emitLine(QStringLiteral("*** Converse mode — type to send; '%1' returns to command mode") + .arg(m_escape)); + return; + } + + if (verb == QLatin1String("S") || verb == QLatin1String("STATUS")) { + cmdStatus(); + return; + } + + if (verb == QLatin1String("MYCALL") || verb == QLatin1String("MY")) { + if (args.isEmpty()) { + emitLine(QStringLiteral("MYCALL %1") + .arg(m_myCall.isValid() ? myCall() : QStringLiteral("(unset)"))); + return; + } + auto call = Address::parse(args); + if (!call) { + emitLine(QStringLiteral("*** Invalid callsign: %1").arg(args)); + return; + } + setMyCall(args); + emitLine(QStringLiteral("*** MYCALL set to %1").arg(myCall())); + return; + } + + if (verb == QLatin1String("MHEARD") || verb == QLatin1String("MH") + || verb == QLatin1String("JHEARD") || verb == QLatin1String("J")) { + cmdMheard(); + return; + } + + if (verb == QLatin1String("LOG")) { + if (args.compare(QLatin1String("OFF"), Qt::CaseInsensitive) == 0) + setLogging(false); + else if (args.compare(QLatin1String("ON"), Qt::CaseInsensitive) == 0) + setLogging(true); + else + setLogging(!isLogging()); // bare LOG toggles + return; + } + + if (verb == QLatin1String("ESCAPE") || verb == QLatin1String("ESC")) { + if (args.size() == 1) { + setEscapeChar(args.at(0)); + emitLine(QStringLiteral("*** Escape character set to '%1'").arg(m_escape)); + } else { + emitLine(QStringLiteral("*** Escape character is '%1'").arg(m_escape)); + } + return; + } + + if (verb == QLatin1String("HELP") || verb == QLatin1String("H") + || verb == QLatin1String("?")) { + emitLine(QStringLiteral("Commands:")); + emitLine(QStringLiteral(" CONNECT [VIA ,...] (C) connect to a station / BBS")); + emitLine(QStringLiteral(" CONV (K) return to converse mode (when connected)")); + emitLine(QStringLiteral(" STATUS (S) show connection stats")); + emitLine(QStringLiteral(" BYE (B,D,DISCONNECT) hang up the link")); + emitLine(QStringLiteral(" MHEARD (MH) stations heard on frequency")); + emitLine(QStringLiteral(" MYCALL set/show your callsign")); + emitLine(QStringLiteral(" LOG [ON|OFF] toggle/set session transcript logging")); + emitLine(QStringLiteral(" ESCAPE set/show the command-mode escape char")); + emitLine(QStringLiteral(" HELP (?) this list")); + emitLine(QStringLiteral("Once connected, type to send; '%1' alone returns here.") + .arg(m_escape)); + return; + } + + emitLine(QStringLiteral("*** ? (unknown command — type HELP)")); +} + +// --------------------------------------------------------------------------- +// Converse mode +// --------------------------------------------------------------------------- + +void TncTerminal::sendToPeer(const QString& line) +{ + if (!m_link->isConnected()) { + emitLine(QStringLiteral("*** Not connected")); + return; + } + // AX.25 BBSes are CR-terminated. Echo locally so the operator sees what they + // sent (the link is half-duplex; the peer does not echo). + QByteArray payload = line.toLatin1(); + payload.append('\r'); + m_txBytes += static_cast(payload.size()); + m_link->sendData(payload); + emitLine(line); + emit stateChanged(); // refresh TX byte counter +} + +// --------------------------------------------------------------------------- +// Link callbacks +// --------------------------------------------------------------------------- + +void TncTerminal::onLinkConnected(const Address& peer) +{ + m_connecting = false; + m_failureReported = false; + m_txBytes = 0; + m_rxBytes = 0; + emitLine(QStringLiteral("*** CONNECTED to %1").arg(peer.toString())); + setMode(Mode::Converse); +} + +void TncTerminal::onLinkDisconnected(const Address& peer, bool byPeer) +{ + m_connecting = false; + if (!m_failureReported) { + emitLine(QStringLiteral("*** DISCONNECTED from %1%2") + .arg(peer.toString(), + byPeer ? QStringLiteral(" (by peer)") : QString())); + } + m_failureReported = false; + setMode(Mode::Command); +} + +void TncTerminal::onLinkConnectFailed(const Address& peer, const QString& reason) +{ + m_connecting = false; + m_failureReported = true; // suppress the redundant DISCONNECTED line that follows + emitLine(QStringLiteral("*** CONNECT to %1 FAILED: %2").arg(peer.toString(), reason)); +} + +void TncTerminal::onLinkData(const QByteArray& data) +{ + m_rxBytes += static_cast(data.size()); + // Normalise the peer's line endings (bare CR or CRLF) to '\n' for the pane. + QString text = QString::fromLatin1(data); + text.replace(QLatin1String("\r\n"), QLatin1String("\n")); + text.replace(QLatin1Char('\r'), QLatin1Char('\n')); + if (!text.isEmpty()) + emitOutput(text); + emit stateChanged(); // refresh RX byte counter +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +void TncTerminal::setMode(Mode mode) +{ + if (m_mode == mode) { + emit stateChanged(); + return; + } + m_mode = mode; + emit stateChanged(); +} + +void TncTerminal::onLinkActivity(const QString& msg) +{ + emit activity(msg); // always available to external consumers (e.g. a debug log) + if (m_verbose) + emitLine(QStringLiteral("\xc2\xb7 %1").arg(msg)); // '·' prefix, inline in transcript + // Every protocol event (RX/TX frame, retransmit, drop) may move the counters, + // so refresh the GUI's live status readout. Packet rates are low, so this is + // cheap. + emit stateChanged(); +} + +void TncTerminal::emitLine(const QString& text) +{ + // A *** session line (or a verbose debug line) must start on its own line so + // it never overprints a BBS prompt that arrived without a trailing newline. + if (!m_atLineStart) + emitOutput(QStringLiteral("\n")); + emitOutput(text + QLatin1Char('\n')); +} + +void TncTerminal::emitOutput(const QString& text) +{ + if (text.isEmpty()) + return; + if (m_logFile && m_logFile->isOpen()) { + m_logFile->write(text.toUtf8()); + m_logFile->flush(); + } + emit output(text); + m_atLineStart = text.endsWith(QLatin1Char('\n')); +} + +void TncTerminal::cmdStatus() +{ + const auto& s = m_link->stats(); + const QString state = m_link->isConnected() + ? QStringLiteral("Connected (%1)") + .arg(m_mode == Mode::Converse ? QStringLiteral("converse") : QStringLiteral("command")) + : (m_connecting ? QStringLiteral("Connecting") + : QStringLiteral("Disconnected")); + + emitLine(QStringLiteral("--- STATUS -------------------------------")); + emitLine(QStringLiteral(" MyCall : %1").arg(m_myCall.isValid() ? myCall() + : QStringLiteral("(unset)"))); + emitLine(QStringLiteral(" Peer : %1").arg(peerCall().isEmpty() ? QStringLiteral("-") + : peerCall())); + emitLine(QStringLiteral(" State : %1").arg(state)); + emitLine(QStringLiteral(" Seq : V(S)=%1 V(R)=%2 unacked %3 send queue %4 B") + .arg(m_link->sendSeq()).arg(m_link->recvSeq()) + .arg(m_link->unacked()).arg(m_link->sendQueueBytes())); + emitLine(QStringLiteral(" Data : TX %1 B RX %2 B").arg(m_txBytes).arg(m_rxBytes)); + emitLine(QStringLiteral(" Packets : I sent %1 resent %2 rcvd %3 dropped %4") + .arg(s.iSent).arg(s.iResent).arg(s.iRcvd).arg(s.iDropped)); + emitLine(QStringLiteral(" Acks : RR rcvd %1 REJ sent %2 REJ rcvd %3 RNR %4") + .arg(s.rrRcvd).arg(s.rejSent).arg(s.rejRcvd).arg(s.rnrRcvd)); + emitLine(QStringLiteral(" Retries : %1/%2 T1 timeouts %3 deferred-acks %4") + .arg(m_link->retries()).arg(m_link->maxRetries()).arg(s.t1Timeouts).arg(s.t2Acks)); + emitLine(QStringLiteral(" Errors : FRMR %1 bad-N(R) %2").arg(s.frmrRcvd).arg(s.invalidNr)); + emitLine(QStringLiteral("------------------------------------------")); +} + +void TncTerminal::cmdMheard() +{ + if (!m_heard || m_heard->isEmpty()) { + emitLine(QStringLiteral("*** Nothing heard yet.")); + return; + } + const auto stations = m_heard->stations(25); + emitLine(QStringLiteral("CALLSIGN LAST HEARD LAST BEACON")); + for (const auto& s : stations) { + QString beacon = s.lastBeacon; + if (beacon.size() > 40) + beacon = beacon.left(39) + QChar(0x2026); // ellipsis + emitLine(QStringLiteral("%1 %2 %3") + .arg(s.station.toString().leftJustified(9), + s.utc.toString(QStringLiteral("MM/dd HH:mm")), + beacon)); + } + emitLine(QStringLiteral("(%1 station(s) heard, UTC)").arg(m_heard->size())); +} + +void TncTerminal::setLogging(bool on) +{ + if (on == isLogging()) + return; + if (!on) { + if (m_logFile) { + emitLine(QStringLiteral("*** Session logging stopped (%1)") + .arg(m_logFile->fileName())); + m_logFile->close(); + delete m_logFile; + m_logFile = nullptr; + } + return; + } + if (m_logDir.isEmpty()) { + emitLine(QStringLiteral("*** Logging unavailable (no log directory configured)")); + return; + } + QDir().mkpath(m_logDir); + const QString stamp = + QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd-HHmmss")); + const QString path = + QDir(m_logDir).filePath(QStringLiteral("terminal-%1.log").arg(stamp)); + auto* file = new QFile(path); + if (!file->open(QIODevice::WriteOnly | QIODevice::Append | QIODevice::Text)) { + emitLine(QStringLiteral("*** Could not open log file: %1").arg(path)); + delete file; + return; + } + m_logFile = file; + emitLine(QStringLiteral("*** Session logging to %1").arg(path)); +} + +} // namespace AetherSDR diff --git a/src/core/tnc/TncTerminal.h b/src/core/tnc/TncTerminal.h new file mode 100644 index 00000000..c8c32f42 --- /dev/null +++ b/src/core/tnc/TncTerminal.h @@ -0,0 +1,169 @@ +#pragma once + +#include "core/tnc/Ax25.h" + +#include +#include +#include + +class QFile; + +namespace AetherSDR { + +class Ax25Connection; +class HeardList; + +// A simple, reliable connected-mode AX.25 *client* terminal — the calling-side +// counterpart of the PmsMailbox (which is the answering side). It drives an +// Ax25Connection in the outbound role so an operator can CONNECT to a 1200-baud +// VHF packet BBS, converse with it, and disconnect, with the same retransmit / +// error-correction / T1 timer machinery the mailbox uses. +// +// Two input modes, exactly like a classic hardware TNC: +// * Command mode — typed lines are interpreted as terminal commands +// (CONNECT, BYE, MYCALL, HELP, ...). This is the prompt. +// * Converse mode — entered once a link is up; typed lines are sent to the +// peer as I-frame data (CR-terminated, with local echo). +// An escape character (default '~' on a line by itself) drops from converse +// back to command mode WITHOUT disconnecting, so the operator can issue another +// command (e.g. BYE, or CONNECT to a different station). +// +// It is RF-agnostic and Qt::Network-free, exactly like PmsMailbox: feed it every +// decoded frame via onAirFrame() and key whatever it emits on transmitFrame(). +// Deliberately no ANSI/VT100 handling — line in, line out, kept simple. +class TncTerminal : public QObject { + Q_OBJECT + +public: + enum class Mode { Command, Converse }; + + explicit TncTerminal(QObject* parent = nullptr); + ~TncTerminal() override; + + // Our station callsign-SSID (the address outbound connects originate from). + // Invalid/empty text leaves the terminal unable to connect until set. + void setMyCall(const QString& callWithSsid); + QString myCall() const; + bool hasMyCall() const; + + // The single character that, alone on a line in converse mode, returns to the + // command prompt. Default '~'. A null QChar is ignored. + void setEscapeChar(QChar c); + QChar escapeChar() const { return m_escape; } + + // Data-link tunables forwarded to the Ax25Connection (retries / timers / MTU). + void setRetryTimeoutMs(int t1); + void setMaxRetries(int n2); + void setPaclen(int bytes); + + // The shared station-heard log (for the MHEARD command and quick-connect). + // Non-owning; the GUI feeds it from the RX path. Safe to leave unset. + void setHeardList(HeardList* heard) { m_heard = heard; } + + // Directory for session transcript logs. The LOG command writes a + // timestamped file here. Empty (default) disables logging. + void setLogDirectory(const QString& dir) { m_logDir = dir; } + bool isLogging() const; + // Start/stop transcript logging directly (for a GUI toggle, vs. the LOG + // command). Safe to call in any mode — never routed to the peer. + void setLogging(bool on); + // Print the heard list to the transcript (for a GUI button), independent of + // command/converse mode. + void printMheard() { cmdMheard(); } + + // When on, low-level protocol activity (TX/RX frame detail) is echoed inline + // into the transcript, prefixed with '·'. Off by default — the transcript + // shows only the BBS data and the *** session lines. + void setVerbose(bool on) { m_verbose = on; } + bool isVerbose() const { return m_verbose; } + + Mode mode() const { return m_mode; } + bool isConnected() const; + bool isConnecting() const { return m_connecting; } + // The peer we are connected to / dialing, or empty when idle. + QString peerCall() const; + // One-line human-readable status for the GUI (e.g. "Connected to KX9X-1"). + QString statusSummary() const; + // Live data-link instrumentation: "V(S)=2 V(R)=3 retry 0/8 TX 1.2k RX 480". + QString linkStats() const; + quint64 txBytes() const { return m_txBytes; } + quint64 rxBytes() const { return m_rxBytes; } + +public slots: + // Feed every decoded AX.25 frame (address..info, no FCS) here. + void onAirFrame(const QByteArray& rawNoFcs); + + // A full line of operator input (the terminal submits on Enter). In command + // mode it is parsed as a command; in converse mode it is sent to the peer. + void submitLine(const QString& line); + + // Return to the command prompt without disconnecting (the escape action). + void enterCommandMode(); + + // Tell the terminal the transcript view was cleared by the GUI, so the next + // *** line doesn't think it must insert a leading newline. + void noteScreenCleared() { m_atLineStart = true; } + + // Gracefully disconnect the current link (DISC). No-op if idle. + void disconnectLink(); + + // Drop everything immediately (e.g. on shutdown / window close). + void reset(); + +signals: + // A raw AX.25 frame (address..info, no FCS) to key on the air. + void transmitFrame(const QByteArray& rawNoFcs); + + // Text to append to the terminal transcript pane. Already newline-normalised + // (peer CR / CRLF collapsed to '\n'); never carries a trailing prompt. + void output(const QString& text); + + // Human-readable protocol activity for the shared AetherModem system log. + void activity(const QString& message); + + // Mode and/or connection state changed — the GUI should refresh its labels. + void stateChanged(); + + // Emitted just before an outbound connect is dialed (from the CONNECT command + // or the GUI Connect button). The GUI uses this to make sure the modem RX tap + // is running before the link comes up, so the BBS's frames are actually heard. + void connectRequested(const QString& peer); + +private: + void onLinkConnected(const ax25::Address& peer); + void onLinkDisconnected(const ax25::Address& peer, bool byPeer); + void onLinkConnectFailed(const ax25::Address& peer, const QString& reason); + void onLinkData(const QByteArray& data); + void onLinkActivity(const QString& msg); + + void handleCommand(const QString& line); + void sendToPeer(const QString& line); + void setMode(Mode mode); + void emitLine(const QString& text); // output() one CR/LF-free line + '\n' + void emitOutput(const QString& text); // emit + tee to the session log + void cmdMheard(); + void cmdStatus(); + + Ax25Connection* m_link{nullptr}; + HeardList* m_heard{nullptr}; // non-owning; shared station-heard log + ax25::Address m_myCall; + Mode m_mode{Mode::Command}; + QChar m_escape{QLatin1Char('~')}; + bool m_connecting{false}; + bool m_verbose{false}; + // Tracks whether the transcript currently sits at the start of a line, so a + // *** session line (or debug line) always begins fresh and never overprints + // a BBS prompt that didn't end in a newline. + bool m_atLineStart{true}; + // Set when connectFailed() already explained why the link went down, so the + // disconnected() that follows doesn't print a redundant "DISCONNECTED" line. + bool m_failureReported{false}; + + quint64 m_txBytes{0}; + quint64 m_rxBytes{0}; + + QString m_logDir; + QFile* m_logFile{nullptr}; +}; + +} // namespace AetherSDR diff --git a/src/gui/Ax25HfPacketDecodeDialog.cpp b/src/gui/Ax25HfPacketDecodeDialog.cpp index 926d6489..0484d274 100644 --- a/src/gui/Ax25HfPacketDecodeDialog.cpp +++ b/src/gui/Ax25HfPacketDecodeDialog.cpp @@ -5,13 +5,17 @@ #include "core/DaxTxPolicy.h" #include "core/LogManager.h" #include "core/ThemeManager.h" +#include "core/tnc/Ax25.h" #include "core/tnc/Ax25FrameFormatter.h" +#include "core/tnc/HeardList.h" #include "core/tnc/KissTncServer.h" +#include "core/tnc/TncTerminal.h" #include "core/pms/PmsMailbox.h" #include "models/RadioModel.h" #include "models/SliceModel.h" #include "models/TransmitModel.h" +#include #include #include #include @@ -19,10 +23,13 @@ #include #include #include +#include #include #include +#include #include #include +#include #include #include #include @@ -34,6 +41,7 @@ #include #include #include +#include #include #include #include @@ -76,10 +84,26 @@ constexpr auto kPmsWelcomeSetting = "AetherModemPmsWelcome"; constexpr auto kPmsBeaconEnabledSetting = "AetherModemPmsBeaconEnabled"; constexpr auto kPmsBeaconTextSetting = "AetherModemPmsBeaconText"; +// TNC Terminal settings keys (persisted in AppSettings). +constexpr auto kTerminalMyCallSetting = "AetherModemTerminalMyCall"; +constexpr auto kTerminalLastCallSetting = "AetherModemTerminalLastCall"; +constexpr auto kTerminalTxTailSetting = "AetherModemTerminalTxTailMs"; +constexpr auto kTerminalRetrySecsSetting = "AetherModemTerminalRetrySecs"; +constexpr auto kTerminalMaxTriesSetting = "AetherModemTerminalMaxTries"; +constexpr auto kTerminalPaclenSetting = "AetherModemTerminalPaclen"; +constexpr auto kTerminalLogSetting = "AetherModemTerminalLogEnabled"; +constexpr int kTerminalDefaultRetrySecs = 6; +constexpr int kTerminalDefaultMaxTries = 8; +constexpr int kTerminalDefaultPaclen = 128; + constexpr int kAudioCaptureSeconds = 180; constexpr int kTxDaxSettleMs = 150; constexpr int kTxLeadMs = 200; -constexpr int kTxTailMs = 250; +// Default TX tail: how long PTT stays up after the audio is queued, to flush the +// DAX/radio buffer before unkey. On a half-duplex link this is also dead air the +// peer can't talk over, so it's operator-tunable (Terminal tab, "TX Tail"); the +// runtime value lives in m_txTailMs. Too short clips the end of our frame. +constexpr int kTxTailDefaultMs = 150; constexpr int kTxChunkMs = 20; // TX jitter buffer: how far ahead of real time we keep the radio's TX FIFO. // The pacer runs on the GUI thread, which jitters under RX-decode / diagnostics @@ -601,7 +625,17 @@ Ax25HfPacketDecodeDialog::Ax25HfPacketDecodeDialog(AudioEngine* audio, m_shim = new AetherAx25LibmodemShim(this); m_kissServer = new KissTncServer(this); + m_heard = new HeardList(this); + m_terminal = new TncTerminal(this); m_pms = new PmsMailbox(this); + + // The TNC store lives next to the app settings (heard log + session logs). + const QString tncDir = + QFileInfo(AppSettings::instance().filePath()).absolutePath() + + QStringLiteral("/tnc"); + m_heard->setPersistencePath(tncDir + QStringLiteral("/heard.json")); + m_terminal->setHeardList(m_heard); + m_terminal->setLogDirectory(tncDir + QStringLiteral("/logs")); m_heartbeatTimer = new QTimer(this); m_heartbeatTimer->setInterval(1000); m_txPaceTimer = new QTimer(this); @@ -617,23 +651,29 @@ Ax25HfPacketDecodeDialog::Ax25HfPacketDecodeDialog(AudioEngine* audio, tabs->setSpacing(0); m_ax25Tab = tabButton(QStringLiteral("AX.25"), true, tabsFrame); m_kissTab = tabButton(QStringLiteral("KISS TNC"), false, tabsFrame); + m_terminalTab = tabButton(QStringLiteral("Terminal"), false, tabsFrame); m_mailboxTab = tabButton(QStringLiteral("Mailbox"), false, tabsFrame); m_ax25Tab->setEnabled(true); m_kissTab->setEnabled(true); + m_terminalTab->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); + tabGroup->addButton(m_terminalTab, 2); + tabGroup->addButton(m_mailboxTab, 3); tabs->addWidget(m_ax25Tab, 1); tabs->addWidget(m_kissTab, 1); + tabs->addWidget(m_terminalTab, 1); tabs->addWidget(m_mailboxTab, 1); root->addWidget(tabsFrame); m_tabStack = new QStackedWidget(bodyWidget()); root->addWidget(m_tabStack); connect(tabGroup, &QButtonGroup::idClicked, m_tabStack, &QStackedWidget::setCurrentIndex); + connect(m_tabStack, &QStackedWidget::currentChanged, + this, &Ax25HfPacketDecodeDialog::updateTabChrome); // AX.25 page: modem config + transmit. The log and status row below the // stack are shared by both tabs. @@ -699,10 +739,13 @@ Ax25HfPacketDecodeDialog::Ax25HfPacketDecodeDialog(AudioEngine* audio, // KISS TNC page (built lazily into the same stack). m_tabStack->addWidget(buildKissTncPage()); + // TNC Terminal page (connected-mode AX.25 client). + m_tabStack->addWidget(buildTerminalPage()); // Mailbox (PMS) page. m_tabStack->addWidget(buildMailboxPage()); auto* logFrame = panel(QStringLiteral("LogFrame"), bodyWidget()); + m_logFrame = logFrame; auto* logLayout = new QVBoxLayout(logFrame); logLayout->setContentsMargins(12, 10, 12, 10); logLayout->setSpacing(0); @@ -716,6 +759,7 @@ Ax25HfPacketDecodeDialog::Ax25HfPacketDecodeDialog(AudioEngine* audio, root->addWidget(logFrame, 1); auto* actionRowFrame = new QWidget(bodyWidget()); + m_actionRowFrame = actionRowFrame; auto* actionRow = new QHBoxLayout(actionRowFrame); actionRow->setContentsMargins(0, 0, 0, 0); actionRow->setSpacing(8); @@ -847,8 +891,17 @@ Ax25HfPacketDecodeDialog::Ax25HfPacketDecodeDialog(AudioEngine* audio, // 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()) + if (frame.ax25FrameNoFcs.isEmpty()) + return; + // Record into the shared heard log once (drives MHEARD + quick-connect). + if (m_heard) { + if (auto decoded = ax25::Frame::decode(frame.ax25FrameNoFcs)) + m_heard->record(*decoded); + } + if (m_pms) m_pms->onAirFrame(frame.ax25FrameNoFcs); + if (m_terminal) + m_terminal->onAirFrame(frame.ax25FrameNoFcs); }); connect(m_shim, &AetherAx25LibmodemShim::diagnosticsUpdated, this, &Ax25HfPacketDecodeDialog::updateDiagnostics); @@ -933,6 +986,40 @@ Ax25HfPacketDecodeDialog::Ax25HfPacketDecodeDialog(AudioEngine* audio, applyPmsConfigFromUi(true); }); + // TNC Terminal wiring. + connect(m_terminal, &TncTerminal::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_terminal, &TncTerminal::output, this, [this](const QString& text) { + if (!m_terminalView) + return; + m_terminalView->moveCursor(QTextCursor::End); + m_terminalView->insertPlainText(text); + m_terminalView->moveCursor(QTextCursor::End); + if (auto* bar = m_terminalView->verticalScrollBar()) + bar->setValue(bar->maximum()); + }); + // Terminal protocol activity stays out of the shared decode log box (and out + // of the transcript unless verbose), but it is always written to the support + // log file so connect/RR/REJ/retransmit traces are available for debugging. + connect(m_terminal, &TncTerminal::activity, this, [](const QString& line) { + qCDebug(lcAx25).noquote() << line; + }); + connect(m_terminal, &TncTerminal::stateChanged, this, [this] { refreshTerminalStatus(); }); + connect(m_heard, &HeardList::changed, this, [this] { refreshTerminalHeardCombo(); }); + // Any outbound connect needs the modem RX tap running, or the BBS's frames + // are never heard. Turn it on automatically before the link is dialed. + connect(m_terminal, &TncTerminal::connectRequested, this, [this](const QString& peer) { + if (m_enableDecode && !m_enableDecode->isChecked()) { + appendSystemLine( + QStringLiteral("Enabling the modem for the terminal connection to %1.").arg(peer)); + m_enableDecode->setChecked(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.")); @@ -953,6 +1040,29 @@ Ax25HfPacketDecodeDialog::Ax25HfPacketDecodeDialog(AudioEngine* audio, if (pmsOn && m_pmsEnable) m_pmsEnable->setChecked(true); // fires setPmsEnabled() via the toggled connection refreshPmsStatus(); + + // Restore TNC Terminal state. + applyTerminalConfigFromUi(false); + const bool termLogOn = AppSettings::instance() + .value(kTerminalLogSetting, QStringLiteral("False")).toString() + == QStringLiteral("True"); + if (termLogOn && m_terminalLogEnable) + m_terminalLogEnable->setChecked(true); // fires the toggled handler + refreshTerminalStatus(); + refreshTerminalHeardCombo(); + + // No control button may be the dialog's default button — otherwise pressing + // Return in a text field would trigger it (Connect, Transmit, ...). Combined + // with the title-bar fix, this guarantees Enter never does anything unwanted + // in any AetherModem field; each field's own returnPressed still works. + for (QPushButton* button : bodyWidget()->findChildren()) { + button->setAutoDefault(false); + button->setDefault(false); + } + + // Apply the per-tab chrome (hide the shared log on the Terminal tab) now that + // the layout is fully built. + updateTabChrome(m_tabStack->currentIndex()); } Ax25HfPacketDecodeDialog::~Ax25HfPacketDecodeDialog() @@ -1246,7 +1356,7 @@ void Ax25HfPacketDecodeDialog::beginTransmitWhenReady() .arg(m_txChunkCount) .arg(kTxDaxSettleMs) .arg(kTxLeadMs) - .arg(kTxTailMs); + .arg(m_txTailMs); refreshTransmitControls(); QTimer::singleShot(kTxDaxSettleMs, this, [this] { @@ -1340,8 +1450,8 @@ void Ax25HfPacketDecodeDialog::paceTransmitAudio() .arg(m_txPaceMaxGapMs) .arg(m_txPaceLateChunks)); appendSystemLine(QStringLiteral("AX.25 TX audio queued; waiting %1 ms before unkey.") - .arg(kTxTailMs)); - QTimer::singleShot(kTxTailMs, this, [this] { + .arg(m_txTailMs)); + QTimer::singleShot(m_txTailMs, this, [this] { finishTransmit(false, QStringLiteral("AX.25 TX complete")); }); return; @@ -1620,6 +1730,8 @@ void Ax25HfPacketDecodeDialog::setDiagnosticsDebugEnabled(bool enabled, bool per m_diagnosticsDebugEnabled = enabled; if (m_shim) m_shim->setDiagnosticsLoggingEnabled(enabled); + if (m_terminal) + m_terminal->setVerbose(enabled); // echo protocol detail inline in the terminal if (m_packetActivity) m_packetActivity->setDebugEnabled(enabled); if (m_packetActivityTitle) { @@ -2008,6 +2120,371 @@ void Ax25HfPacketDecodeDialog::refreshTncStatus() } } +// --------------------------------------------------------------------------- +// TNC Terminal tab (connected-mode AX.25 client) +// --------------------------------------------------------------------------- + +QWidget* Ax25HfPacketDecodeDialog::buildTerminalPage() +{ + auto* page = new QWidget(m_tabStack); + auto* layout = new QVBoxLayout(page); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(10); + + // --- Connect controls ----------------------------------------------------- + auto* controlsFrame = panel(QStringLiteral("ControlsFrame"), page); + auto* controls = new QHBoxLayout(controlsFrame); + controls->setContentsMargins(16, 14, 16, 14); + controls->setSpacing(20); + + auto* callCell = panel(QStringLiteral("ControlCell"), controlsFrame); + auto* callLayout = new QVBoxLayout(callCell); + callLayout->setContentsMargins(0, 0, 20, 0); + callLayout->setSpacing(12); + callLayout->addWidget(sectionLabel(QStringLiteral("MY CALLSIGN"), callCell)); + m_terminalMyCall = new QLineEdit(callCell); + m_terminalMyCall->setPlaceholderText(QStringLiteral("e.g. N0CALL-7")); + m_terminalMyCall->setToolTip(QStringLiteral( + "Your station callsign-SSID. Outbound connects originate from this address.")); + m_terminalMyCall->setMaximumWidth(200); + callLayout->addWidget(m_terminalMyCall); + controls->addWidget(callCell); + + auto* targetCell = panel(QStringLiteral("ControlCell"), controlsFrame); + auto* targetLayout = new QVBoxLayout(targetCell); + targetLayout->setContentsMargins(0, 0, 20, 0); + targetLayout->setSpacing(12); + targetLayout->addWidget(sectionLabel(QStringLiteral("CONNECT TO"), targetCell)); + auto* targetRow = new QHBoxLayout; + targetRow->setSpacing(10); + m_terminalTarget = new QLineEdit(targetCell); + m_terminalTarget->setPlaceholderText(QStringLiteral("e.g. KX9X-1")); + m_terminalTarget->setMaximumWidth(180); + targetRow->addWidget(m_terminalTarget); + m_terminalConnectButton = new QPushButton(QStringLiteral("Connect"), targetCell); + m_terminalConnectButton->setMinimumHeight(36); + targetRow->addWidget(m_terminalConnectButton); + m_terminalCmdButton = new QPushButton(QStringLiteral("Cmd Mode"), targetCell); + m_terminalCmdButton->setMinimumHeight(36); + m_terminalCmdButton->setToolTip(QStringLiteral( + "Return to the command prompt without disconnecting (the escape action).")); + targetRow->addWidget(m_terminalCmdButton); + targetLayout->addLayout(targetRow); + + // Quick-connect: a dropdown of recently-heard stations fills the target box. + auto* heardRow = new QHBoxLayout; + heardRow->setSpacing(10); + m_terminalHeardCombo = new QComboBox(targetCell); + m_terminalHeardCombo->setMinimumWidth(220); + m_terminalHeardCombo->setToolTip(QStringLiteral( + "Stations heard on frequency. Pick one to fill the target callsign.")); + heardRow->addWidget(m_terminalHeardCombo); + m_terminalMheardButton = new QPushButton(QStringLiteral("MHeard"), targetCell); + m_terminalMheardButton->setMinimumHeight(36); + m_terminalMheardButton->setToolTip(QStringLiteral( + "Print the full heard list (callsign, last heard, last beacon) to the terminal.")); + heardRow->addWidget(m_terminalMheardButton); + heardRow->addStretch(1); + targetLayout->addLayout(heardRow); + controls->addWidget(targetCell, 1); + + // Link parameters (forwarded to the data link) + session logging. + auto* paramCell = panel(QStringLiteral("ControlCell"), controlsFrame); + auto* paramLayout = new QVBoxLayout(paramCell); + paramLayout->setContentsMargins(0, 0, 0, 0); + paramLayout->setSpacing(12); + paramLayout->addWidget(sectionLabel(QStringLiteral("LINK PARAMETERS"), paramCell)); + auto* paramRow = new QHBoxLayout; + paramRow->setSpacing(14); + auto addSpin = [&](const QString& label, int lo, int hi, int def, const QString& tip) { + auto* col = new QVBoxLayout; + col->setSpacing(4); + auto* cap = new QLabel(label, paramCell); + cap->setObjectName(QStringLiteral("StatusValue")); + col->addWidget(cap); + auto* spin = new QSpinBox(paramCell); + spin->setRange(lo, hi); + spin->setValue(def); + spin->setToolTip(tip); + col->addWidget(spin); + paramRow->addLayout(col); + return spin; + }; + m_terminalRetrySecs = addSpin(QStringLiteral("Retry s"), 1, 60, kTerminalDefaultRetrySecs, + QStringLiteral("T1 retransmit timeout in seconds.")); + m_terminalMaxTries = addSpin(QStringLiteral("Tries"), 1, 20, kTerminalDefaultMaxTries, + QStringLiteral("N2 — retransmit attempts before the link is declared dead.")); + m_terminalPaclen = addSpin(QStringLiteral("Paclen"), 16, 256, kTerminalDefaultPaclen, + QStringLiteral("Max bytes per I-frame.")); + m_terminalTxTail = addSpin(QStringLiteral("TX Tail ms"), 0, 500, kTxTailDefaultMs, + QStringLiteral("PTT tail (ms) held after the TX audio before unkey. Lower = we hear " + "the peer's next frame sooner on a half-duplex link; too low clips the " + "end of our transmission so the peer can't decode it.")); + paramLayout->addLayout(paramRow); + m_terminalLogEnable = new QCheckBox(QStringLiteral("Log session to file"), paramCell); + m_terminalLogEnable->setToolTip(QStringLiteral( + "Tee the transcript to a timestamped file under the TNC store.")); + paramLayout->addWidget(m_terminalLogEnable); + controls->addWidget(paramCell); + controls->addStretch(1); + layout->addWidget(controlsFrame); + + // --- Status --------------------------------------------------------------- + layout->addWidget(statusPanel(QStringLiteral("TERMINAL"), + &m_terminalStatusDot, &m_terminalStatusValue, page)); + + // --- Transcript ----------------------------------------------------------- + QFont mono(QStringLiteral("Menlo")); + mono.setStyleHint(QFont::Monospace); + mono.setPointSize(12); + + auto* viewFrame = panel(QStringLiteral("LogFrame"), page); + auto* viewLayout = new QVBoxLayout(viewFrame); + viewLayout->setContentsMargins(12, 10, 12, 10); + viewLayout->setSpacing(0); + m_terminalView = new QTextEdit(viewFrame); + m_terminalView->setReadOnly(true); + m_terminalView->document()->setMaximumBlockCount(5000); + m_terminalView->setLineWrapMode(QTextEdit::WidgetWidth); + m_terminalView->setFont(mono); + m_terminalView->setPlaceholderText(QStringLiteral( + "Set MY CALLSIGN, enter a target call, and press Connect. Type HELP for commands.")); + // Right-click menu: Clear the screen, and Command Mode (same as the '~' key). + m_terminalView->setContextMenuPolicy(Qt::CustomContextMenu); + connect(m_terminalView, &QTextEdit::customContextMenuRequested, this, + [this](const QPoint& pos) { + QMenu* menu = m_terminalView->createStandardContextMenu(); + menu->addSeparator(); + QAction* clear = menu->addAction(QStringLiteral("Clear")); + connect(clear, &QAction::triggered, this, [this] { + m_terminalView->clear(); + if (m_terminal) + m_terminal->noteScreenCleared(); + }); + QAction* cmd = menu->addAction(QStringLiteral("Command Mode")); + cmd->setEnabled(m_terminal && m_terminal->mode() == TncTerminal::Mode::Converse); + connect(cmd, &QAction::triggered, this, [this] { + if (m_terminal) + m_terminal->enterCommandMode(); + m_terminalInput->setFocus(); + }); + menu->exec(m_terminalView->viewport()->mapToGlobal(pos)); + menu->deleteLater(); + }); + viewLayout->addWidget(m_terminalView); + layout->addWidget(viewFrame, 1); + + // --- Input ---------------------------------------------------------------- + auto* inputFrame = panel(QStringLiteral("ControlsFrame"), page); + auto* inputRow = new QHBoxLayout(inputFrame); + inputRow->setContentsMargins(16, 12, 16, 12); + inputRow->setSpacing(12); + inputRow->addWidget(sectionLabel(QStringLiteral("INPUT"), inputFrame)); + m_terminalInput = new QLineEdit(inputFrame); + m_terminalInput->setFont(mono); + m_terminalInput->setPlaceholderText(QStringLiteral( + "Command mode — type CONNECT , HELP, ...")); + m_terminalInput->installEventFilter(this); // Up/Down command history + inputRow->addWidget(m_terminalInput, 1); + m_terminalSendButton = new QPushButton(QStringLiteral("Send"), inputFrame); + m_terminalSendButton->setMinimumHeight(36); + inputRow->addWidget(m_terminalSendButton); + layout->addWidget(inputFrame); + + // --- Wiring --------------------------------------------------------------- + connect(m_terminalInput, &QLineEdit::returnPressed, + this, &Ax25HfPacketDecodeDialog::submitTerminalInput); + connect(m_terminalSendButton, &QPushButton::clicked, + this, &Ax25HfPacketDecodeDialog::submitTerminalInput); + connect(m_terminalConnectButton, &QPushButton::clicked, this, [this] { + const QString target = m_terminalTarget->text().trimmed(); + if (target.isEmpty()) { + m_terminalInput->setFocus(); + return; + } + m_terminal->submitLine(QStringLiteral("CONNECT %1").arg(target)); + m_terminalInput->setFocus(); + }); + connect(m_terminalCmdButton, &QPushButton::clicked, this, [this] { + m_terminal->enterCommandMode(); + m_terminalInput->setFocus(); + }); + connect(m_terminalMyCall, &QLineEdit::editingFinished, this, [this] { + applyTerminalConfigFromUi(true); + }); + connect(m_terminalMheardButton, &QPushButton::clicked, this, [this] { + m_terminal->printMheard(); + m_terminalInput->setFocus(); + }); + connect(m_terminalHeardCombo, qOverload(&QComboBox::activated), this, [this](int) { + const QString call = m_terminalHeardCombo->currentData().toString(); + if (!call.isEmpty()) + m_terminalTarget->setText(call); + }); + for (QSpinBox* spin : {m_terminalRetrySecs, m_terminalMaxTries, m_terminalPaclen, + m_terminalTxTail}) { + connect(spin, qOverload(&QSpinBox::valueChanged), this, [this](int) { + applyTerminalConfigFromUi(true); + }); + } + connect(m_terminalLogEnable, &QCheckBox::toggled, this, [this](bool on) { + m_terminal->setLogging(on); + AppSettings::instance().setValue(kTerminalLogSetting, + on ? QStringLiteral("True") : QStringLiteral("False")); + AppSettings::instance().save(); + // Logging may fail to start (e.g. unwritable dir); reflect reality. + const QSignalBlocker block(m_terminalLogEnable); + m_terminalLogEnable->setChecked(m_terminal->isLogging()); + }); + + // Restore persisted config. + m_terminalMyCall->setText( + AppSettings::instance().value(kTerminalMyCallSetting, QString()).toString()); + m_lastDialedCall = + AppSettings::instance().value(kTerminalLastCallSetting, QString()).toString(); + m_terminalTarget->setText(m_lastDialedCall); // last BBS, persisted across restarts + m_terminalRetrySecs->setValue(AppSettings::instance() + .value(kTerminalRetrySecsSetting, kTerminalDefaultRetrySecs).toInt()); + m_terminalMaxTries->setValue(AppSettings::instance() + .value(kTerminalMaxTriesSetting, kTerminalDefaultMaxTries).toInt()); + m_terminalPaclen->setValue(AppSettings::instance() + .value(kTerminalPaclenSetting, kTerminalDefaultPaclen).toInt()); + m_terminalTxTail->setValue(AppSettings::instance() + .value(kTerminalTxTailSetting, kTxTailDefaultMs).toInt()); + + refreshTerminalHeardCombo(); + return page; +} + +void Ax25HfPacketDecodeDialog::submitTerminalInput() +{ + if (!m_terminalInput || !m_terminal) + return; + const QString line = m_terminalInput->text(); + m_terminalInput->clear(); + if (!line.trimmed().isEmpty() + && (m_terminalHistory.isEmpty() || m_terminalHistory.last() != line)) { + m_terminalHistory.append(line); + if (m_terminalHistory.size() > 100) + m_terminalHistory.removeFirst(); + } + m_terminalHistoryIndex = m_terminalHistory.size(); + m_terminal->submitLine(line); +} + +void Ax25HfPacketDecodeDialog::applyTerminalConfigFromUi(bool persist) +{ + if (!m_terminal || !m_terminalMyCall) + return; + const QString call = m_terminalMyCall->text().trimmed(); + m_terminal->setMyCall(call); + if (m_terminalRetrySecs) + m_terminal->setRetryTimeoutMs(m_terminalRetrySecs->value() * 1000); + if (m_terminalMaxTries) + m_terminal->setMaxRetries(m_terminalMaxTries->value()); + if (m_terminalPaclen) + m_terminal->setPaclen(m_terminalPaclen->value()); + if (m_terminalTxTail) + m_txTailMs = m_terminalTxTail->value(); // applies to the next transmission + if (persist) { + AppSettings::instance().setValue(kTerminalMyCallSetting, call); + if (m_terminalRetrySecs) + AppSettings::instance().setValue(kTerminalRetrySecsSetting, + QString::number(m_terminalRetrySecs->value())); + if (m_terminalMaxTries) + AppSettings::instance().setValue(kTerminalMaxTriesSetting, + QString::number(m_terminalMaxTries->value())); + if (m_terminalPaclen) + AppSettings::instance().setValue(kTerminalPaclenSetting, + QString::number(m_terminalPaclen->value())); + if (m_terminalTxTail) + AppSettings::instance().setValue(kTerminalTxTailSetting, + QString::number(m_terminalTxTail->value())); + AppSettings::instance().save(); + } + refreshTerminalStatus(); +} + +void Ax25HfPacketDecodeDialog::refreshTerminalHeardCombo() +{ + if (!m_terminalHeardCombo || !m_heard) + return; + const QString keep = m_terminalHeardCombo->currentData().toString(); + const QSignalBlocker block(m_terminalHeardCombo); + m_terminalHeardCombo->clear(); + m_terminalHeardCombo->addItem(QStringLiteral("— heard stations —"), QString()); + int restore = 0; + const auto stations = m_heard->stations(50); + for (const auto& s : stations) { + const QString call = s.station.toString(); + QString label = QStringLiteral("%1 %2") + .arg(call, s.utc.toString(QStringLiteral("MM/dd HH:mm"))); + m_terminalHeardCombo->addItem(label, call); + if (call == keep) + restore = m_terminalHeardCombo->count() - 1; + } + m_terminalHeardCombo->setCurrentIndex(restore); +} + +void Ax25HfPacketDecodeDialog::refreshTerminalStatus() +{ + if (!m_terminalStatusValue || !m_terminal) + return; + const bool connected = m_terminal->isConnected(); + const bool connecting = m_terminal->isConnecting(); + const bool converse = connected && m_terminal->mode() == TncTerminal::Mode::Converse; + + m_terminalStatusValue->setText(QStringLiteral("%1 | %2") + .arg(m_terminal->statusSummary(), m_terminal->linkStats())); + if (m_terminalStatusDot) { + m_terminalStatusDot->setFixedSize(12, 12); + const QString color = connected ? QStringLiteral("#5fce66") + : (connecting ? QStringLiteral("#e0b341") : QStringLiteral("#8190a3")); + m_terminalStatusDot->setStyleSheet( + QStringLiteral("background:%1;border-radius:6px;").arg(color)); + } + if (m_terminalConnectButton) + m_terminalConnectButton->setEnabled(!connected && !connecting); + if (m_terminalCmdButton) + m_terminalCmdButton->setEnabled(converse); + if (m_terminalInput) { + m_terminalInput->setPlaceholderText(converse + ? QStringLiteral("Connected — type a line to send (or '%1' alone to return to commands)") + .arg(m_terminal->escapeChar()) + : QStringLiteral("Command mode — type CONNECT , HELP, ...")); + } + + // Persist the last BBS we dialed so it pre-fills the target after a restart. + if (connected || connecting) { + const QString peer = m_terminal->peerCall(); + if (!peer.isEmpty() && peer != m_lastDialedCall) { + m_lastDialedCall = peer; + if (m_terminalTarget) + m_terminalTarget->setText(peer); + AppSettings::instance().setValue(kTerminalLastCallSetting, peer); + AppSettings::instance().save(); + } + } +} + +void Ax25HfPacketDecodeDialog::updateTabChrome(int index) +{ + // The Terminal tab (stack index 2) wants the whole window: hide the shared + // decode-log panel and the placeholder action row, and give the tab stack the + // vertical stretch so the transcript fills the viewport. Other tabs keep the + // log panel below their controls. + const bool terminal = (index == 2); + if (m_logFrame) + m_logFrame->setVisible(!terminal); + if (m_actionRowFrame) + m_actionRowFrame->setVisible(!terminal); + if (auto* root = qobject_cast(bodyWidget()->layout())) { + root->setStretchFactor(m_tabStack, terminal ? 1 : 0); + if (m_logFrame) + root->setStretchFactor(m_logFrame, terminal ? 0 : 1); + } +} + // --------------------------------------------------------------------------- // Personal Mailbox System (PMS) tab // --------------------------------------------------------------------------- @@ -2247,4 +2724,28 @@ void Ax25HfPacketDecodeDialog::refreshPmsStatus() } } +bool Ax25HfPacketDecodeDialog::eventFilter(QObject* watched, QEvent* event) +{ + if (watched == m_terminalInput && event->type() == QEvent::KeyPress) { + auto* key = static_cast(event); + if (key->key() == Qt::Key_Up) { + if (m_terminalHistoryIndex > 0) { + --m_terminalHistoryIndex; + m_terminalInput->setText(m_terminalHistory.value(m_terminalHistoryIndex)); + } + return true; + } + if (key->key() == Qt::Key_Down) { + if (m_terminalHistoryIndex < m_terminalHistory.size()) { + ++m_terminalHistoryIndex; + m_terminalInput->setText(m_terminalHistoryIndex < m_terminalHistory.size() + ? m_terminalHistory.value(m_terminalHistoryIndex) + : QString()); + } + return true; + } + } + return PersistentDialog::eventFilter(watched, event); +} + } // namespace AetherSDR diff --git a/src/gui/Ax25HfPacketDecodeDialog.h b/src/gui/Ax25HfPacketDecodeDialog.h index c6eed9b2..4077c729 100644 --- a/src/gui/Ax25HfPacketDecodeDialog.h +++ b/src/gui/Ax25HfPacketDecodeDialog.h @@ -9,9 +9,11 @@ #include #include #include +#include class QAbstractButton; class QCheckBox; +class QComboBox; class QLabel; class QLineEdit; class QPushButton; @@ -24,11 +26,13 @@ class QTimer; namespace AetherSDR { class AudioEngine; +class HeardList; class KissTncServer; class PacketActivityWidget; class PmsMailbox; class RadioModel; class SliceModel; +class TncTerminal; // Persistence for the AetherModem KISS TNC. Per Constitution Principle V, // new feature configuration lives as a nested JSON blob under one root key @@ -84,6 +88,10 @@ class Ax25HfPacketDecodeDialog : public PersistentDialog { void setAttachedSlice(SliceModel* slice); +protected: + // Command history (Up/Down) on the terminal input line. + bool eventFilter(QObject* watched, QEvent* event) override; + private: void setModemProfile(Ax25ModemProfile profile, bool persist); void setDecodeEnabled(bool enabled); @@ -102,6 +110,16 @@ class Ax25HfPacketDecodeDialog : public PersistentDialog { void applyPmsConfigFromUi(bool persist); void refreshPmsStatus(); + // TNC Terminal tab: connected-mode AX.25 client (call out to a packet BBS). + QWidget* buildTerminalPage(); + void submitTerminalInput(); + void refreshTerminalStatus(); + void applyTerminalConfigFromUi(bool persist); + void refreshTerminalHeardCombo(); + // Hide the shared log panel and grow the tab stack on the Terminal tab so the + // transcript gets the full viewport; restore the chrome on the other tabs. + void updateTabChrome(int index); + // KISS TNC tab + TCP server wiring. QWidget* buildKissTncPage(); void setTncEnabled(bool enabled, bool persist); @@ -135,6 +153,8 @@ class Ax25HfPacketDecodeDialog : public PersistentDialog { QLineEdit* m_txText{nullptr}; QPushButton* m_txButton{nullptr}; QTextEdit* m_log{nullptr}; + QWidget* m_logFrame{nullptr}; + QWidget* m_actionRowFrame{nullptr}; QLabel* m_modemStatusDot{nullptr}; QLabel* m_modemStatusValue{nullptr}; QLabel* m_gainStageDot{nullptr}; @@ -196,6 +216,35 @@ class Ax25HfPacketDecodeDialog : public PersistentDialog { quint64 m_kissTxCount{0}; quint64 m_kissRxCount{0}; + // Shared station-heard log (feeds the terminal MHEARD + quick-connect). + HeardList* m_heard{nullptr}; + + // TNC Terminal service (connected-mode AX.25 client) and its controls. + TncTerminal* m_terminal{nullptr}; + QAbstractButton* m_terminalTab{nullptr}; + QLineEdit* m_terminalMyCall{nullptr}; + QLineEdit* m_terminalTarget{nullptr}; + QComboBox* m_terminalHeardCombo{nullptr}; + QPushButton* m_terminalConnectButton{nullptr}; + QPushButton* m_terminalCmdButton{nullptr}; + QPushButton* m_terminalMheardButton{nullptr}; + QSpinBox* m_terminalRetrySecs{nullptr}; + QSpinBox* m_terminalMaxTries{nullptr}; + QSpinBox* m_terminalPaclen{nullptr}; + QSpinBox* m_terminalTxTail{nullptr}; + // PTT tail (ms) after TX audio, before unkey. Operator-tunable to shrink the + // half-duplex turnaround so we hear the peer's next frame sooner. + int m_txTailMs{150}; + QCheckBox* m_terminalLogEnable{nullptr}; + QTextEdit* m_terminalView{nullptr}; + QLineEdit* m_terminalInput{nullptr}; + QPushButton* m_terminalSendButton{nullptr}; + QLabel* m_terminalStatusDot{nullptr}; + QLabel* m_terminalStatusValue{nullptr}; + QStringList m_terminalHistory; + int m_terminalHistoryIndex{0}; + QString m_lastDialedCall; // persisted across restarts (last BBS connected) + // Personal Mailbox System (PMS) service and its controls. PmsMailbox* m_pms{nullptr}; QAbstractButton* m_mailboxTab{nullptr}; diff --git a/src/gui/FramelessWindowTitleBar.cpp b/src/gui/FramelessWindowTitleBar.cpp index 852c0c63..163496bd 100644 --- a/src/gui/FramelessWindowTitleBar.cpp +++ b/src/gui/FramelessWindowTitleBar.cpp @@ -60,6 +60,12 @@ FramelessWindowTitleBar::FramelessWindowTitleBar(const QString& title, QWidget* minButton->setCursor(Qt::ArrowCursor); minButton->setStyleSheet(kButtonStyle); minButton->setToolTip("Minimize"); + // Title-bar buttons must never become the dialog's default button, or + // pressing Return in a text field would trigger them (on macOS this minimized + // the window). Keep them out of the Return/Tab focus chain entirely. + minButton->setAutoDefault(false); + minButton->setDefault(false); + minButton->setFocusPolicy(Qt::NoFocus); connect(minButton, &QPushButton::clicked, this, [this] { if (auto* w = window()) w->showMinimized(); @@ -71,6 +77,9 @@ FramelessWindowTitleBar::FramelessWindowTitleBar(const QString& title, QWidget* maxButton->setCursor(Qt::ArrowCursor); maxButton->setStyleSheet(kButtonStyle); maxButton->setToolTip("Maximize"); + maxButton->setAutoDefault(false); + maxButton->setDefault(false); + maxButton->setFocusPolicy(Qt::NoFocus); connect(maxButton, &QPushButton::clicked, this, [this] { if (auto* w = window()) { if (w->isMaximized()) { @@ -87,6 +96,9 @@ FramelessWindowTitleBar::FramelessWindowTitleBar(const QString& title, QWidget* closeButton->setCursor(Qt::ArrowCursor); closeButton->setStyleSheet(kCloseButtonStyle); closeButton->setToolTip("Close"); + closeButton->setAutoDefault(false); + closeButton->setDefault(false); + closeButton->setFocusPolicy(Qt::NoFocus); connect(closeButton, &QPushButton::clicked, this, [this] { if (auto* w = window()) w->close(); diff --git a/tests/tnc_terminal_test.cpp b/tests/tnc_terminal_test.cpp new file mode 100644 index 00000000..2c8c683a --- /dev/null +++ b/tests/tnc_terminal_test.cpp @@ -0,0 +1,534 @@ +// Integration tests for the TNC terminal (connected-mode AX.25 *client*). These +// drive a TncTerminal against a bare Ax25Connection acting as the answering BBS, +// cross-wiring their frames over a simulated half-duplex air channel. Everything +// runs synchronously (nested signal emission) — no DSP, radio, or event loop — +// exactly mirroring how pms_mailbox_test exercises the answering side. + +#include "core/tnc/Ax25.h" +#include "core/tnc/Ax25Connection.h" +#include "core/tnc/HeardList.h" +#include "core/tnc/TncTerminal.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) + +namespace { +Address call(const char* text) +{ + auto a = Address::parse(QString::fromLatin1(text)); + return a ? *a : Address{}; +} +} // namespace + +// Full happy-path session: connect, exchange data both ways, escape to command +// mode without dropping the link, then BYE to disconnect. +static void testConnectConverseDisconnect() +{ + TncTerminal client; + client.setMyCall(QStringLiteral("N0AAA")); + + Ax25Connection bbs; // the answering side (a BBS) + bbs.setLocalAddress(call("N0BBB-1")); + + // Simulated air: each side's outbound frames are the other's inbound frames. + QObject::connect(&client, &TncTerminal::transmitFrame, [&](const QByteArray& raw) { + if (auto f = Frame::decode(raw)) + bbs.onFrameReceived(*f); + }); + QObject::connect(&bbs, &Ax25Connection::sendFrame, [&](const QByteArray& raw) { + client.onAirFrame(raw); + }); + + QStringList out; + QObject::connect(&client, &TncTerminal::output, [&](const QString& t) { out += t; }); + QByteArray bbsRx; + QObject::connect(&bbs, &Ax25Connection::dataReceived, + [&](const QByteArray& d) { bbsRx += d; }); + bool bbsConnected = false; + bool bbsDisconnected = false; + QObject::connect(&bbs, &Ax25Connection::connected, + [&](const Address&) { bbsConnected = true; }); + QObject::connect(&bbs, &Ax25Connection::disconnected, + [&](const Address&, bool) { bbsDisconnected = true; }); + + // --- CONNECT -------------------------------------------------------------- + client.submitLine(QStringLiteral("C N0BBB-1")); + CHECK(bbsConnected, "BBS accepted the connect (SABM/UA handshake)"); + CHECK(client.isConnected(), "client reports connected"); + CHECK(client.mode() == TncTerminal::Mode::Converse, "client entered converse mode"); + CHECK(out.join(QString()).contains(QLatin1String("CONNECTED")), + "transcript shows CONNECTED"); + + // --- RX from the BBS ------------------------------------------------------ + bbs.sendData(QByteArray("Welcome to N0BBB BBS\r")); + const QString joined = out.join(QString()); + CHECK(joined.contains(QLatin1String("Welcome to N0BBB BBS")), "BBS text shown"); + CHECK(!joined.contains(QLatin1Char('\r')), "CR stripped from displayed text"); + + // --- TX to the BBS (converse mode) --------------------------------------- + client.submitLine(QStringLiteral("LIST")); + CHECK(bbsRx == QByteArray("LIST\r"), "converse line sent CR-terminated to BBS"); + + // --- escape returns to command mode WITHOUT disconnecting ----------------- + client.submitLine(QStringLiteral("~")); + CHECK(client.mode() == TncTerminal::Mode::Command, "escape returned to command mode"); + CHECK(client.isConnected(), "link still up after escape"); + + // --- BYE disconnects ------------------------------------------------------ + client.submitLine(QStringLiteral("BYE")); + CHECK(bbsDisconnected, "BBS saw the DISC"); + CHECK(!client.isConnected(), "client link is down after BYE"); +} + +// A busy/unknown peer replies DM — the connect must fail cleanly and land back +// in command mode (no half-open link). +static void testConnectRefusedDm() +{ + TncTerminal client; + client.setMyCall(QStringLiteral("N0AAA")); + + // Responder: answer any SABM with a DM (refuse). + QObject::connect(&client, &TncTerminal::transmitFrame, [&](const QByteArray& raw) { + auto f = Frame::decode(raw); + if (f && f->type == FrameType::SABM) { + Frame dm = Frame::makeU(f->src, f->dest, FrameType::DM, + f->pollFinal, /*command=*/false); + client.onAirFrame(dm.encode()); + } + }); + + QStringList out; + QObject::connect(&client, &TncTerminal::output, [&](const QString& t) { out += t; }); + + client.submitLine(QStringLiteral("C N0BBB")); + CHECK(!client.isConnected(), "refused connect leaves us disconnected"); + CHECK(!client.isConnecting(), "no lingering connecting state"); + CHECK(client.mode() == TncTerminal::Mode::Command, "back in command mode after refusal"); + CHECK(out.join(QString()).contains(QLatin1String("FAILED")), + "transcript reports the connect failure"); +} + +// Guard rails: command parsing without a link. +static void testCommandGuards() +{ + TncTerminal client; + QStringList out; + QObject::connect(&client, &TncTerminal::output, [&](const QString& t) { out += t; }); + + // CONNECT before MYCALL is set must be rejected. + client.setMyCall(QString()); + client.submitLine(QStringLiteral("CONNECT N0BBB")); + CHECK(!client.isConnecting(), "CONNECT without MYCALL does not dial"); + CHECK(out.join(QString()).contains(QLatin1String("MYCALL")), + "prompted to set MYCALL"); + + // MYCALL command sets the address. + out.clear(); + client.submitLine(QStringLiteral("MYCALL N0AAA-5")); + CHECK(client.myCall() == QLatin1String("N0AAA-5"), "MYCALL command set the call"); + + // Unknown command is reported, not silently ignored. + out.clear(); + client.submitLine(QStringLiteral("FLOOB")); + CHECK(out.join(QString()).contains(QLatin1String("unknown")), + "unknown command reported"); + + // BYE while idle is a no-op that says so. + out.clear(); + client.submitLine(QStringLiteral("BYE")); + CHECK(out.join(QString()).contains(QLatin1String("Not connected")), + "BYE while idle reports not connected"); +} + +// CONNECT ... VIA must attach the digipeater path to the outbound SABM so the +// digi repeats us to the peer. +static void testConnectViaDigipeater() +{ + TncTerminal client; + client.setMyCall(QStringLiteral("N0AAA")); + + QByteArray firstFrame; + QObject::connect(&client, &TncTerminal::transmitFrame, [&](const QByteArray& raw) { + if (firstFrame.isEmpty()) + firstFrame = raw; + }); + + client.submitLine(QStringLiteral("C KX9X-2 VIA WIDE1-1,RELAY")); + auto f = Frame::decode(firstFrame); + CHECK(f && f->type == FrameType::SABM, "outbound SABM emitted"); + CHECK(f && f->dest == call("KX9X-2"), "SABM addressed to the peer"); + CHECK(f && f->via.size() == 2, "two digipeaters in the path"); + CHECK(f && f->via.size() == 2 && f->via.at(0) == call("WIDE1-1") + && f->via.at(1) == call("RELAY"), "digi path in order"); + CHECK(f && !f->via.isEmpty() && !f->via.at(0).hasBeenRepeated, + "outbound digis not yet repeated (H=0)"); +} + +// MHEARD lists stations from the shared heard log, including the last UI beacon. +static void testMheard() +{ + HeardList heard; + // A plain frame and a UI beacon from two stations. + heard.record(Frame::makeI(call("N0AAA"), call("W1ABC"), 0, 0, false, QByteArray("hi"))); + heard.record(Frame::makeUI(call("BEACON"), call("K7XYZ-1"), {}, + QByteArray("AetherMailbox online - connect for mail"))); + + TncTerminal client; + client.setHeardList(&heard); + + QStringList out; + QObject::connect(&client, &TncTerminal::output, [&](const QString& t) { out += t; }); + + client.submitLine(QStringLiteral("MHEARD")); + const QString joined = out.join(QString()); + CHECK(joined.contains(QLatin1String("W1ABC")), "MHEARD lists a heard station"); + CHECK(joined.contains(QLatin1String("K7XYZ-1")), "MHEARD shows SSID"); + CHECK(joined.contains(QLatin1String("AetherMailbox online")), + "MHEARD shows the last beacon text"); +} + +// Lost-UA recovery: we send SABM, the peer accepts but its UA is lost on the +// air, and the peer's prompt I-frame arrives while we still think we're +// connecting. We must adopt the link, deliver the data, and ack — not ignore it +// or reply DM. (Regression for the live 2026-06-03 W6RAY-3 session: connect went +// live but no data ever transferred.) +static void testLostUaAdoptionOnIFrame() +{ + Ax25Connection conn; + conn.setLocalAddress(call("N0AAA")); + + QVector tx; + bool connected = false; + QByteArray rx; + QObject::connect(&conn, &Ax25Connection::sendFrame, + [&](const QByteArray& f) { tx.append(f); }); + QObject::connect(&conn, &Ax25Connection::connected, + [&](const Address&) { connected = true; }); + QObject::connect(&conn, &Ax25Connection::dataReceived, + [&](const QByteArray& d) { rx = d; }); + + conn.connectTo(call("N0BBB")); + CHECK(!connected, "not connected until the peer responds"); + + // No UA arrives; the peer's prompt I-frame (NS=0) shows up instead. + conn.onFrameReceived(Frame::makeI(call("N0AAA"), call("N0BBB"), 0, 0, true, + QByteArray("Welcome to N0BBB\r"))); + CHECK(connected, "adopted the link on the peer's I-frame (lost UA)"); + CHECK(conn.isConnected(), "state is Connected after adoption"); + CHECK(rx == QByteArray("Welcome to N0BBB\r"), "prompt data delivered, not DM'd"); + bool sawRR = false; + bool sawDm = false; + for (const QByteArray& f : tx) { + auto d = Frame::decode(f); + if (d && d->type == FrameType::RR && d->nr == 1) + sawRR = true; + if (d && d->type == FrameType::DM) + sawDm = true; + } + CHECK(sawRR, "acked the adopted I-frame with RR NR=1"); + CHECK(!sawDm, "never sent DM in response to the prompt"); +} + +// Same recovery, but the peer's first post-connect frame is an RR poll (as seen +// live). We must adopt and answer the poll with an RR final, not keep SABMing. +static void testLostUaAdoptionOnRrPoll() +{ + Ax25Connection conn; + conn.setLocalAddress(call("N0AAA")); + + QVector tx; + bool connected = false; + QObject::connect(&conn, &Ax25Connection::sendFrame, + [&](const QByteArray& f) { tx.append(f); }); + QObject::connect(&conn, &Ax25Connection::connected, + [&](const Address&) { connected = true; }); + + conn.connectTo(call("N0BBB")); + tx.clear(); // drop the SABM; we care about the response to the poll + + // Peer polls us with RR command, P=1 (it already thinks we're connected). + conn.onFrameReceived(Frame::makeS(call("N0AAA"), call("N0BBB"), FrameType::RR, + 0, /*pf=*/true, /*cmd=*/true)); + CHECK(connected, "adopted the link on the peer's RR poll"); + bool sawRrFinal = false; + bool sawSabm = false; + for (const QByteArray& f : tx) { + auto d = Frame::decode(f); + if (d && d->type == FrameType::RR && d->pollFinal) + sawRrFinal = true; + if (d && d->type == FrameType::SABM) + sawSabm = true; + } + CHECK(sawRrFinal, "answered the RR poll with an RR final"); + CHECK(!sawSabm, "did not retransmit SABM after adopting"); +} + +// Multi-frame reply (e.g. a long BBS help menu): the peer sends a window of +// I-frames in one burst. On a half-duplex link we must NOT key up to ack each +// unpolled frame mid-burst (that would deafen us to the rest of the window and +// stall the menu). We ack once — when the final, polled frame arrives. +// (Regression for the reported "first packet shows, then stalls".) +static void testMultiFrameDeferredAck() +{ + Ax25Connection conn; + conn.setLocalAddress(call("N0AAA")); + + QVector tx; + QByteArray rx; + QObject::connect(&conn, &Ax25Connection::sendFrame, + [&](const QByteArray& f) { tx.append(f); }); + QObject::connect(&conn, &Ax25Connection::dataReceived, + [&](const QByteArray& d) { rx += d; }); + + // Establish the link (client connectTo + UA). + conn.connectTo(call("N0BBB")); + conn.onFrameReceived(Frame::makeU(call("N0AAA"), call("N0BBB"), FrameType::UA, + /*pf=*/true, /*cmd=*/false)); + tx.clear(); + + auto countType = [&](FrameType t) { + int n = 0; + for (const QByteArray& f : tx) { + auto d = Frame::decode(f); + if (d && d->type == t) + n++; + } + return n; + }; + + // Three-frame menu; only the last frame polls (P=1). + conn.onFrameReceived(Frame::makeI(call("N0AAA"), call("N0BBB"), 0, 0, /*pf=*/false, + QByteArray("MENU line 1\r"))); + conn.onFrameReceived(Frame::makeI(call("N0AAA"), call("N0BBB"), 1, 0, /*pf=*/false, + QByteArray("MENU line 2\r"))); + CHECK(countType(FrameType::RR) == 0, "no ack keyed mid-burst (would deafen us)"); + + conn.onFrameReceived(Frame::makeI(call("N0AAA"), call("N0BBB"), 2, 0, /*pf=*/true, + QByteArray("MENU line 3\r"))); + CHECK(rx == QByteArray("MENU line 1\rMENU line 2\rMENU line 3\r"), + "all three menu frames delivered in order"); + CHECK(countType(FrameType::RR) == 1, "exactly one coalesced ack for the burst"); + + bool finalAckOk = false; + for (const QByteArray& f : tx) { + auto d = Frame::decode(f); + if (d && d->type == FrameType::RR && d->nr == 3 && d->pollFinal) + finalAckOk = true; + } + CHECK(finalAckOk, "ack is RR NR=3 with F=1 (acks the whole window)"); +} + +// Root cause of the "stall while entering commands, far end keeps retrying, then +// disconnects us" report: when the peer REJ's an outbound I-frame, we must resend +// it from our store. The old code rewound V(S) and called pumpOutbound(), but the +// frame's payload was already consumed from the send buffer, so nothing was +// resent and the link silently desynced. +static void testRejTriggersResend() +{ + Ax25Connection conn; + conn.setLocalAddress(call("N0AAA")); + + QVector tx; + QObject::connect(&conn, &Ax25Connection::sendFrame, + [&](const QByteArray& f) { tx.append(f); }); + + conn.connectTo(call("N0BBB")); + conn.onFrameReceived(Frame::makeU(call("N0AAA"), call("N0BBB"), FrameType::UA, + /*pf=*/true, /*cmd=*/false)); + tx.clear(); + + auto countINs = [&](int ns) { + int n = 0; + for (const QByteArray& f : tx) { + auto d = Frame::decode(f); + if (d && d->type == FrameType::I && d->ns == ns) + n++; + } + return n; + }; + + conn.sendData(QByteArray("HELP\r")); + CHECK(countINs(0) == 1, "command goes out as I-frame N(S)=0"); + + // Peer rejects, asking us to resend starting at N(S)=0. + conn.onFrameReceived(Frame::makeS(call("N0AAA"), call("N0BBB"), FrameType::REJ, + /*nr=*/0, /*pf=*/true, /*cmd=*/true)); + CHECK(countINs(0) == 2, "REJ N(R)=0 retransmits the outstanding I-frame N(S)=0"); +} + +// On a half-duplex window=1 link, each outbound I-frame must poll (P=1) so the +// peer acks immediately instead of batching the ack and stalling us into a T1 +// retransmit. +static void testOutboundFramePolls() +{ + Ax25Connection conn; + conn.setLocalAddress(call("N0AAA")); + + QVector tx; + QObject::connect(&conn, &Ax25Connection::sendFrame, + [&](const QByteArray& f) { tx.append(f); }); + + conn.connectTo(call("N0BBB")); + conn.onFrameReceived(Frame::makeU(call("N0AAA"), call("N0BBB"), FrameType::UA, + /*pf=*/true, /*cmd=*/false)); + tx.clear(); + + conn.sendData(QByteArray("X\r")); + bool polled = false; + for (const QByteArray& f : tx) { + auto d = Frame::decode(f); + if (d && d->type == FrameType::I && d->ns == 0) + polled = d->pollFinal; + } + CHECK(polled, "single outbound I-frame sets P=1 (window=1)"); +} + +// Reject-exception: a run of out-of-sequence I-frames must produce exactly ONE +// REJ, not one per frame. The repeated-REJ storm was keeping us keyed on a +// half-duplex link and deaf to the retransmission we asked for, stalling the +// session (observed live with SJVBBS-1: missed NS=1, then a REJ-per-NS=2 storm). +static void testRejectExceptionSuppressesStorm() +{ + Ax25Connection conn; + conn.setLocalAddress(call("N0AAA")); + + QVector tx; + QObject::connect(&conn, &Ax25Connection::sendFrame, + [&](const QByteArray& f) { tx.append(f); }); + + conn.connectTo(call("N0BBB")); + conn.onFrameReceived(Frame::makeU(call("N0AAA"), call("N0BBB"), FrameType::UA, + /*pf=*/true, /*cmd=*/false)); + tx.clear(); + + auto countREJ = [&] { + int n = 0; + for (const QByteArray& f : tx) { + auto d = Frame::decode(f); + if (d && d->type == FrameType::REJ) + n++; + } + return n; + }; + + // Expected N(S)=0, but a run of POLLED NS=2 retransmits arrives — exactly + // the SJVBBS-1 pattern. We must still REJ only once and then stay silent; + // answering each poll is what created the half-duplex phase-lock. + for (int i = 0; i < 4; ++i) + conn.onFrameReceived(Frame::makeI(call("N0AAA"), call("N0BBB"), 2, 0, + /*pf=*/true, QByteArray("x"))); + CHECK(countREJ() == 1, "a run of polled out-of-sequence frames yields exactly one REJ"); + + // The awaited in-sequence frame clears the reject exception... + conn.onFrameReceived(Frame::makeI(call("N0AAA"), call("N0BBB"), 0, 0, + /*pf=*/false, QByteArray("hi"))); + // ...so a fresh gap is allowed to REJ again. + conn.onFrameReceived(Frame::makeI(call("N0AAA"), call("N0BBB"), 3, 0, + /*pf=*/false, QByteArray("y"))); + CHECK(countREJ() == 2, "a new gap after recovery REJs again"); +} + +// An N(R) that acknowledges frames we never sent must be ignored, not applied — +// applying it walks V(A) past V(S) around the mod-8 ring and corrupts the send +// window (the harness caught this as a phantom outstanding count). +static void testInvalidNrIgnored() +{ + Ax25Connection conn; + conn.setLocalAddress(call("N0AAA")); + + conn.connectTo(call("N0BBB")); + conn.onFrameReceived(Frame::makeU(call("N0AAA"), call("N0BBB"), FrameType::UA, + /*pf=*/true, /*cmd=*/false)); + // We have sent no I-frames: V(S)=V(A)=0, nothing outstanding. + conn.onFrameReceived(Frame::makeS(call("N0AAA"), call("N0BBB"), FrameType::RR, + /*nr=*/3, /*pf=*/false, /*cmd=*/false)); + CHECK(conn.sendSeq() == 0, "V(S) unchanged by an out-of-range N(R)"); + CHECK(conn.unacked() == 0, "send window intact (no phantom outstanding frames)"); +} + +// CONV (back to converse) and STATUS (connection stats) command-mode commands. +static void testConvAndStatusCommands() +{ + TncTerminal client; + client.setMyCall(QStringLiteral("N0AAA")); + + Ax25Connection bbs; + bbs.setLocalAddress(call("N0BBB")); + QObject::connect(&client, &TncTerminal::transmitFrame, [&](const QByteArray& raw) { + if (auto f = Frame::decode(raw)) bbs.onFrameReceived(*f); + }); + QObject::connect(&bbs, &Ax25Connection::sendFrame, [&](const QByteArray& raw) { + client.onAirFrame(raw); + }); + + QStringList out; + QObject::connect(&client, &TncTerminal::output, [&](const QString& t) { out += t; }); + + // CONV before connecting is rejected. + client.submitLine(QStringLiteral("CONV")); + CHECK(out.join(QString()).contains(QLatin1String("Not connected")), + "CONV while idle reports not connected"); + + // STATUS works any time and shows the key fields. + out.clear(); + client.submitLine(QStringLiteral("STATUS")); + const QString st = out.join(QString()); + CHECK(st.contains(QLatin1String("STATUS")) && st.contains(QLatin1String("Packets")) + && st.contains(QLatin1String("Retries")), + "STATUS prints the stats block"); + CHECK(st.contains(QLatin1String("N0AAA")), "STATUS shows our callsign"); + + // Connect, drop to command mode, then CONV returns to converse. + client.submitLine(QStringLiteral("C N0BBB")); + CHECK(client.isConnected(), "connected for CONV test"); + client.submitLine(QStringLiteral("~")); // escape to command mode + CHECK(client.mode() == TncTerminal::Mode::Command, "escaped to command mode"); + out.clear(); + client.submitLine(QStringLiteral("CONV")); + CHECK(client.mode() == TncTerminal::Mode::Converse, "CONV returns to converse mode"); +} + +int main(int argc, char** argv) +{ + QCoreApplication app(argc, argv); + + testConvAndStatusCommands(); + testConnectConverseDisconnect(); + testConnectRefusedDm(); + testCommandGuards(); + testConnectViaDigipeater(); + testMheard(); + testLostUaAdoptionOnIFrame(); + testLostUaAdoptionOnRrPoll(); + testMultiFrameDeferredAck(); + testRejTriggersResend(); + testOutboundFramePolls(); + testRejectExceptionSuppressesStorm(); + testInvalidNrIgnored(); + + if (g_failures == 0) + std::fprintf(stderr, "tnc_terminal_test: all checks passed\n"); + else + std::fprintf(stderr, "tnc_terminal_test: %d FAILURE(S)\n", g_failures); + return g_failures == 0 ? 0 : 1; +} diff --git a/tools/ax25_session_analyze.cpp b/tools/ax25_session_analyze.cpp new file mode 100644 index 00000000..881e2cf1 --- /dev/null +++ b/tools/ax25_session_analyze.cpp @@ -0,0 +1,187 @@ +// ax25_session_analyze — replay a captured WAV through BOTH the real decoder +// (AetherAx25LibmodemShim) AND the real connected-mode state machine +// (Ax25Connection), then report how our client reacts frame-by-frame and flag +// sequencing / retry gaps (out-of-sequence drops, REJ storms, stalls). +// +// The captures are RX-only (what the far end transmitted to us), so this models +// "if we received exactly this frame stream, in this order, how does our state +// machine behave?" — isolating logical protocol bugs from RF/half-duplex timing. +// +// Usage: ax25_session_analyze [baud=1200] + +#include "core/tnc/AetherAx25LibmodemShim.h" +#include "core/tnc/Ax25.h" +#include "core/tnc/Ax25Connection.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +using namespace AetherSDR; +using AetherSDR::ax25::Address; +using AetherSDR::ax25::Frame; +using AetherSDR::ax25::FrameType; + +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 (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 frameDesc(const Frame& f) +{ + QString s = ax25::frameTypeName(f.type); + if (f.type == FrameType::I) + s += QStringLiteral(" NS=%1 NR=%2").arg(f.ns).arg(f.nr); + else if (f.type == FrameType::RR || f.type == FrameType::RNR || f.type == FrameType::REJ) + s += QStringLiteral(" NR=%1").arg(f.nr); + if (f.pollFinal) s += QStringLiteral(" P/F"); + return s; +} + +} // 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; + } + + // 1) Decode the WAV to an ordered list of (time, frame). + const Ax25ModemProfile profile = (baud == 300) ? Ax25ModemProfile::Hf300 : Ax25ModemProfile::Vhf1200; + struct Rx { double t; Frame frame; }; + QVector rx; + { + AetherAx25LibmodemShim shim; + shim.configure(ax25DemodConfigForProfile(profile, Ax25TonePolarity::Normal)); + const int chunk = sampleRate / 10; + for (size_t off = 0; off < samples.size(); off += chunk) { + const int n = int(std::min(chunk, samples.size() - off)); + const auto frames = shim.processMonoFloat(samples.data() + off, n, sampleRate); + for (const auto& df : frames) { + if (df.ax25FrameNoFcs.isEmpty()) continue; + auto parsed = Frame::decode(df.ax25FrameNoFcs); + if (parsed) rx.push_back({double(off) / sampleRate, *parsed}); + } + } + } + std::printf("== %s ==\n", path.toLocal8Bit().constData()); + std::printf("decoded %d frame(s) over %.0f s\n", int(rx.size()), + samples.size() / double(sampleRate ? sampleRate : 1)); + if (rx.isEmpty()) { std::printf("(no frames)\n\n"); return 0; } + + // 2) Identify our local address (the dest the far end is talking to) + peer. + const Address local = rx.first().frame.dest; + const Address peer = rx.first().frame.src; + std::printf("local=%s peer=%s\n", local.toString().toLocal8Bit().constData(), + peer.toString().toLocal8Bit().constData()); + + // 3) Drive the real state machine. connectTo() arms the Connecting state; the + // first I/RR adopts the link (lost-UA recovery), then frames flow normally. + Ax25Connection conn; + conn.setLocalAddress(local); + + QVector emitted; + QByteArray deliveredThisFrame; + int totalDelivered = 0, inSeq = 0, outOfSeq = 0, rejSent = 0, rrSent = 0; + int maxOutOfSeqRun = 0, curOutOfSeqRun = 0; + + QObject::connect(&conn, &Ax25Connection::sendFrame, + [&](const QByteArray& f) { emitted.append(f); }); + QObject::connect(&conn, &Ax25Connection::dataReceived, + [&](const QByteArray& d) { deliveredThisFrame += d; }); + + conn.connectTo(peer); + emitted.clear(); + + int prevVr = -1; + for (const Rx& r : rx) { + if (r.frame.dest != local) continue; // only frames addressed to us + emitted.clear(); + deliveredThisFrame.clear(); + const int vrBefore = conn.recvSeq(); + conn.onFrameReceived(r.frame); + + // Classify our reaction. + QStringList reactions; + for (const QByteArray& e : emitted) { + auto d = Frame::decode(e); + if (d) reactions << frameDesc(*d); + if (d && d->type == FrameType::REJ) ++rejSent; + if (d && d->type == FrameType::RR) ++rrSent; + } + const bool advanced = conn.recvSeq() != vrBefore; + if (r.frame.type == FrameType::I) { + if (advanced) { ++inSeq; curOutOfSeqRun = 0; } + else { ++outOfSeq; ++curOutOfSeqRun; maxOutOfSeqRun = std::max(maxOutOfSeqRun, curOutOfSeqRun); } + } + totalDelivered += deliveredThisFrame.size(); + + const char* flag = (r.frame.type == FrameType::I && !advanced) ? " <-- DROPPED (out-of-seq)" : ""; + std::printf("[%6.1fs] RX %-18s -> TX [%s] V(R)=%d->%d data=%dB%s\n", + r.t, frameDesc(r.frame).toLocal8Bit().constData(), + reactions.join(QStringLiteral(", ")).toLocal8Bit().constData(), + vrBefore, conn.recvSeq(), int(deliveredThisFrame.size()), flag); + prevVr = conn.recvSeq(); + } + (void)prevVr; + + std::printf("\n SUMMARY: I-frames in-seq(delivered)=%d out-of-seq(dropped)=%d " + "REJ sent=%d RR sent=%d bytes delivered=%d longest out-of-seq run=%d\n\n", + inSeq, outOfSeq, rejSent, rrSent, totalDelivered, maxOutOfSeqRun); + return 0; +} From c00a83aa5882e9254682eb7a5fd904adc002f0c1 Mon Sep 17 00:00:00 2001 From: "Jeremy [KK7GWY]" Date: Sat, 6 Jun 2026 09:09:39 -0700 Subject: [PATCH 7/8] fix(modem): debounce HeardList saves; document lost-UA invariant + libmodem patch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small fix-ups to bring #3381 to a mergeable state. These were flagged by the AetherClaude bot review on the PR; the rebase that recovered the PMS work after #3290's cascade-close was mechanical and didn't address the review items, so picking them up here. 1. HeardList: debounce save() + warn on persistence failure. record() previously called save() synchronously on every decoded frame — including channel chatter the operator isn't connected to. On a busy APRS frequency or shared HF channel that churns the disk per beacon for no observer-visible benefit (MHEARD doesn't refresh that fast and the right-click → Clear path is unchanged). Now record() calls scheduleSave(), which restarts a 2 s single-shot QTimer; bursts of frames collapse into one rewrite. The destructor flushes a pending save so a clean shutdown doesn't lose the most recent records. clear() stays synchronous because the operator expects the file to be empty when the menu action returns. Separately, save() now emits qCWarning(lcAx25) if QFile::open() fails — the silent-ignore made a failed write invisible. tnc_terminal_test gains LogManager + AsyncLogWriter + AppSettings in its sources so the lcAx25 symbol resolves at link time. 2. Ax25Connection: document the lost-UA fall-through invariant. The lost-UA recovery block calls enterConnected() (which resets V(R)=V(S)=V(A)=0) and then falls through to the switch where the I/RR/RNR/REJ handlers run against the freshly-reset state. That works because the peer's first post-UA-loss I-frame is N(S)=0 = freshly-reset V(R), and ackUpTo() with V(A)=V(S)=0 walks zero slots for any legal N(R). Both invariants are load-bearing — a future MAXFRAME>1 path that pre-allocates V(R) would silently drop the first I-frame as out-of-sequence. Expanded the comment so the next person touching enterConnected()'s reset block sees the coupling. 3. third_party/libmodem_core/README.aethersdr.md: track the off-by-one patch so the next upstream refresh doesn't silently revert it. bitstream.h's `try_decode_frame()` gate was relaxed from `frame_size < 18` to `< 17` in this PR (a no-PID U-frame is exactly 17 bytes; the old gate dropped every SABM/DISC/UA/DM and made connected mode impossible). README now has a "Local patches against upstream" section listing the change with a pointer back to #3381, and the refresh-notes checklist gains "re-apply each entry" as step 4. Also flagged for upstreaming — single-byte off-by-one that bites any libmodem caller doing connected mode, not just AetherSDR. Verified locally on Arch Linux x86: - tnc_terminal_test (12 cases) green - pms_mailbox_test green - ax25_libmodem_shim_test green - Full app build clean (629/629) Principle XI. --- CMakeLists.txt | 5 +++ src/core/tnc/Ax25Connection.cpp | 12 ++++++ src/core/tnc/HeardList.cpp | 37 ++++++++++++++++--- src/core/tnc/HeardList.h | 7 ++++ third_party/libmodem_core/README.aethersdr.md | 18 ++++++++- 5 files changed, 73 insertions(+), 6 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index d315da99..c4148ac0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2002,6 +2002,11 @@ add_executable(tnc_terminal_test src/core/tnc/Ax25Connection.cpp src/core/tnc/HeardList.cpp src/core/tnc/TncTerminal.cpp + # HeardList now emits qCWarning(lcAx25) on persistence failure; pull in + # LogManager + its deps so the category symbol resolves. + src/core/LogManager.cpp + src/core/AsyncLogWriter.cpp + src/core/AppSettings.cpp ) target_include_directories(tnc_terminal_test PRIVATE src) target_link_libraries(tnc_terminal_test PRIVATE Qt6::Core) diff --git a/src/core/tnc/Ax25Connection.cpp b/src/core/tnc/Ax25Connection.cpp index 7788ae62..f1799aae 100644 --- a/src/core/tnc/Ax25Connection.cpp +++ b/src/core/tnc/Ax25Connection.cpp @@ -200,6 +200,18 @@ void Ax25Connection::onFrameReceived(const Frame& frame) // peer's link state (and its prompt), so on a marginal half-duplex path this // is the difference between a working session and a connect that goes live // but never passes data. (UA and DM are handled explicitly in the switch.) + // + // Invariant — the fall-through into the switch below is load-bearing: + // * enterConnected() resets V(R)=V(S)=V(A)=0, so a peer's first + // post-connect I-frame at N(S)=0 lines up with the freshly-reset V(R) + // and the normal I-handler accepts it. With MAXFRAME=1 (today's only + // config) this is always the case. + // * The normal RR/RNR/REJ handlers call ackUpTo(frame.nr); with V(A)= + // V(S)=0 every legal N(R) is in-range and the ack walks zero slots. + // If enterConnected()'s reset block is ever changed to leave V(R) non-zero + // (e.g. a future MAXFRAME>1 path that pre-allocates send slots), this + // adoption must re-sync V(R) to frame.ns before the fall-through — or the + // first I-frame will be silently dropped as out-of-sequence. if (m_state == State::Connecting && frame.src == m_remote && (frame.type == FrameType::I || frame.type == FrameType::RR || frame.type == FrameType::RNR || frame.type == FrameType::REJ)) { diff --git a/src/core/tnc/HeardList.cpp b/src/core/tnc/HeardList.cpp index a801e086..fd0c1a0a 100644 --- a/src/core/tnc/HeardList.cpp +++ b/src/core/tnc/HeardList.cpp @@ -1,11 +1,14 @@ #include "core/tnc/HeardList.h" +#include "core/LogManager.h" + #include #include #include #include #include #include +#include #include @@ -32,9 +35,22 @@ QString beaconText(const QByteArray& info) HeardList::HeardList(QObject* parent) : QObject(parent) { + m_saveCoalesce.setSingleShot(true); + m_saveCoalesce.setInterval(2000); // 2 s — long enough to fold a beacon + // burst, short enough that a SIGKILL + // before flush loses at most ~one cycle + connect(&m_saveCoalesce, &QTimer::timeout, this, [this] { save(); }); } -HeardList::~HeardList() = default; +HeardList::~HeardList() +{ + // If a save was queued but the timer hasn't fired yet, flush now so we + // don't lose the most recent records on a clean shutdown. + if (m_saveCoalesce.isActive()) { + m_saveCoalesce.stop(); + save(); + } +} void HeardList::setPersistencePath(const QString& path) { @@ -69,7 +85,7 @@ void HeardList::record(const Frame& frame) s.lastBeacon = beaconText(frame.info); s.beaconUtc = now; } - save(); + scheduleSave(); emit changed(); return; } @@ -91,10 +107,17 @@ void HeardList::record(const Frame& frame) [](const Station& a, const Station& b) { return a.utc > b.utc; }); m_stations.resize(m_max); } - save(); + scheduleSave(); emit changed(); } +void HeardList::scheduleSave() +{ + if (m_path.isEmpty()) + return; + m_saveCoalesce.start(); // restart timer; bursts collapse into one save +} + QVector HeardList::stations(int max) const { QVector sorted = m_stations; @@ -170,8 +193,12 @@ void HeardList::save() const QJsonObject root; root.insert(QStringLiteral("heard"), arr); QFile f(m_path); - if (f.open(QIODevice::WriteOnly | QIODevice::Truncate)) - f.write(QJsonDocument(root).toJson()); + if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + qCWarning(lcAx25).noquote() + << "HeardList: could not write" << m_path << "—" << f.errorString(); + return; + } + f.write(QJsonDocument(root).toJson()); } } // namespace AetherSDR diff --git a/src/core/tnc/HeardList.h b/src/core/tnc/HeardList.h index 3ffeae9f..fdb2d60a 100644 --- a/src/core/tnc/HeardList.h +++ b/src/core/tnc/HeardList.h @@ -6,6 +6,7 @@ #include #include #include +#include #include namespace AetherSDR { @@ -58,10 +59,16 @@ class HeardList : public QObject { private: void load(); void save() const; + // Schedule a deferred save() if a persistence path is set. Coalesces + // bursts of record() — a busy APRS channel can decode hundreds of frames + // per minute, and a synchronous JSON rewrite per frame churns the disk + // for no observer-visible benefit. + void scheduleSave(); QVector m_stations; QString m_path; int m_max{200}; + QTimer m_saveCoalesce; // single-shot, ~2 s; restarts on each scheduleSave() }; } // namespace AetherSDR diff --git a/third_party/libmodem_core/README.aethersdr.md b/third_party/libmodem_core/README.aethersdr.md index 9162a228..3e8d85ec 100644 --- a/third_party/libmodem_core/README.aethersdr.md +++ b/third_party/libmodem_core/README.aethersdr.md @@ -32,12 +32,28 @@ Dependency removals: declarations continue to compile without libcorrect. AetherSDR Phase 1 does not call them. +Local patches against upstream: + +Any local fix we apply on top of the upstream import must be listed here so +the next refresh doesn't silently revert it. **Re-apply every entry below +after step 2 of the refresh notes.** + +- **`bitstream.h` `try_decode_frame()` — `frame_size < 18` → `< 17`** + ([PR #3381](https://github.com/aethersdr/AetherSDR/pull/3381)). + Minimum valid AX.25 frame WITH FCS is 15 bytes (14 address + 1 control) + plus 2 bytes of FCS = 17. A no-PID U-frame (SABM/DISC/UA/DM — every + connected-mode handshake frame) sits at exactly 17 bytes; the old `< 18` + gate dropped every one, so connected mode could never complete. This is + also worth pushing upstream — it's a single-byte off-by-one that bites + any caller doing connected mode, not just AetherSDR. + Refresh notes: 1. Clone upstream libmodem. 2. Copy only the files above. 3. Re-apply the AX.25-only trim to remove libcorrect-backed FX.25/IL2P code. -4. Build `aether_libmodem_core` and run the AX.25 shim tests. +4. Re-apply each entry in "Local patches against upstream" above. +5. Build `aether_libmodem_core` and run the AX.25 shim tests. Manual RX/TX test notes: From 9d80255fd6c50d8d513206dcbaaa5296db749528 Mon Sep 17 00:00:00 2001 From: "Jeremy [KK7GWY]" Date: Sat, 6 Jun 2026 09:48:02 -0700 Subject: [PATCH 8/8] docs(modem): final-pass tone cleanup on HeardList docstring + libmodem README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two small stale-doc fixes spotted on the final pass before merge: 1. HeardList.h setPersistencePath() docstring still said "persist to on every change" — left over from before c00a83aa's debounce coalescing. Now reflects that writes go through the single-shot QTimer so a beacon burst collapses into one rewrite. 2. third_party/libmodem_core/README.aethersdr.md prose said "Re-apply every entry below after step 2 of the refresh notes" while the numbered checklist immediately below has its own dedicated step 4 for exactly that. Duplicate guidance with off-by-two step numbering — kept the numbered list authoritative. Verified locally: tnc_terminal_test still green (12 cases). Principle XI. --- src/core/tnc/HeardList.h | 6 ++++-- third_party/libmodem_core/README.aethersdr.md | 5 +++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/core/tnc/HeardList.h b/src/core/tnc/HeardList.h index fdb2d60a..14d47dd9 100644 --- a/src/core/tnc/HeardList.h +++ b/src/core/tnc/HeardList.h @@ -34,8 +34,10 @@ class HeardList : public QObject { explicit HeardList(QObject* parent = nullptr); ~HeardList() override; - // Point at a JSON file to load now and persist to on every change. Pass an - // empty path for an in-memory-only list (the default). + // Point at a JSON file to load now and persist to on change (writes are + // coalesced through a single-shot QTimer so a beacon burst collapses into + // one rewrite — see scheduleSave()). Pass an empty path for an + // in-memory-only list (the default). void setPersistencePath(const QString& path); void setMaxStations(int n) { m_max = qBound(10, n, 5000); } diff --git a/third_party/libmodem_core/README.aethersdr.md b/third_party/libmodem_core/README.aethersdr.md index 3e8d85ec..55fb3110 100644 --- a/third_party/libmodem_core/README.aethersdr.md +++ b/third_party/libmodem_core/README.aethersdr.md @@ -35,8 +35,9 @@ Dependency removals: Local patches against upstream: Any local fix we apply on top of the upstream import must be listed here so -the next refresh doesn't silently revert it. **Re-apply every entry below -after step 2 of the refresh notes.** +the next refresh doesn't silently revert it. The numbered refresh-notes +checklist below has a dedicated step for re-applying every entry — keep +the list authoritative, not the prose. - **`bitstream.h` `try_decode_frame()` — `frame_size < 18` → `< 17`** ([PR #3381](https://github.com/aethersdr/AetherSDR/pull/3381)).