From 9da4b2bbfb92af83803da46ad95559f7ff427e1b Mon Sep 17 00:00:00 2001 From: opalito Date: Thu, 10 Sep 2026 11:36:55 +0200 Subject: [PATCH 1/5] fix(spe): pace LCD polls from replies and dim, not blank, a stale mirror. Principle II. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The floating mirror visibly appeared and disappeared in cycles of a few seconds on a real 1.5K-FA over a ser2net telnet link (v26.9.2). Two compounding causes: - The 600 ms LCD cadence free-ran as an exact multiple of the 100 ms Status poll, so the two timers phase-lock (Qt coarse timers actively coalesce), and when the locked phase puts each 371-byte display reply across a status poll on the wire, display frames drop in consecutive bursts until clock drift walks the alignment out. The cadence now re-arms from each display reply, folding the amplifier's variable response latency into the period so no stable phase can form. - Freshness loss blanked the glass to the idle hint after two missed refreshes, turning every burst into a full appear/disappear blink. The gate now tolerates one lost frame (stale after three misses), and going stale dims the last image in place — Principle II: the mirror presents the device's last known screen as last-known, it does not pretend the screen ceased to exist. The FRONT PANEL keys still gate on freshness exactly as before; hard clears remain on disconnect and presentation switches, where the image is truly obsolete. Colour ratchet: +0 unique / +0 references / +0 setStyleSheet (the stale veil is an alpha over the existing color.spe.lcd.background token). Co-Authored-By: Claude Fable 5 --- .../spe-expert-amplifier-design.md | 16 ++++++++++--- src/core/SpeConnection.cpp | 10 ++++++++ src/core/SpeConnection.h | 14 +++++++---- src/gui/SpeApplet.cpp | 13 ++++++++--- src/gui/SpeLcdWidget.cpp | 23 ++++++++++++++++++- src/gui/SpeLcdWidget.h | 10 +++++++- 6 files changed, 74 insertions(+), 12 deletions(-) diff --git a/docs/architecture/spe-expert-amplifier-design.md b/docs/architecture/spe-expert-amplifier-design.md index 7a26ebca6..19856ea1d 100644 --- a/docs/architecture/spe-expert-amplifier-design.md +++ b/docs/architecture/spe-expert-amplifier-design.md @@ -343,6 +343,16 @@ With the mirror on screen, the FRONT PANEL keys stop being blind — the operator navigates the amplifier's menu watching the amplifier's screen, which is what unlocked the §4 ruling change. Those keys remain disabled until the first checksum-valid display arrives and are disabled again after -two missed 600 ms refreshes. Every acknowledged keystroke requests an -immediate display refresh and resets the periodic cadence, so a fast menu -sequence does not have to wait a full polling interval to show its result. +three missed 600 ms refreshes — one display frame lost to mid-frame +corruption on a proxy link must read as a hiccup, not flap the gate. Losing +freshness dims the last image on the glass rather than blanking it; the +mirror only returns to the idle glass when the image is truly obsolete +(disconnect, or a docked⇄floating switch). Every acknowledged keystroke +requests an immediate display refresh, and the periodic cadence re-arms +from each display *reply* rather than free-running: 600 ms is an exact +multiple of the 100 ms Status poll, and two free-running timers can +phase-lock with every display reply straddling a status poll on the wire, +dropping display frames in bursts until clock drift walks the alignment +out. Pacing from the reply folds the amplifier's variable response latency +into the period, so no stable phase relationship can form and a fast menu +sequence still never waits a full polling interval to show its result. diff --git a/src/core/SpeConnection.cpp b/src/core/SpeConnection.cpp index f34817a6b..1a74054ff 100644 --- a/src/core/SpeConnection.cpp +++ b/src/core/SpeConnection.cpp @@ -21,6 +21,16 @@ SpeConnection::SpeConnection(QObject* parent) emit lcdFrameReceived(*frame); setLcdFresh(true); m_lcdStaleTimer.start(); + // Pace the next request from the REPLY, not just from our own + // send: 600 is an exact multiple of the 100 ms Status cadence, + // so two free-running timers can phase-lock (Qt's coarse timers + // actively coalesce them) with every display reply straddling a + // status poll on the wire — and hold that alignment for many + // seconds until clock drift walks out of it, dropping several + // display frames in a row. Re-arming here folds the amp's own + // (variable) response latency into the period, so no stable + // phase relationship with the status poll can form. + m_lcdTimer.start(); } }); diff --git a/src/core/SpeConnection.h b/src/core/SpeConnection.h index 95f239c5a..ae9815075 100644 --- a/src/core/SpeConnection.h +++ b/src/core/SpeConnection.h @@ -108,9 +108,10 @@ class SpeConnection : public QObject { void connectionFailed(const QString& errorString); void statusUpdated(const AetherSDR::Spe::Status& status); void lcdFrameReceived(const AetherSDR::Spe::Lcd::Frame& frame); - // True only after a checksum-valid LCD reply, and false again after two - // missed 600 ms refreshes or whenever LCD polling/transport stops. The - // floating menu keys use this independently of Status liveness. + // True only after a checksum-valid LCD reply, and false again after + // kLcdStaleTimeoutMs without one, or whenever LCD polling/transport + // stops. The floating menu keys use this independently of Status + // liveness. void lcdFreshChanged(bool fresh); // Fires on the first Status reply of a connection and again if the // reported ID ever changes (in practice: never mid-session). The GUI @@ -176,7 +177,12 @@ private slots: bool m_lcdWanted{false}; bool m_lcdFresh{false}; static constexpr int kLcdPollIntervalMs = 600; - static constexpr int kLcdStaleTimeoutMs = kLcdPollIntervalMs * 2; + // Three missed refreshes, not two: on a telnet proxy link a single + // display reply is occasionally lost to mid-frame corruption (the + // parser resyncs on the next Status frame), and a one-loss margin made + // the freshness gate visibly flap on real stations. One survivable + // loss, two consecutive losses = stale. + static constexpr int kLcdStaleTimeoutMs = kLcdPollIntervalMs * 3; QString m_currentModelId; diff --git a/src/gui/SpeApplet.cpp b/src/gui/SpeApplet.cpp index f888f6c78..7ecda2455 100644 --- a/src/gui/SpeApplet.cpp +++ b/src/gui/SpeApplet.cpp @@ -380,6 +380,10 @@ void SpeApplet::setFloating(bool floating) return; } m_floating = floating; + // A presentation switch starts the mirror over — whatever image is held + // is from the previous floating session, not merely stale, so drop it + // to the idle glass before the freshness gate re-applies. + m_lcd->clear(); setLcdFresh(false); applyDensity(); // The LCD mirror only exists in the floating presentation — start (or @@ -395,9 +399,12 @@ void SpeApplet::setLcdFrame(const AetherSDR::Spe::Lcd::Frame& frame) void SpeApplet::setLcdFresh(bool fresh) { m_lcdFresh = fresh; - if (!fresh) { - m_lcd->clear(); - } + // Dim, don't clear: losing freshness mid-session means refreshes + // stopped arriving, and blanking the mirror on every dropout made the + // display appear and disappear on lossy links. The keys still gate on + // m_lcdFresh; only the glass keeps its last image. Hard clears remain + // where the image is truly obsolete (disconnect, presentation switch). + m_lcd->setStale(!fresh); updateCommandsEnabled(); } diff --git a/src/gui/SpeLcdWidget.cpp b/src/gui/SpeLcdWidget.cpp index c169638ba..7a198052d 100644 --- a/src/gui/SpeLcdWidget.cpp +++ b/src/gui/SpeLcdWidget.cpp @@ -54,17 +54,28 @@ void SpeLcdWidget::setFrame(const Spe::Lcd::Frame& frame) { m_frame = frame; m_hasFrame = true; + m_stale = false; renderFrame(); update(); } +void SpeLcdWidget::setStale(bool stale) +{ + if (stale == m_stale) { + return; + } + m_stale = stale; + update(); +} + void SpeLcdWidget::clear() { - if (!m_hasFrame) { + if (!m_hasFrame && !m_stale) { return; } m_frame = {}; m_hasFrame = false; + m_stale = false; renderFrame(); update(); } @@ -129,6 +140,16 @@ void SpeLcdWidget::paintEvent(QPaintEvent* event) p.drawImage(QRect(x, y, w, h), m_image); + if (m_hasFrame && m_stale) { + // Veil the glass toward its own background rather than blanking it: + // the operator keeps the last screen for context while the dimming + // says "not live" — alpha over the background token, no new colour. + QColor veil = glassBg; + veil.setAlpha(170); + p.setBrush(veil); + p.drawRect(x, y, w, h); + } + if (!m_hasFrame) { p.setPen(theme.color(this, QStringLiteral("color.spe.lcd.dim"))); QFont f = p.font(); diff --git a/src/gui/SpeLcdWidget.h b/src/gui/SpeLcdWidget.h index b1d393dff..06094cac3 100644 --- a/src/gui/SpeLcdWidget.h +++ b/src/gui/SpeLcdWidget.h @@ -23,8 +23,15 @@ class SpeLcdWidget : public QWidget { explicit SpeLcdWidget(QWidget* parent = nullptr); void setFrame(const Spe::Lcd::Frame& frame); + // Stale = refreshes stopped arriving but the connection is still up: + // the last image stays visible, dimmed. A dropped display frame or two + // on a lossy proxy link must read as a hiccup, not blank the mirror to + // the idle glass and back (the keys' freshness gate is the applet's + // concern, not this widget's). + void setStale(bool stale); // Back to the idle glass (dim "no display data" hint) — used when the - // amplifier goes silent or the connection drops. + // connection drops or a floating presentation opens with no current + // frame; a stale-but-connected mirror keeps its image via setStale. void clear(); QSize minimumSizeHint() const override; @@ -38,6 +45,7 @@ class SpeLcdWidget : public QWidget { Spe::Lcd::Frame m_frame; bool m_hasFrame{false}; + bool m_stale{false}; QImage m_image; // native-resolution render, integer-scaled at paint }; From bad920deac6555cd07b7cedac0776b224eb6d43d Mon Sep 17 00:00:00 2001 From: opalito Date: Thu, 10 Sep 2026 11:44:22 +0200 Subject: [PATCH 2/5] =?UTF-8?q?feat(spe):=20a=20250=20ms=20reply-paced=20L?= =?UTF-8?q?CD=20gap=20=E2=80=94=20faster=20mirror,=20still=20safe=20on=20s?= =?UTF-8?q?low=20links.=20Principle=20II.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the cadence re-armed from each display reply, kLcdPollIntervalMs is an idle gap, not a worst-case-link period: a second request is never in flight before the previous 371-byte reply has fully arrived, so a slow proxy serial side stretches the effective cadence instead of building a request backlog, and the amplifier is never asked to interleave display blocks. 250 ms gives ~3.5 refreshes/s at 115200 (<25% of the wire with the Status poll included) and degrades to ~2/s on a 19200 link on its own. The staleness window becomes an absolute 1800 ms, sized to cover a lost frame plus a retry even at 9600 baud — where the 100 ms Status poll alone nearly saturates the wire, now noted in the design note with a >=57600 proxy recommendation. Co-Authored-By: Claude Fable 5 --- .../spe-expert-amplifier-design.md | 42 ++++++++++++------- src/core/SpeConnection.cpp | 21 ++++++---- src/core/SpeConnection.h | 31 ++++++++++---- 3 files changed, 60 insertions(+), 34 deletions(-) diff --git a/docs/architecture/spe-expert-amplifier-design.md b/docs/architecture/spe-expert-amplifier-design.md index 19856ea1d..48319ed41 100644 --- a/docs/architecture/spe-expert-amplifier-design.md +++ b/docs/architecture/spe-expert-amplifier-design.md @@ -315,9 +315,17 @@ control application and re-validated against the real 1.5K-FA (see `THIRD_PARTY_LICENSES` for the provenance chain, which ends at the MIT-licensed expert-amp-server project) — is: -- **Request**: the standard keystroke-style packet with code `0x80`, - polled at 600 ms (the field-proven cadence; the frame is ~5x a Status - reply, and the mirror is for eyes, not telemetry). +- **Request**: the standard keystroke-style packet with code `0x80`. + Polling is reply-paced: each decoded display schedules the next request + 250 ms later, so the effective cadence is that idle gap plus the round + trip plus the link's own serialization time for the 371-byte frame + (~285 ms total at 115200; a 19200 proxy serial side stretches it to + ~450 ms on its own). A second request is never in flight before the + previous reply has fully arrived — the amplifier is never asked to + interleave display blocks, and a slow link stretches the cadence + instead of accumulating a request backlog. At a 9600 baud proxy serial + side the 100 ms Status poll alone consumes ~80% of the wire, so ser2net + serial sides should be configured at 57600 or above. - **Reply**: `AA AA AA | 6A 01` (16-bit payload length, 362) `| 95 FE | ` 2-byte inverted flag word |` 320 character bytes (8 rows x 40 columns, row-major) + 40 attribute bytes (one per column, bit N = @@ -343,16 +351,18 @@ With the mirror on screen, the FRONT PANEL keys stop being blind — the operator navigates the amplifier's menu watching the amplifier's screen, which is what unlocked the §4 ruling change. Those keys remain disabled until the first checksum-valid display arrives and are disabled again after -three missed 600 ms refreshes — one display frame lost to mid-frame -corruption on a proxy link must read as a hiccup, not flap the gate. Losing -freshness dims the last image on the glass rather than blanking it; the -mirror only returns to the idle glass when the image is truly obsolete -(disconnect, or a docked⇄floating switch). Every acknowledged keystroke -requests an immediate display refresh, and the periodic cadence re-arms -from each display *reply* rather than free-running: 600 ms is an exact -multiple of the 100 ms Status poll, and two free-running timers can -phase-lock with every display reply straddling a status poll on the wire, -dropping display frames in bursts until clock drift walks the alignment -out. Pacing from the reply folds the amplifier's variable response latency -into the period, so no stable phase relationship can form and a fast menu -sequence still never waits a full polling interval to show its result. +1.8 s without one — an absolute window sized to cover a lost frame plus a +retry even on a 9600 baud proxy serial side, because one display frame +lost to mid-frame corruption on a proxy link must read as a hiccup, not +flap the gate. Losing freshness dims the last image on the glass rather +than blanking it; the mirror only returns to the idle glass when the image +is truly obsolete (disconnect, or a docked⇄floating switch). Every +acknowledged keystroke requests an immediate display refresh, and the +cadence re-arms from each display *reply* rather than free-running: the +original free-running 600 ms period was an exact multiple of the 100 ms +Status poll, and two such timers phase-lock with every display reply +straddling a status poll on the wire, dropping display frames in bursts +until clock drift walks the alignment out. Pacing from the reply folds the +amplifier's variable response latency into the period, so no stable phase +relationship can form — and it is also what makes the small 250 ms gap +safe on slow links (see the request bullet above). diff --git a/src/core/SpeConnection.cpp b/src/core/SpeConnection.cpp index 1a74054ff..271795eb5 100644 --- a/src/core/SpeConnection.cpp +++ b/src/core/SpeConnection.cpp @@ -22,14 +22,17 @@ SpeConnection::SpeConnection(QObject* parent) setLcdFresh(true); m_lcdStaleTimer.start(); // Pace the next request from the REPLY, not just from our own - // send: 600 is an exact multiple of the 100 ms Status cadence, - // so two free-running timers can phase-lock (Qt's coarse timers - // actively coalesce them) with every display reply straddling a - // status poll on the wire — and hold that alignment for many - // seconds until clock drift walks out of it, dropping several - // display frames in a row. Re-arming here folds the amp's own + // send. Two free-running timers whose periods divide evenly + // (the original 600 ms cadence was an exact multiple of the + // 100 ms Status poll) phase-lock — Qt's coarse timers actively + // coalesce them — with every display reply straddling a status + // poll on the wire, and hold that alignment for many seconds + // until clock drift walks out of it, dropping several display + // frames in a row. Re-arming here folds the amp's own // (variable) response latency into the period, so no stable - // phase relationship with the status poll can form. + // phase relationship with the status poll can form — and it is + // also what lets kLcdPollIntervalMs be a small idle gap rather + // than a conservative worst-case-link period. m_lcdTimer.start(); } }); @@ -370,8 +373,8 @@ void SpeConnection::onFrameReceived(const Spe::Frame& f) qCDebug(lcTuner) << "SpeConnection: ACK for command" << QString::number(static_cast(f.data.at(0)), 16); // The keys are safe only beside a fresh mirror. Pull the resulting - // screen immediately instead of making a fast menu sequence wait up - // to the next 600 ms periodic refresh. + // screen immediately instead of making a fast menu sequence wait + // out the rest of the current poll gap. requestLcdFrame(); return; } diff --git a/src/core/SpeConnection.h b/src/core/SpeConnection.h index ae9815075..58b029dfe 100644 --- a/src/core/SpeConnection.h +++ b/src/core/SpeConnection.h @@ -75,8 +75,9 @@ class SpeConnection : public QObject { void switchOff() { sendKey(Spe::Key::SwitchOff); } // Remote LCD mirroring: while enabled (and connected) the amplifier's - // display is polled with the 0x80 request at kLcdPollIntervalMs and - // every decoded refresh arrives via lcdFrameReceived. Driven by the + // display is polled with the 0x80 request — each reply schedules the + // next request kLcdPollIntervalMs later — and every decoded refresh + // arrives via lcdFrameReceived. Driven by the // applet's floating state — the docked rail has no room for the LCD, // so polling it there would be pure link noise. void setLcdPolling(bool on); @@ -176,13 +177,25 @@ private slots: QTimer m_lcdStaleTimer; bool m_lcdWanted{false}; bool m_lcdFresh{false}; - static constexpr int kLcdPollIntervalMs = 600; - // Three missed refreshes, not two: on a telnet proxy link a single - // display reply is occasionally lost to mid-frame corruption (the - // parser resyncs on the next Status frame), and a one-loss margin made - // the freshness gate visibly flap on real stations. One survivable - // loss, two consecutive losses = stale. - static constexpr int kLcdStaleTimeoutMs = kLcdPollIntervalMs * 3; + // The IDLE GAP between a display reply and the next request, not a + // free-running period: the timer re-arms from each reply, so the + // effective cadence is gap + round trip + the link's own serialization + // time for the 371-byte frame (~32 ms at 115200, ~193 ms at 19200). + // That self-clocking is what makes a small gap safe on slow links — a + // second request is never in flight before the previous reply has + // fully arrived, so the amp is never asked to interleave display + // blocks and the cadence degrades gracefully instead of piling up. + // (At ≤9600 the 100 ms Status poll alone nearly saturates the wire — + // see the design note §11's proxy baud recommendation.) + static constexpr int kLcdPollIntervalMs = 250; + // Absolute, deliberately decoupled from the poll gap: it must cover a + // full lost frame plus a retry on the slowest plausible link (a 9600 + // baud proxy serial side spends ~390 ms per display frame), and on a + // fast link the extra margin only makes the freshness gate calmer. On + // a telnet proxy a single display reply is occasionally lost to + // mid-frame corruption (the parser resyncs on the next Status frame), + // and a one-loss margin made the gate visibly flap on real stations. + static constexpr int kLcdStaleTimeoutMs = 1800; QString m_currentModelId; From a1ad6eb8aae8f4a58a3e578b9245529d155e67b1 Mon Sep 17 00:00:00 2001 From: opalito Date: Thu, 10 Sep 2026 14:27:44 +0200 Subject: [PATCH 3/5] fix(spe): retry rejected display frames and survive the amp's quiet spells. Principle VII. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hardware truth from the reporting 1.5K-FA: mid-transmit, display replies die on the wire far more often than Status replies — at the same bit-error rate a 371-byte frame is ~5x as exposed as a 76-byte one, and strong RF next to the serial run mid-transmit is exactly where the rate spikes. The amp also stops serving the display for a moment around OPERATE/STANDBY relay transitions. Validation (correctly, Principle VII) rejects the corrupted frames; presenting every such spell as a heavy dim made the mirror read as switched off through whole transmissions. (The parser itself is not at fault: a Python transliteration ran a 20-case torture suite over telnet-escaped inverse-video TX frames — attribute bytes full of 0xFF, splits inside doubled-IAC pairs, 0xFF checksums — and every case passed.) - FrameParser gains a display-reject callback, firing exactly once per complete display frame that fails validation in both its raw and telnet readings; covered by new protocol tests (no fire while a frame is merely incomplete; a clean frame after a reject still hands out). - SpeConnection re-requests 80 ms after a reject: each retry can only be provoked by a full received-and-rejected frame, so the retry stream is self-limited by the link's own serialization time. One clean frame a second is enough to keep the mirror live through a transmission. - Staleness window 1800 -> 2400 ms so relay-transition quiet spells no longer flap the freshness gate. - The stale veil lightens (alpha 170 -> 90 over the background token): the authoritative not-live signal is the disabled key group, not the depth of the dim. Colour ratchet stays +0/+0/+0 (verified strict vs main). Co-Authored-By: Claude Fable 5 --- .../spe-expert-amplifier-design.md | 21 +++++++++++----- src/core/SpeConnection.cpp | 25 +++++++++++++++++++ src/core/SpeConnection.h | 19 +++++++++----- src/core/SpeProtocol.cpp | 7 ++++++ src/core/SpeProtocol.h | 8 ++++++ src/gui/SpeLcdWidget.cpp | 7 +++++- tests/spe_protocol_test.cpp | 21 ++++++++++++++++ 7 files changed, 95 insertions(+), 13 deletions(-) diff --git a/docs/architecture/spe-expert-amplifier-design.md b/docs/architecture/spe-expert-amplifier-design.md index 48319ed41..6561c6978 100644 --- a/docs/architecture/spe-expert-amplifier-design.md +++ b/docs/architecture/spe-expert-amplifier-design.md @@ -351,12 +351,21 @@ With the mirror on screen, the FRONT PANEL keys stop being blind — the operator navigates the amplifier's menu watching the amplifier's screen, which is what unlocked the §4 ruling change. Those keys remain disabled until the first checksum-valid display arrives and are disabled again after -1.8 s without one — an absolute window sized to cover a lost frame plus a -retry even on a 9600 baud proxy serial side, because one display frame -lost to mid-frame corruption on a proxy link must read as a hiccup, not -flap the gate. Losing freshness dims the last image on the glass rather -than blanking it; the mirror only returns to the idle glass when the image -is truly obsolete (disconnect, or a docked⇄floating switch). Every +2.4 s without one — an absolute window sized to cover a lost frame plus a +retry even on a 9600 baud proxy serial side AND the amplifier's own quiet +spells around OPERATE/STANDBY relay transitions, because routine events +must read as a hiccup, not flap the gate. A display frame that arrives +complete but fails validation triggers a prompt re-request (80 ms pause; +each retry is itself provoked by a full received-and-rejected frame, so +the retry stream is self-limited by the link's serialization time): the +field case is strong RF near the serial run mid-transmit, where the +371-byte display reply dies to bit errors far more often than the 76-byte +Status reply, and one clean frame every second or two is all the mirror +needs to stay live through a transmission. Losing freshness anyway dims +the last image on the glass — lightly; the authoritative not-live signal +is the disabled key group, not the depth of the dim — rather than blanking +it; the mirror only returns to the idle glass when the image is truly +obsolete (disconnect, or a docked⇄floating switch). Every acknowledged keystroke requests an immediate display refresh, and the cadence re-arms from each display *reply* rather than free-running: the original free-running 600 ms period was an exact multiple of the 100 ms diff --git a/src/core/SpeConnection.cpp b/src/core/SpeConnection.cpp index 271795eb5..0b9f05cca 100644 --- a/src/core/SpeConnection.cpp +++ b/src/core/SpeConnection.cpp @@ -21,6 +21,7 @@ SpeConnection::SpeConnection(QObject* parent) emit lcdFrameReceived(*frame); setLcdFresh(true); m_lcdStaleTimer.start(); + m_lcdRetryTimer.stop(); // a good frame supersedes a pending retry // Pace the next request from the REPLY, not just from our own // send. Two free-running timers whose periods divide evenly // (the original 600 ms cadence was an exact multiple of the @@ -67,6 +68,27 @@ SpeConnection::SpeConnection(QObject* parent) connect(&m_lcdStaleTimer, &QTimer::timeout, this, [this]() { setLcdFresh(false); }); + + // A display frame that died on the wire is re-requested promptly (the + // field case: strong RF near the serial run mid-transmit corrupts the + // long display replies far more often than the short Status ones, and + // one clean frame every second or two is all the mirror needs to stay + // live). The short pause is the flood guard: each retry can only be + // provoked by a complete received-and-rejected frame, so the loop is + // additionally self-limited by the link's own serialization time. + m_lcdRetryTimer.setSingleShot(true); + m_lcdRetryTimer.setInterval(kLcdRetryGapMs); + connect(&m_lcdRetryTimer, &QTimer::timeout, this, &SpeConnection::requestLcdFrame); + m_parser.setDisplayRejectCallback([this]() { + if (!m_lcdWanted || !m_connected) { + return; + } + qCDebug(lcTuner) << "SpeConnection: display frame failed validation —" + " re-requesting"; + if (!m_lcdRetryTimer.isActive()) { + m_lcdRetryTimer.start(); + } + }); } void SpeConnection::setLcdPolling(bool on) @@ -81,6 +103,7 @@ void SpeConnection::setLcdPolling(bool on) } else { m_lcdTimer.stop(); m_lcdStaleTimer.stop(); + m_lcdRetryTimer.stop(); setLcdFresh(false); } } @@ -211,6 +234,7 @@ void SpeConnection::disconnect() m_pollTimer.stop(); m_lcdTimer.stop(); m_lcdStaleTimer.stop(); + m_lcdRetryTimer.stop(); setLcdFresh(false); m_powerOnTimer.stop(); m_powerOnStep = -1; @@ -272,6 +296,7 @@ void SpeConnection::onTransportDown() m_pollTimer.stop(); m_lcdTimer.stop(); m_lcdStaleTimer.stop(); + m_lcdRetryTimer.stop(); setLcdFresh(false); m_powerOnTimer.stop(); m_powerOnStep = -1; diff --git a/src/core/SpeConnection.h b/src/core/SpeConnection.h index 58b029dfe..7f23242b2 100644 --- a/src/core/SpeConnection.h +++ b/src/core/SpeConnection.h @@ -175,6 +175,7 @@ private slots: // against Status's ~76, and the panel is for eyes, not telemetry. QTimer m_lcdTimer; QTimer m_lcdStaleTimer; + QTimer m_lcdRetryTimer; // single-shot reject->re-request pause bool m_lcdWanted{false}; bool m_lcdFresh{false}; // The IDLE GAP between a display reply and the next request, not a @@ -188,14 +189,20 @@ private slots: // (At ≤9600 the 100 ms Status poll alone nearly saturates the wire — // see the design note §11's proxy baud recommendation.) static constexpr int kLcdPollIntervalMs = 250; + // Prompt-retry pause after a display frame fails validation (see the + // parser's reject callback). Short enough that a mostly-corrupted + // mid-transmit stream still lands a clean frame within the staleness + // window whenever one gets through at all; long enough that the retry + // stream (each retry also provoked by a full received frame) stays + // well under the wire's capacity even at 115200 with Status polling. + static constexpr int kLcdRetryGapMs = 80; // Absolute, deliberately decoupled from the poll gap: it must cover a // full lost frame plus a retry on the slowest plausible link (a 9600 - // baud proxy serial side spends ~390 ms per display frame), and on a - // fast link the extra margin only makes the freshness gate calmer. On - // a telnet proxy a single display reply is occasionally lost to - // mid-frame corruption (the parser resyncs on the next Status frame), - // and a one-loss margin made the gate visibly flap on real stations. - static constexpr int kLcdStaleTimeoutMs = 1800; + // baud proxy serial side spends ~390 ms per display frame) AND the + // amplifier's own quiet spells — it stops serving the display for a + // moment around OPERATE/STANDBY relay transitions — so routine events + // never flap the gate. On a fast link the margin only calms things. + static constexpr int kLcdStaleTimeoutMs = 2400; QString m_currentModelId; diff --git a/src/core/SpeProtocol.cpp b/src/core/SpeProtocol.cpp index 2ca1d011e..2460450d6 100644 --- a/src/core/SpeProtocol.cpp +++ b/src/core/SpeProtocol.cpp @@ -201,6 +201,13 @@ void FrameParser::feed(const QByteArray& bytes) || telnet.state == DisplayCandidateState::Incomplete) { return; } + // Complete in both readings and valid in neither: a display + // frame died on the wire. Report it before resyncing so the + // owner can re-request promptly instead of waiting out the + // rest of its poll gap with a dead mirror. + if (m_onDisplayReject) { + m_onDisplayReject(); + } if (!resyncToNextSync()) { return; } diff --git a/src/core/SpeProtocol.h b/src/core/SpeProtocol.h index 64f3ea8a9..45b8a1c7e 100644 --- a/src/core/SpeProtocol.h +++ b/src/core/SpeProtocol.h @@ -114,6 +114,13 @@ class FrameParser { // payload-length + type-marker bytes and hands the complete raw frame // here instead of misreading the length field as a CNT byte. void setDisplayCallback(std::function cb) { m_onDisplay = std::move(cb); } + // Fires when a complete display-shaped frame fails validation in both + // its raw and telnet-escaped readings and is dropped. A display reply + // is 371 bytes against Status's ~76, so under the same bit-error rate + // (strong RF near the serial run mid-transmit is the field case) it is + // ~5x as likely to die — and unlike Status, nothing re-polls it for + // most of a poll gap. The owner uses this to schedule a prompt retry. + void setDisplayRejectCallback(std::function cb) { m_onDisplayReject = std::move(cb); } void feed(const QByteArray& bytes); void reset() { m_buf.clear(); } @@ -126,6 +133,7 @@ class FrameParser { QByteArray m_buf; std::function m_onFrame; std::function m_onDisplay; + std::function m_onDisplayReject; }; // ── Status string decode (spec §5) ─────────────────────────────────────── diff --git a/src/gui/SpeLcdWidget.cpp b/src/gui/SpeLcdWidget.cpp index 7a198052d..1b3ce6840 100644 --- a/src/gui/SpeLcdWidget.cpp +++ b/src/gui/SpeLcdWidget.cpp @@ -144,8 +144,13 @@ void SpeLcdWidget::paintEvent(QPaintEvent* event) // Veil the glass toward its own background rather than blanking it: // the operator keeps the last screen for context while the dimming // says "not live" — alpha over the background token, no new colour. + // Kept light on purpose: the amplifier routinely pauses display + // service (relay transitions, heavy transmit), and a heavy veil + // made those ordinary moments read as the LCD switching off. The + // authoritative not-live signal is the disabled key group, not the + // depth of the dim. QColor veil = glassBg; - veil.setAlpha(170); + veil.setAlpha(90); p.setBrush(veil); p.drawRect(x, y, w, h); } diff --git a/tests/spe_protocol_test.cpp b/tests/spe_protocol_test.cpp index 824986bbd..eb72a7911 100644 --- a/tests/spe_protocol_test.cpp +++ b/tests/spe_protocol_test.cpp @@ -409,6 +409,27 @@ int main() report("bad display checksum resyncs without consuming the following ACK", afterBadChecksum.isEmpty() && ackAfterBadChecksum.size() == 1); + // The reject callback is the mirror's retry trigger: it must fire + // exactly once per display frame that died on the wire, never for a + // frame that is merely still arriving, and a clean frame afterward + // must still be handed out (mid-transmit RF corrupting most display + // replies is the field case this recovers). + int rejects = 0; + QList afterReject; + FrameParser rejectParser; + rejectParser.setDisplayRejectCallback([&]() { ++rejects; }); + rejectParser.setDisplayCallback( + [&](const QByteArray& d) { afterReject.append(d); }); + rejectParser.feed(badChecksum.left(200)); + report("no reject while the display frame is still incomplete", + rejects == 0); + rejectParser.feed(badChecksum.mid(200)); + report("a complete corrupted display frame fires one reject", + rejects == 1); + rejectParser.feed(raw); + report("a clean display frame after a reject is still handed out", + afterReject.size() == 1 && afterReject.at(0) == raw && rejects == 1); + QByteArray telnet; for (char byte : raw) { telnet.append(byte); From 86039928d6cd42b24139b6a702b98d0a1626da78 Mon Sep 17 00:00:00 2001 From: opalito Date: Thu, 10 Sep 2026 18:03:51 +0200 Subject: [PATCH 4/5] =?UTF-8?q?fix(spe):=20the=20mirror=20never=20marks=20?= =?UTF-8?q?staleness=20on=20the=20glass=20=E2=80=94=20only=20the=20key=20g?= =?UTF-8?q?ate.=20Principle=20II.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field measurement on the reporting station (brightness-mapped from a screen recording, 81 s in plain STANDBY on a quiet band, no transmit): display-frame gaps of 0.5-4 s recur irregularly — eleven episodes in 81 seconds — with Status telemetry flowing throughout. Gaps like these are routine on a best-effort link (a network stall, the amp's own quiet spells; transmit-time RF makes them longer and denser but is not their only cause), so ANY visible staleness treatment fires constantly: the blank-to-idle glass blinked, and even the light dim read as the LCD switching off through every gap. So the glass now behaves like the amplifier's own LCD: it holds the newest picture it has, at full brightness, for as long as the connection lives. Freshness gates exactly one thing — the FRONT PANEL key group — which was always the actual safety property (no blind menu keystrokes). Hard clears remain where the image is truly obsolete: disconnect and presentation switches. The staleness transition is now logged with its window so field reports can measure real gap lengths. The reply-paced cadence, corrupted-frame retry, and 2400 ms key-gate window from the previous commits stay: they minimise how often the keys gate; this commit stops the glass from advertising it. Co-Authored-By: Claude Fable 5 --- .../spe-expert-amplifier-design.md | 17 +++++++---- src/core/SpeConnection.cpp | 5 ++++ src/gui/SpeApplet.cpp | 17 +++++++---- src/gui/SpeLcdWidget.cpp | 28 +------------------ src/gui/SpeLcdWidget.h | 18 ++++++------ 5 files changed, 37 insertions(+), 48 deletions(-) diff --git a/docs/architecture/spe-expert-amplifier-design.md b/docs/architecture/spe-expert-amplifier-design.md index 6561c6978..38d060e04 100644 --- a/docs/architecture/spe-expert-amplifier-design.md +++ b/docs/architecture/spe-expert-amplifier-design.md @@ -361,11 +361,18 @@ the retry stream is self-limited by the link's serialization time): the field case is strong RF near the serial run mid-transmit, where the 371-byte display reply dies to bit errors far more often than the 76-byte Status reply, and one clean frame every second or two is all the mirror -needs to stay live through a transmission. Losing freshness anyway dims -the last image on the glass — lightly; the authoritative not-live signal -is the disabled key group, not the depth of the dim — rather than blanking -it; the mirror only returns to the idle glass when the image is truly -obsolete (disconnect, or a docked⇄floating switch). Every +needs to stay live through a transmission. Losing freshness changes +nothing on the glass: the mirror holds its newest image at full +brightness, exactly like the amplifier's own LCD holds its picture, and +the disabled key group is the one and only not-live signal. (Both +alternatives were field-tested and rejected: blanking the glass made the +mirror blink in and out, and even a light dim read as the LCD switching +off — display gaps of one to several seconds are ROUTINE on a +best-effort link, in plain standby on a quiet band, so any visible +staleness treatment fires constantly and punishes the operator without +adding safety the key gate doesn't already provide.) The mirror only +returns to the idle glass when the image is truly obsolete (disconnect, +or a docked⇄floating switch). Every acknowledged keystroke requests an immediate display refresh, and the cadence re-arms from each display *reply* rather than free-running: the original free-running 600 ms period was an exact multiple of the 100 ms diff --git a/src/core/SpeConnection.cpp b/src/core/SpeConnection.cpp index 0b9f05cca..71caa12ce 100644 --- a/src/core/SpeConnection.cpp +++ b/src/core/SpeConnection.cpp @@ -66,6 +66,11 @@ SpeConnection::SpeConnection(QObject* parent) m_lcdStaleTimer.setSingleShot(true); m_lcdStaleTimer.setInterval(kLcdStaleTimeoutMs); connect(&m_lcdStaleTimer, &QTimer::timeout, this, [this]() { + // Routine on best-effort links (a stall, an amp quiet spell, RF + // mid-transmit) — logged so field reports can measure the gaps. + qCDebug(lcTuner) << "SpeConnection: no valid display frame for" + << kLcdStaleTimeoutMs << "ms — menu keys gated until" + " the next one"; setLcdFresh(false); }); diff --git a/src/gui/SpeApplet.cpp b/src/gui/SpeApplet.cpp index 7ecda2455..59cd2c0cf 100644 --- a/src/gui/SpeApplet.cpp +++ b/src/gui/SpeApplet.cpp @@ -398,13 +398,18 @@ void SpeApplet::setLcdFrame(const AetherSDR::Spe::Lcd::Frame& frame) void SpeApplet::setLcdFresh(bool fresh) { + // Freshness gates the FRONT PANEL keys and nothing else. The glass + // deliberately keeps its last image at full brightness: display frames + // stop for seconds at a time in routine operation (link stalls, the + // amp's own quiet spells around relay transitions, RF bursts on the + // serial run mid-transmit), and every attempt to mark those moments on + // the glass — blanking it, then dimming it — field-tested as the + // mirror visibly "switching off" over and over. The authoritative + // not-live signal is the disabled key group; the mirror, like the + // amplifier's own LCD, just shows the newest picture it has. Hard + // clears remain where the image is truly obsolete (disconnect, + // presentation switch). m_lcdFresh = fresh; - // Dim, don't clear: losing freshness mid-session means refreshes - // stopped arriving, and blanking the mirror on every dropout made the - // display appear and disappear on lossy links. The keys still gate on - // m_lcdFresh; only the glass keeps its last image. Hard clears remain - // where the image is truly obsolete (disconnect, presentation switch). - m_lcd->setStale(!fresh); updateCommandsEnabled(); } diff --git a/src/gui/SpeLcdWidget.cpp b/src/gui/SpeLcdWidget.cpp index 1b3ce6840..c169638ba 100644 --- a/src/gui/SpeLcdWidget.cpp +++ b/src/gui/SpeLcdWidget.cpp @@ -54,28 +54,17 @@ void SpeLcdWidget::setFrame(const Spe::Lcd::Frame& frame) { m_frame = frame; m_hasFrame = true; - m_stale = false; renderFrame(); update(); } -void SpeLcdWidget::setStale(bool stale) -{ - if (stale == m_stale) { - return; - } - m_stale = stale; - update(); -} - void SpeLcdWidget::clear() { - if (!m_hasFrame && !m_stale) { + if (!m_hasFrame) { return; } m_frame = {}; m_hasFrame = false; - m_stale = false; renderFrame(); update(); } @@ -140,21 +129,6 @@ void SpeLcdWidget::paintEvent(QPaintEvent* event) p.drawImage(QRect(x, y, w, h), m_image); - if (m_hasFrame && m_stale) { - // Veil the glass toward its own background rather than blanking it: - // the operator keeps the last screen for context while the dimming - // says "not live" — alpha over the background token, no new colour. - // Kept light on purpose: the amplifier routinely pauses display - // service (relay transitions, heavy transmit), and a heavy veil - // made those ordinary moments read as the LCD switching off. The - // authoritative not-live signal is the disabled key group, not the - // depth of the dim. - QColor veil = glassBg; - veil.setAlpha(90); - p.setBrush(veil); - p.drawRect(x, y, w, h); - } - if (!m_hasFrame) { p.setPen(theme.color(this, QStringLiteral("color.spe.lcd.dim"))); QFont f = p.font(); diff --git a/src/gui/SpeLcdWidget.h b/src/gui/SpeLcdWidget.h index 06094cac3..270d94727 100644 --- a/src/gui/SpeLcdWidget.h +++ b/src/gui/SpeLcdWidget.h @@ -23,15 +23,14 @@ class SpeLcdWidget : public QWidget { explicit SpeLcdWidget(QWidget* parent = nullptr); void setFrame(const Spe::Lcd::Frame& frame); - // Stale = refreshes stopped arriving but the connection is still up: - // the last image stays visible, dimmed. A dropped display frame or two - // on a lossy proxy link must read as a hiccup, not blank the mirror to - // the idle glass and back (the keys' freshness gate is the applet's - // concern, not this widget's). - void setStale(bool stale); - // Back to the idle glass (dim "no display data" hint) — used when the - // connection drops or a floating presentation opens with no current - // frame; a stale-but-connected mirror keeps its image via setStale. + // Back to the idle glass ("waiting for display…" hint) — used only + // when the held image is truly obsolete: the connection dropped, or a + // presentation switch started the mirror over. While connected, the + // widget keeps its newest frame at full brightness no matter how old + // it is — display frames routinely pause for seconds (link stalls, + // the amp's relay transitions, RF mid-transmit), and marking those + // moments on the glass field-tested as the mirror "switching off"; + // staleness gates the applet's menu keys, never this glass. void clear(); QSize minimumSizeHint() const override; @@ -45,7 +44,6 @@ class SpeLcdWidget : public QWidget { Spe::Lcd::Frame m_frame; bool m_hasFrame{false}; - bool m_stale{false}; QImage m_image; // native-resolution render, integer-scaled at paint }; From 6c7033e81c251ab9f55565ee07075ef20ce791d4 Mon Sep 17 00:00:00 2001 From: opalito Date: Thu, 10 Sep 2026 19:05:17 +0200 Subject: [PATCH 5/5] fix(spe): single-shot LCD pacing so the no-overlap invariant is real. Principle II. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review-caught (aethersdr-agent, PR #5542): m_lcdTimer was a repeating timer armed at SEND time, so whenever the round trip exceeded the 250 ms gap the send-side timer fired before the reply and the loop free-ran at a fixed 250 ms send-to-send cadence — pipelining requests into a still-transmitting frame, on exactly the slow link the design note used to justify the small gap, and at a period commensurate with the 100 ms status poll (LCM 500 ms). The doc's central invariant was asserted, not implemented. As the issue triage specified: the timer is now single-shot; a request arms only a 1 s lost-reply fallback (sized above the worst plausible round trip — a 9600 baud proxy serial side spends ~390 ms serializing the frame alone), and only a decoded reply re-arms the short gap. Within the fallback a request is either answered or genuinely lost, never merely still in flight; a round trip beyond it is treated as lost and retried, accepting the overlap risk on a link that degenerate — the design note now states the condition instead of asserting the invariant unconditionally. The reply-side re-arms are also gated on m_lcdWanted && m_connected (the review's teardown-reentry nit). On a 115200 link (round trip well under the gap) the reply re-arm always won the race, so scheduling there is unchanged from the hardware-validated head. Co-Authored-By: Claude Fable 5 --- .../spe-expert-amplifier-design.md | 25 +++++---- src/core/SpeConnection.cpp | 51 ++++++++++++------- src/core/SpeConnection.h | 26 ++++++---- 3 files changed, 64 insertions(+), 38 deletions(-) diff --git a/docs/architecture/spe-expert-amplifier-design.md b/docs/architecture/spe-expert-amplifier-design.md index 38d060e04..3280103cb 100644 --- a/docs/architecture/spe-expert-amplifier-design.md +++ b/docs/architecture/spe-expert-amplifier-design.md @@ -316,16 +316,21 @@ control application and re-validated against the real 1.5K-FA (see MIT-licensed expert-amp-server project) — is: - **Request**: the standard keystroke-style packet with code `0x80`. - Polling is reply-paced: each decoded display schedules the next request - 250 ms later, so the effective cadence is that idle gap plus the round - trip plus the link's own serialization time for the 371-byte frame - (~285 ms total at 115200; a 19200 proxy serial side stretches it to - ~450 ms on its own). A second request is never in flight before the - previous reply has fully arrived — the amplifier is never asked to - interleave display blocks, and a slow link stretches the cadence - instead of accumulating a request backlog. At a 9600 baud proxy serial - side the 100 ms Status poll alone consumes ~80% of the wire, so ser2net - serial sides should be configured at 57600 or above. + Polling is reply-paced on a single-shot timer: a request arms only a + 1 s lost-reply fallback, and each decoded display re-arms the short + 250 ms gap — so the effective cadence is gap plus round trip plus the + link's own serialization time for the 371-byte frame (~285 ms total at + 115200; a 19200 proxy serial side stretches it to ~450 ms on its own). + As long as the round trip stays under the 1 s fallback — which covers + the slowest plausible link, a 9600 baud serial side spending ~390 ms on + the frame alone — a second request is never issued while the previous + reply is still arriving: the amplifier is never asked to interleave + display blocks, and a slow link stretches the cadence instead of + accumulating a request backlog. A round trip beyond the fallback is + treated as a lost reply and retried, accepting the overlap risk on a + link that degenerate. At a 9600 baud proxy serial side the 100 ms + Status poll alone consumes ~80% of the wire, so ser2net serial sides + should be configured at 57600 or above. - **Reply**: `AA AA AA | 6A 01` (16-bit payload length, 362) `| 95 FE | ` 2-byte inverted flag word |` 320 character bytes (8 rows x 40 columns, row-major) + 40 attribute bytes (one per column, bit N = diff --git a/src/core/SpeConnection.cpp b/src/core/SpeConnection.cpp index 71caa12ce..62cefe022 100644 --- a/src/core/SpeConnection.cpp +++ b/src/core/SpeConnection.cpp @@ -19,22 +19,27 @@ SpeConnection::SpeConnection(QObject* parent) // a moving display must not keep stale telemetry/buttons looking live. if (const auto frame = Spe::Lcd::decode(raw)) { emit lcdFrameReceived(*frame); - setLcdFresh(true); - m_lcdStaleTimer.start(); - m_lcdRetryTimer.stop(); // a good frame supersedes a pending retry - // Pace the next request from the REPLY, not just from our own - // send. Two free-running timers whose periods divide evenly - // (the original 600 ms cadence was an exact multiple of the - // 100 ms Status poll) phase-lock — Qt's coarse timers actively - // coalesce them — with every display reply straddling a status - // poll on the wire, and hold that alignment for many seconds - // until clock drift walks out of it, dropping several display - // frames in a row. Re-arming here folds the amp's own - // (variable) response latency into the period, so no stable - // phase relationship with the status poll can form — and it is - // also what lets kLcdPollIntervalMs be a small idle gap rather - // than a conservative worst-case-link period. - m_lcdTimer.start(); + if (m_lcdWanted && m_connected) { + setLcdFresh(true); + m_lcdStaleTimer.start(); + m_lcdRetryTimer.stop(); // a good frame supersedes a pending retry + // Pace the next request from the REPLY: the request path + // armed only the long lost-reply fallback, so this re-arm + // to the short gap is the one that sets the cadence. Two + // free-running timers whose periods divide evenly (the + // original 600 ms cadence was an exact multiple of the + // 100 ms Status poll) phase-lock — Qt's coarse timers + // actively coalesce them — with every display reply + // straddling a status poll on the wire, and hold that + // alignment for many seconds until clock drift walks out + // of it, dropping several display frames in a row. Pacing + // from the reply folds the amp's own (variable) response + // latency into the period, so no stable phase relationship + // with the status poll can form — and it is what lets + // kLcdPollIntervalMs be a small idle gap rather than a + // conservative worst-case-link period. + m_lcdTimer.start(kLcdPollIntervalMs); + } } }); @@ -60,6 +65,12 @@ SpeConnection::SpeConnection(QObject* parent) m_powerOnTimer.setSingleShot(true); connect(&m_powerOnTimer, &QTimer::timeout, this, &SpeConnection::powerOnStep); + // Single-shot on purpose: the cadence is reply-paced (armed with the + // short gap from each decoded display reply), and a request arms only + // the LONG lost-reply interval — so the next request cannot fire while + // the previous reply is still in flight unless the round trip exceeds + // kLcdLostReplyMs, which the design note states as the condition. + m_lcdTimer.setSingleShot(true); m_lcdTimer.setInterval(kLcdPollIntervalMs); connect(&m_lcdTimer, &QTimer::timeout, this, &SpeConnection::requestLcdFrame); @@ -119,9 +130,11 @@ void SpeConnection::requestLcdFrame() return; } sendRaw(Spe::Lcd::buildRequest()); - // An ACK-triggered refresh resets the periodic cadence, avoiding an - // immediate duplicate request from the timer that may already be near due. - m_lcdTimer.start(); + // A request arms only the lost-reply fallback. The short-gap re-arm + // lives in the display callback, so on a slow link the loop waits for + // the reply (or this timeout) rather than free-running a fixed cadence + // into a still-transmitting frame — the review-caught failure mode. + m_lcdTimer.start(kLcdLostReplyMs); } void SpeConnection::setLcdFresh(bool fresh) diff --git a/src/core/SpeConnection.h b/src/core/SpeConnection.h index 7f23242b2..7957ac27c 100644 --- a/src/core/SpeConnection.h +++ b/src/core/SpeConnection.h @@ -179,16 +179,24 @@ private slots: bool m_lcdWanted{false}; bool m_lcdFresh{false}; // The IDLE GAP between a display reply and the next request, not a - // free-running period: the timer re-arms from each reply, so the - // effective cadence is gap + round trip + the link's own serialization - // time for the 371-byte frame (~32 ms at 115200, ~193 ms at 19200). - // That self-clocking is what makes a small gap safe on slow links — a - // second request is never in flight before the previous reply has - // fully arrived, so the amp is never asked to interleave display - // blocks and the cadence degrades gracefully instead of piling up. - // (At ≤9600 the 100 ms Status poll alone nearly saturates the wire — - // see the design note §11's proxy baud recommendation.) + // free-running period: m_lcdTimer is single-shot, a REQUEST arms only + // the kLcdLostReplyMs fallback, and only a decoded REPLY re-arms this + // short gap — so the effective cadence is gap + round trip + the + // link's serialization time for the 371-byte frame (~32 ms at 115200, + // ~193 ms at 19200), and a second request cannot be issued while the + // previous reply is still arriving unless the round trip exceeds + // kLcdLostReplyMs. That self-clocking is what makes a small gap safe + // on slow links: the amp is never asked to interleave display blocks + // and the cadence stretches instead of piling up. (At ≤9600 the + // 100 ms Status poll alone nearly saturates the wire — see the design + // note §11's proxy baud recommendation.) static constexpr int kLcdPollIntervalMs = 250; + // Lost-reply fallback: armed at request time, superseded by the reply + // re-arm above. Sized above the worst plausible round trip (a 9600 + // baud proxy serial side spends ~390 ms serializing the frame alone), + // so within it a request is either answered or genuinely lost — never + // merely still in flight. + static constexpr int kLcdLostReplyMs = 1000; // Prompt-retry pause after a display frame fails validation (see the // parser's reject callback). Short enough that a mostly-corrupted // mid-transmit stream still lands a clean frame within the staleness