diff --git a/CMakeLists.txt b/CMakeLists.txt index d3635f822..c4148ac09 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -577,8 +577,13 @@ 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/HeardList.cpp src/core/tnc/KissFraming.cpp src/core/tnc/KissTncServer.cpp + src/core/tnc/TncTerminal.cpp + src/core/pms/PmsMailbox.cpp ) if(APPLE) @@ -1938,6 +1943,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 @@ -1951,6 +1957,61 @@ 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(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 + 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(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 + # 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) +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/docs/MODEM.md b/docs/MODEM.md index bb3c6cb3e..0a26e2a6b 100644 --- a/docs/MODEM.md +++ b/docs/MODEM.md @@ -202,6 +202,58 @@ 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 **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. + ## 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 +268,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 000000000..039f752fb --- /dev/null +++ b/src/core/pms/PmsMailbox.cpp @@ -0,0 +1,824 @@ +#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 +{ + // 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) +{ + if (m_enabled == on) + return; + m_enabled = on; + if (on) { + if (!m_loaded) + loadAll(); + 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%2.") + .arg(m_listen.toString(), + m_alias.isValid() ? QStringLiteral(" (alias %1)").arg(m_alias.toString()) + : QString())); + } else { + m_link->reset(); + m_beaconTimer->stop(); + saveHeard(); + emit activity(QStringLiteral("PMS disabled.")); + } + emit stateChanged(); +} + +void PmsMailbox::setListenCallsign(const QString& callWithSsid) +{ + 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_listen = addr; + m_link->setLocalAddress(m_listen); + emit stateChanged(); +} + +void PmsMailbox::setAliasCallsign(const QString& callWithSsid) +{ + 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_alias = addr; + m_link->setAliasAddress(m_alias); + 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 (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); + + // 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) + 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 000000000..6b18dff6a --- /dev/null +++ b/src/core/pms/PmsMailbox.h @@ -0,0 +1,192 @@ +#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; } + // 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; } + + 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}; + 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}; + 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/AetherAx25LibmodemShim.cpp b/src/core/tnc/AetherAx25LibmodemShim.cpp index 873e02ab7..c4fa843b1 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/src/core/tnc/Ax25.cpp b/src/core/tnc/Ax25.cpp new file mode 100644 index 000000000..0c170ec11 --- /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 000000000..4d6794109 --- /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 000000000..f1799aae5 --- /dev/null +++ b/src/core/tnc/Ax25Connection.cpp @@ -0,0 +1,538 @@ +#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); + + m_t2 = new QTimer(this); + m_t2->setSingleShot(true); + connect(m_t2, &QTimer::timeout, this, &Ax25Connection::onT2Timeout); +} + +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; +} + +void Ax25Connection::startT1() +{ + m_t1->start(m_t1Ms); +} + +void Ax25Connection::stopT1() +{ + m_t1->stop(); +} + +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(), + 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())); + const QByteArray raw = frame.encode(); + emit sendFrame(raw); + return raw; +} + +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_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); +} + +void Ax25Connection::enterDisconnected(bool byPeer) +{ + const Address peer = m_remote; + stopT1(); + stopT2(); + 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_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 + // 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), + frame.src.toString(), + frame.dest.toString(), + frame.type == FrameType::I + ? 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.) + // + // 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)) { + 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. + 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::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::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: { + 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). 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(); + 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. 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); + // 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(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(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); + m_retryCount = 0; + // 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); + enterDisconnected(/*byPeer=*/true); + } + break; + } + case FrameType::UI: + case FrameType::Unknown: + break; // UI handled elsewhere (beacons / monitor); ignore here. + } +} + +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; + 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() < m_window) { + 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=*/poll, segment); + m_iFrameValid[ns] = true; + m_vs = (m_vs + 1) % 8; + // 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(); + } +} + +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; + ++m_stats.iResent; + } + 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; + ++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); + enterDisconnected(/*byPeer=*/true); + } + return; + } + ++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(); + 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(); + stopT2(); + } +} + +} // namespace AetherSDR diff --git a/src/core/tnc/Ax25Connection.h b/src/core/tnc/Ax25Connection.h new file mode 100644 index 000000000..508889003 --- /dev/null +++ b/src/core/tnc/Ax25Connection.h @@ -0,0 +1,207 @@ +#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 + Connecting, // SABM sent, awaiting UA (outbound connect) + Connected, // information transfer + Disconnecting // DISC sent, awaiting UA + }; + + explicit Ax25Connection(QObject* parent = nullptr); + ~Ax25Connection() override; + + // 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; } + + 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) + // 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); } + + // 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); + + // 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); + + // 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); + + // 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); + // 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 + void ackUpTo(int nr); // slide window per received N(R) + void retransmitUnacked(); // T1 expiry + 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 + int m_vr{0}; // V(R) next expected receive sequence + int m_va{0}; // V(A) last acknowledged send sequence + + 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}; + int m_t2Ms{2000}; // deferred-ack delay (half-duplex burst guard) + int m_retryCount{0}; + 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 000000000..fd0c1a0a3 --- /dev/null +++ b/src/core/tnc/HeardList.cpp @@ -0,0 +1,204 @@ +#include "core/tnc/HeardList.h" + +#include "core/LogManager.h" + +#include +#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) +{ + 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() +{ + // 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) +{ + 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; + } + scheduleSave(); + 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); + } + 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; + 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)) { + 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 new file mode 100644 index 000000000..14d47dd99 --- /dev/null +++ b/src/core/tnc/HeardList.h @@ -0,0 +1,76 @@ +#pragma once + +#include "core/tnc/Ax25.h" + +#include +#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 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); } + + // 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; + // 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/src/core/tnc/TncTerminal.cpp b/src/core/tnc/TncTerminal.cpp new file mode 100644 index 000000000..9bf2197af --- /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 000000000..c8c32f429 --- /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 f20008c14..0484d274e 100644 --- a/src/gui/Ax25HfPacketDecodeDialog.cpp +++ b/src/gui/Ax25HfPacketDecodeDialog.cpp @@ -5,12 +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 @@ -18,10 +23,13 @@ #include #include #include +#include #include #include +#include #include #include +#include #include #include #include @@ -33,6 +41,7 @@ #include #include #include +#include #include #include #include @@ -64,10 +73,37 @@ 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 kPmsListenCallSetting = "AetherModemPmsListenCallsign"; +constexpr auto kPmsAliasCallSetting = "AetherModemPmsAliasCallsign"; +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 @@ -589,6 +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); @@ -604,19 +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_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. @@ -682,8 +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); @@ -697,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); @@ -722,32 +785,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()); @@ -802,6 +887,22 @@ 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 (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); connect(m_shim, &AetherAx25LibmodemShim::statusChanged, @@ -855,6 +956,70 @@ 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_pmsListenCall, &QLineEdit::editingFinished, this, [this] { + applyPmsConfigFromUi(true); + refreshPmsStatus(); + }); + connect(m_pmsAliasCall, &QLineEdit::editingFinished, this, [this] { + 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); + }); + + // 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.")); @@ -863,6 +1028,41 @@ 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(); + + // 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() @@ -1156,7 +1356,7 @@ void Ax25HfPacketDecodeDialog::beginTransmitWhenReady() .arg(m_txChunkCount) .arg(kTxDaxSettleMs) .arg(kTxLeadMs) - .arg(kTxTailMs); + .arg(m_txTailMs); refreshTransmitControls(); QTimer::singleShot(kTxDaxSettleMs, this, [this] { @@ -1250,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; @@ -1530,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) { @@ -1918,4 +2120,632 @@ 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 +// --------------------------------------------------------------------------- + +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, 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); + + 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); + + // 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); + callersLayout->setSpacing(8); + callersLayout->addWidget(sectionLabel(QStringLiteral("LAST CALLERS"), callersFrame)); + m_pmsCallersValue = new QLabel(callersFrame); + m_pmsCallersValue->setObjectName(QStringLiteral("StatusValue")); + m_pmsCallersValue->setWordWrap(true); + m_pmsCallersValue->setAlignment(Qt::AlignTop | Qt::AlignLeft); + callersLayout->addWidget(m_pmsCallersValue, 1); + infoRow->addWidget(callersFrame, 1); + + layout->addLayout(infoRow); + layout->addStretch(1); + + // Seed control values from settings (before signals are wired in the ctor). + // 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() + .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; + 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) + m_pms->setBeaconText(m_pmsBeaconText->text()); + if (m_pmsBeaconEnable) + m_pms->setBeaconEnabled(m_pmsBeaconEnable->isChecked()); + + if (persist) { + auto& s = AppSettings::instance(); + 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) + 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); + } + applyPmsConfigFromUi(false); + if (!m_pms->hasValidAddress()) { + appendSystemLine(QStringLiteral( + "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); + } + refreshPmsStatus(); + return; + } + 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))); + } +} + +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 ef733473b..4077c7299 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,10 +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 @@ -83,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); @@ -95,6 +104,22 @@ 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(); + + // 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); @@ -128,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}; @@ -188,6 +215,49 @@ class Ax25HfPacketDecodeDialog : public PersistentDialog { int m_kissTxBusyRetries{0}; 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}; + QCheckBox* m_pmsEnable{nullptr}; + QLineEdit* m_pmsListenCall{nullptr}; + QLineEdit* m_pmsAliasCall{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/src/gui/FramelessWindowTitleBar.cpp b/src/gui/FramelessWindowTitleBar.cpp index 852c0c63d..163496bd7 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/ax25_libmodem_shim_test.cpp b/tests/ax25_libmodem_shim_test.cpp index 8f3932e1d..fc57b2f43 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/tests/pms_mailbox_test.cpp b/tests/pms_mailbox_test.cpp new file mode 100644 index 000000000..81bc3920d --- /dev/null +++ b/tests/pms_mailbox_test.cpp @@ -0,0 +1,383 @@ +// 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"); + // 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() +{ + 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"); +} + +// 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 { + PmsMailbox* pms{nullptr}; + Address local; + Address 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 + { + 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. 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 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()); + } + 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.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}; + 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"); +} + +// 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 + // 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(); + testHalfDuplexWindowOneDrainsMultiFrame(); + testMailbox(); + testAliasDial(); + + 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; +} diff --git a/tests/tnc_terminal_test.cpp b/tests/tnc_terminal_test.cpp new file mode 100644 index 000000000..2c8c683a3 --- /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/third_party/libmodem_core/README.aethersdr.md b/third_party/libmodem_core/README.aethersdr.md index 9162a2288..55fb31106 100644 --- a/third_party/libmodem_core/README.aethersdr.md +++ b/third_party/libmodem_core/README.aethersdr.md @@ -32,12 +32,29 @@ 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. 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)). + 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: diff --git a/third_party/libmodem_core/bitstream.h b/third_party/libmodem_core/bitstream.h index de940ca9b..a6193becb 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 000000000..02b0fbf3a --- /dev/null +++ b/tools/ax25_replay.cpp @@ -0,0 +1,184 @@ +// 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; + auto reportFrame = [&](const Ax25DecodedFrame& f) { + ++decoded; + const quint8 m = f.control & ~quint8(0x10); // strip P/F + const char* kind = f.isUiFrame ? "UI" + : (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, 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()); + }; + + // 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)); + const auto frames = shim.processMonoFloat(samples.data() + off, n, sampleRate); + for (const auto& f : frames) + reportFrame(f); + } + + 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; +} diff --git a/tools/ax25_session_analyze.cpp b/tools/ax25_session_analyze.cpp new file mode 100644 index 000000000..881e2cf1d --- /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; +}