From 12d94fc67828aa5d29138c279e993639d099e84e Mon Sep 17 00:00:00 2001 From: jensenpat Date: Tue, 8 Sep 2026 18:03:29 -0700 Subject: [PATCH] Fix Icom Persist freshness and TX evidence reporting. Principle VIII. --- docs/automation-bridge.md | 35 ++++++ docs/automation/TX_TEST_PROMPT.md | 15 ++- ...ist-icom7300mk2-expanded-run-2026-09-08.md | 8 +- ...ersist-icom7300mk2-first-run-2026-09-08.md | 13 +- ...sist-icom7300mk2-p1-followup-2026-09-08.md | 114 ++++++++++++++++++ src/core/AutomationServer.cpp | 46 ++++++- src/core/backends/icom/IcomCivBackend.cpp | 92 +++++++++++++- src/core/backends/icom/IcomCivBackend.h | 14 +++ src/core/backends/icom/IcomCivScheduler.cpp | 1 + src/core/backends/icom/IcomCivScheduler.h | 2 + tests/automation_persist_diagnostics_test.cpp | 93 ++++++++++++++ tests/icom_civ_scheduler_test.cpp | 21 ++++ tests/icom_incident_telemetry_test.cpp | 77 ++++++++++++ tests/tests.cmake | 7 ++ tools/test_tx_meter_test.py | 69 +++++++++++ tools/tx_meter_test.py | 111 +++++++++++++---- 16 files changed, 686 insertions(+), 32 deletions(-) create mode 100644 docs/research/persist-icom7300mk2-p1-followup-2026-09-08.md create mode 100644 tests/automation_persist_diagnostics_test.cpp diff --git a/docs/automation-bridge.md b/docs/automation-bridge.md index a3c557841..52962b978 100644 --- a/docs/automation-bridge.md +++ b/docs/automation-bridge.md @@ -1046,6 +1046,15 @@ used by the stacked trace renderer. - `kiwiFftTraceFloorDbm` versus `kiwiDisplayFloorDbm` — distinguishes the FFT trace floor used by 3D placement from the waterfall color floor. +`get meters` additionally reports `temperature` and `voltage` observations with +`status`, `value`, `unit` (when declared), and `ageMs`. Unsupported, never-fed, and stale vitals +have a null value; a fresh zero is still a real reading. The legacy `paTemp` and +`supplyVolts` scalars use those same qualified values. `alc` retains the native +unit and age; `swAlc` is a legacy conversion and must not be labeled physical +Icom dBFS. `txtest twotone` refuses Icom: its current TUNE backend generates one +sine wave and has no two-tone selection route. Ordinary TUNE remains available +in supported modes. + ### `radiocert persist` `radiocert persist` returns a **read-only persistence snapshot**, also allowed in @@ -1061,6 +1070,22 @@ average (with its known flag), waterfall rate (legacy name `waterfallLineDuration`, **1..100, not milliseconds**, -1 unknown), center-known, WNB and available RX antennas. +For Icom, `backendDiagnostics.result` also includes the read-only `civ scheduler` +payload. Its `stateFreshness` separates `transportConnected`, CI-V `identified`, +and `trackedStateReady`. The six tracked fields are selected-VFO frequency, +mode/DATA/filter tuple (decimal wire codes), squelch percent, AGC code, RF power +percent, and PTT. Each has a last decoded value, age, semantic key and status: +`never-confirmed`, `pending`, `previous-context`, `stale`, or `confirmed`. +Only validated receive publications refresh these fields, including unchanged +replies. A setter or generic ACK cannot confirm them. Frequency/mode/filter +changes and outgoing VFO select/exchange invalidate the prior context; session +changes invalidate old observations. The diagnostic age budget is 5000 ms and +does not change polling or authorize TX. Fields outside this list, including +filter width and AGC threshold/off level, carry no freshness claim. CI-V has no +transaction identifiers, so delayed unsolicited data cannot prove physical +intent correlation or an unobserved front-panel VFO change with identical mode +and frequency. + The snapshot explicitly identifies its evidence as **client model and presentation**. Some model setters update optimistically. Equality here alone is neither independent wire readback nor proof of a durable disk commit. @@ -3578,6 +3603,16 @@ producer in isolation: "pendingPttIntent":false}} ``` +The scheduler also returns up to 128 `transactions`, `firstRetainedEventId`, +`lastRetainedEventId`, and `stateFreshness` (see Persist above). Deduplicate +completion events by `backendInstanceId` plus `eventId`, never by semantic +`key`/`generation`/`completion`: periodic polls reuse those three fields. +Event IDs increase across ring eviction, history clears and scheduler resets. +A timeout and its eventual late reply are separate completion events. A jump +past the previously collected ID is an evidence gap, not zero missing activity. +A new backend starts a new UUID `backendInstanceId`, also present inside +`stateFreshness`; use it even when a reconnect reuses the same process and radio. + While a PTT request is awaiting confirmation the reply also carries `"pttIntent"` (the requested state) and `"pttIntentRemainingMs"` (how much of the bounded window is left). Suppression applies only while `pttIntent` is diff --git a/docs/automation/TX_TEST_PROMPT.md b/docs/automation/TX_TEST_PROMPT.md index 942c461e9..d97c0f363 100644 --- a/docs/automation/TX_TEST_PROMPT.md +++ b/docs/automation/TX_TEST_PROMPT.md @@ -84,7 +84,16 @@ subscription path. - Begin with the lowest authorized Tune Power percentage. - Sample forward power, SWR, ALC, compression, voltage, current, and thermal data that the radio actually supports. Mark unsupported meters as such. -- Reject stale ages and rail-pinned values. +- Reject stale ages and rail-pinned values. Do not substitute scalar defaults + for unsupported temperature or voltage. Report ALC in its declared native unit. +- Start the freshness deadline at the key command, including command latency. + A sample predating that command cannot qualify as this burst's telemetry. +- In CW, a zero-carrier gap deliberately nulls the displayed SWR. It can continue + only after a qualified SWR was observed in this burst, with fresh zero-watt + power and fresh SWR receive timestamps. Missing/stale telemetry and a missing + ratio with positive power still stop the run; the timing budgets are unchanged. +- Icom's current TUNE producer is a single sine wave. `txtest twotone` refuses + that family; do not record ordinary TUNE output as two-tone or IMD proof. - Verify the actual power gauge is live only while keyed. - Unkey immediately, then verify the gauge is zero both at the edge and after a late in-flight response could arrive. @@ -106,6 +115,10 @@ bypass state. The sampling window repeats the context check and uses fresh peak SWR and every sampled meter row. Missing/unknown link state, a missing or stale calibrated power definition, and missing/stale SWR stop the run. Unkey is confirmed before restoring power; unknown TX flags never count as unkeyed. +For Icom, model flags alone are insufficient: the harness additionally requires +`stateFreshness.fields.ptt` to report a confirmed false value received during +that unkey observation window and younger than 500 ms. Older app builds without +this diagnostic cannot satisfy that Icom confirmation gate. ### 4. Restart proof diff --git a/docs/research/persist-icom7300mk2-expanded-run-2026-09-08.md b/docs/research/persist-icom7300mk2-expanded-run-2026-09-08.md index 5a82ae409..c4bca6c0d 100644 --- a/docs/research/persist-icom7300mk2-expanded-run-2026-09-08.md +++ b/docs/research/persist-icom7300mk2-expanded-run-2026-09-08.md @@ -86,7 +86,9 @@ controlled regression and live before/after prove causality. ## Guarded transmit and restoration -After the fourth retention restart, one 1.6-second USB two-tone window used +After the fourth retention restart, one 1.6-second USB window requested the +bridge two-tone action. Later source review established that Icom generated a +single tone; this is not two-tone/IMD proof. That window used 7.200 MHz, ANT1, RF 3% and Tune 2%. It obtained six fresh positive forward-power samples, peak unsmoothed calibrated **2.098 W** and SWR **1.0**, with no stop reason. The actual forward gauge was sampled while keyed; explicit unkey @@ -156,3 +158,7 @@ TX safety checks passed in the publication worktree; bridge-doc generation and whitespace validation passed. An initial `unittest` module invocation lacked the tools import path; direct documented script entry points completed successfully. These are tooling checks, not new native-build or hardware-fix evidence. + +The subsequent [P1 follow-up](persist-icom7300mk2-p1-followup-2026-09-08.md) +implements bounded freshness diagnostics and corrects meter/TX-test reporting. +It preserves this earlier run and its unresolved observations. diff --git a/docs/research/persist-icom7300mk2-first-run-2026-09-08.md b/docs/research/persist-icom7300mk2-first-run-2026-09-08.md index acb19c894..67f4d9a31 100644 --- a/docs/research/persist-icom7300mk2-first-run-2026-09-08.md +++ b/docs/research/persist-icom7300mk2-first-run-2026-09-08.md @@ -160,8 +160,11 @@ also observed the actual gauge rise from zero through approximately 1.05–2.27 while keyed. Earlier windows captured producer/model meters plus pre/post widgets; they did not sample the actual widget throughout keying. -The Tune waveform selection can persist after two-tone, so later DIGL/RTTY -TUNE button results must not be labeled proven single-tone tests. They show +P1 source review corrected the waveform interpretation: Icom's `setTune()` +generates one sine wave; the Flex `tune_mode` command has no Icom route. The rows +labeled Two-tone above describe the requested bridge action, not a proven +waveform. Their RF observations remain valid, but none proves two-tone/IMD. +The updated bridge refuses that misleading Icom request. These windows show the requested TUNE path produced reported RF in those radio modes. Both DATA and non-DATA modulation sources already replied LAN (`1a 05 00 85 05` @@ -205,7 +208,11 @@ are also retained. These are harness findings, not successful radio tests. Diagnostic logging was reset, the final client exited normally, and the radio lock was released. Final state: **one slice, transmitting false**. -## Concrete next work +## Concrete next work recorded at the first run + +The [P1 follow-up](persist-icom7300mk2-p1-followup-2026-09-08.md) now implements +bounded freshness/event diagnostics and corrects the TX/meter harness. The list +below records the original findings; remaining limits are updated in that report. 1. Add a stable transaction event sequence and bounded export/coverage counters so queue/response distributions can be measured without duplicate loss. diff --git a/docs/research/persist-icom7300mk2-p1-followup-2026-09-08.md b/docs/research/persist-icom7300mk2-p1-followup-2026-09-08.md new file mode 100644 index 000000000..807c1ffd7 --- /dev/null +++ b/docs/research/persist-icom7300mk2-p1-followup-2026-09-08.md @@ -0,0 +1,114 @@ +# IC-7300MK2 Persist P1 follow-up + +This follow-up to merged #5500 addresses the diagnostic and test-process P1 +items from the [expanded run](persist-icom7300mk2-expanded-run-2026-09-08.md). +The four earlier persistence repairs belong to #5514. This change does not +claim to repair every intermittent radio symptom or complete the entire Icom +control matrix. + +## Changes and observed outcomes + +| Area | Repair / evidence | Remaining boundary | +|---|---|---| +| State freshness | Accepted frequency, mode/data/filter tuple, SQL, AGC, RF-power and PTT publications carry confirmation age and session/context identity. Pending writes and startup defaults cannot imply readiness. Unchanged valid replies refresh age. | Six tracked fields only. Filter width/PBT, unselected VFO, meters and other controls are not included in the readiness flag. Readiness is diagnostic, not permission to transmit. | +| Transaction history | Monotonic event IDs survive scheduler reset; a backend UUID separates different instances. The bounded export includes retained endpoints for collector gap detection. | Only 128 retained events. Deduplicate by backend UUID/event ID; initial history is not newly observed traffic. CI-V has no transaction IDs, so delayed unsolicited replies cannot prove physical-intent correlation. | +| TX safety/reporting | The first-sample deadline starts before the key command. Prior-burst samples cannot qualify. Fresh zero-carrier CW gaps may omit the SWR ratio only after a valid ratio in the same burst, with both power and SWR telemetry under 500 ms. Icom unkey requires a fresh accepted PTT-off reply as well as model flags. | The 0.9-second initial deadline, 500 ms safety freshness and measured-watt limit are unchanged. Missing replies, positive power without SWR, and bursts that never establish SWR still stop. | +| Waveform labeling | Icom `setTune()` feeds a single sine; Flex `tune_mode` has no Icom route. The bridge now refuses `txtest twotone` on Icom before keying. Earlier reports are corrected. | Actual Icom two-tone/IMD generation remains unsupported. Ordinary TUNE remains a single-tone path. | +| Meter provenance | Undefined or never-fed PA temperature is null, not zero. Low-rate vitals have status, unit and age. The TX harness reports native ALC units rather than substituting legacy `swAlc` dBFS. | Native radio meters and widget observations are not independent RF instrument measurements. | + +No poll cadence, queue priority, retry count, audio pipeline or default radio +setting was changed. A mode/frequency publication change and an outgoing VFO +select/exchange invalidate tracked context; an unobserved physical VFO change +with identical reported values cannot be detected by this mechanism. + +## Live evidence + +The operator authorized an IC-7300MK2 on a dummy load at ANT1, 7.200 MHz, all +modes, maximum 10 W. The radio lock was acquired before connecting. Test clients +used an isolated profile, session-only credentials, a three-second TX watchdog +and a five-percent TX control ceiling. No ATU cycle or VOX enable was performed. +Percentages below are setpoints; watts are the radio's unsmoothed calibrated +forward-power readings. + +| Trial | Outcome | +|---|---| +| CW text, 2% RF | Repeat established 2.797 W and SWR 1.0. One earlier follow-up stopped during a zero-carrier character gap: power and SWR telemetry were fresh, but the model intentionally hid the ratio at zero power. The corrected gap handling completed the repeat. | +| AM TUNE, 2% | Radio returned fresh zero forward-power data and no usable SWR; the guard stopped. This establishes a meter-reported zero, not an independent measurement of zero RF or receiver sensitivity. | +| AM TUNE, 5% | Fresh positive output: 1.748 W in the development repeat and 1.399 W in the final repeat, SWR 1.0. | +| DIGU TUNE, 5% | Two first-TX-after-process-start trials established 6.993 W and SWR 1.0. The older intermittent initial DIGU failure was not reproduced. | +| Unkey | Explicit unkey succeeded after each burst; actual forward-power gauges read zero immediately and 0.7 seconds later. Final CW/AM repeats also used the stricter accepted-PTT-off confirmation. | +| Actual meter surfaces | Forward power, SWR and ALC gauges were sampled. Visible supply voltage was 14.894737 V, displayed +14.89 V, matching its native observation (263 ms age). PA-temperature and fan widgets were hidden; temperature was null/unsupported. | +| Process persistence | Manual SQL 27 and AGC Fast agreed in model and actual RX controls after normal Quit/new-process startup. Tracked readiness arrived at 2.513 seconds initially and 2.319 seconds after restart. | +| Final RX reconnect | The final backend-UUID build reached readiness at 2.418 and 2.357 seconds across same-process reconnect. Session generation changed from 2 to 5; the same backend UUID remained, and retained event IDs advanced from 1–66 to 202–267. A socket-free test separately verifies distinct backend-instance UUIDs. | + +These repeats establish the exercised paths, not a deterministic cause or fix +for the original missing-CW-power report or the original first-DIGU stop. Those +historical observations remain open. The unsupported two-tone label is a +confirmed client defect; the zero-carrier-gap stop is a confirmed harness defect. +No IC-705 or IC-9700 hardware was exercised. + +## Freshness baseline + +A no-action 12-second window retained 53 snapshots, all with the six tracked +fields ready. It collected 481 distinct transaction events: 128 initial history +entries plus 353 subsequent events, with no collector gaps. + +| Accepted field age (ms) | Median | p95 | Maximum | +|---|---:|---:|---:| +| AGC | 1471 | 2976 | 3217 | +| Frequency | 1050 | 2233 | 2618 | +| Mode/data/filter tuple | 983 | 2166 | 2484 | +| PTT | 132 | 234 | 250 | +| RF power | 1567 | 2983 | 3352 | +| SQL | 1418 | 2983 | 3448 | + +These are ages observed by the collector, not wire round-trip latency or +physical front-panel-to-display latency. They provide a baseline before any +scheduler optimization. Five seconds is the diagnostic readiness budget; +TX safety continues to require the tighter 500 ms meter/PTT evidence. + +## Restoration and test-process lessons + +The final comparison matched the settled original snapshot: 18 slice fields +and 12 transmit fields, including 7.224540 MHz LSB, Manual SQL 14 and AGC Med. +RF 100% and Tune 10% were restored only in a verified TX-disabled client. +ANT1 remained selected, tuner bypassed and VOX off. PTT-off was freshly +confirmed, the app quit normally, and the radio lock was released. + +An early startup snapshot showed fallback filter edges -3300…-300 Hz, whereas +the settled original was -3000…0 Hz. No filter recall was used to overwrite that +settled state. Six-field readiness must not be presented as full filter-width +readiness. During cleanup, setting AGC before a mode change was superseded by +the radio's mode-specific recall; restoration was corrected after each mode +confirmation (AM Slow, DIGU Med, LSB Med). Intermediate mismatches were retained. +The earlier expanded run's uncaptured hidden filter-bank definitions remain a +restoration limitation; this follow-up does not recover those missing originals. + +## Build and evidence attribution + +Live TX evidence used local base `7e6c4805481c73ae814210c83c15edd4c8721e8e` +with the P1 changes, executable SHA-256 +`d25c117c4716d013ba35bbe4c2da4532e5dd888a04345ae6dae008259d8361b3`. +The subsequent UUID addition was checked in RX-only reconnects using executable +SHA-256 `542df16d0fd6e1e2e724af31e5e114bd9df6fbe94eebc2cbaca14cfc79596176`. +The new PR is based on refreshed main after #5500 merged; integration build and +local test evidence are distinct: base `ac92489b`, modified tree, full macOS +build passed with `cmake --build build -j22`; eight selected headless CTests +passed in 1.97 seconds, including 17 TX safety cases. Integration executable +SHA-256 is `b1cdf9d88fbe6e46560a544ada6d324afaea6ec53484f4cb62e8b4f6674046bd`. +Engine-boundary, test-registration, bridge-doc, touchpoint-manifest and frozen +CI-gate checks passed locally. No post-transplant TX +or full Icom matrix is claimed. Publication review additionally tightened +aggregate sample qualification to the same post-key/500 ms rule; this has +socket-free coverage and was not a further live TX trial. + +Eight focused headless CTest selections passed before the transplant. Mutation +checks demonstrated failures when event IDs were made constant, context +invalidation was disabled, and the Icom two-tone refusal was removed; guards +were restored before the passing run. The new bridge regression injects an +inert backend and invokes the real dispatcher without sockets or firmware peers. + +Local evidence sets are `persist-p1-live`, `persist-p1-final` and +`persist-p1-identity`. Ordered observations, source scripts, snapshots, widget +samples, raw replies and hashes remain local. Credentials, network/session +identifiers, profiles and raw artifacts are not included in this report. diff --git a/src/core/AutomationServer.cpp b/src/core/AutomationServer.cpp index b20f1a53f..8842aa9d2 100644 --- a/src/core/AutomationServer.cpp +++ b/src/core/AutomationServer.cpp @@ -2199,6 +2199,37 @@ QString unreliableMeterNote(const QString& meterName, const QString& radioModel) return QString(); } +// A scalar constructor default is not a meter reading. Keep support, liveness +// and units beside the value for the low-rate vitals as well as the TX meters. +QJsonObject meterObservation(const QJsonArray& meters, const QString& name) +{ + QJsonObject selected; + bool supported = false; + for (const QJsonValue& item : meters) { + const QJsonObject row = item.toObject(); + if (row.value(QStringLiteral("name")).toString() != name + || row.value(QStringLiteral("source")).toString() == QLatin1String("AMP")) { + continue; + } + supported = true; + if (selected.isEmpty() || (row.value(QStringLiteral("has_value")).toBool() + && (!selected.value(QStringLiteral("has_value")).toBool() + || row.value(QStringLiteral("age_ms")).toDouble() + < selected.value(QStringLiteral("age_ms")).toDouble()))) { + selected = row; + } + } + const qint64 age = selected.value(QStringLiteral("age_ms")).toInteger(-1); + const bool fed = selected.value(QStringLiteral("has_value")).toBool() && age >= 0; + const bool fresh = fed && age < 1500; + return {{QStringLiteral("status"), !supported ? QStringLiteral("unsupported") + : !fed ? QStringLiteral("never-fed") + : fresh ? QStringLiteral("fresh") : QStringLiteral("stale")}, + {QStringLiteral("value"), fresh ? selected.value(QStringLiteral("value")) : QJsonValue()}, + {QStringLiteral("unit"), selected.value(QStringLiteral("unit"))}, + {QStringLiteral("ageMs"), age}}; +} + // Live meter readout. The flat convenience fields are the headline TX meters // with their freshness age (ms since last update, -1 if never) so a reader can // reject stale values — critical because some meters (notably PACURRENT) are @@ -2223,7 +2254,11 @@ QJsonObject metersSnapshot(MeterModel* m, const QString& radioModel) } } + const QJsonObject temperature = meterObservation(all, QStringLiteral("PATEMP")); + const QJsonObject voltage = meterObservation(all, QStringLiteral("+13.8A")); return QJsonObject{ + {QStringLiteral("temperature"), temperature}, + {QStringLiteral("voltage"), voltage}, {QStringLiteral("fwdPower"), m->fwdPower()}, // Watts (smoothed) {QStringLiteral("fwdPowerInstant"), m->fwdPowerInstant()}, // Watts (peak) {QStringLiteral("fwdPowerAgeMs"), age(m->fwdPowerUpdatedAtMs())}, @@ -2239,8 +2274,8 @@ QJsonObject metersSnapshot(MeterModel* m, const QString& radioModel) {QStringLiteral("swr"), m->swrIfLive() ? QJsonValue(*m->swrIfLive()) : QJsonValue()}, {QStringLiteral("swrAgeMs"), age(m->swrUpdatedAtMs())}, - {QStringLiteral("paTemp"), m->paTemp()}, // °C - {QStringLiteral("supplyVolts"), m->supplyVolts()}, // V + {QStringLiteral("paTemp"), temperature.value(QStringLiteral("value"))}, + {QStringLiteral("supplyVolts"), voltage.value(QStringLiteral("value"))}, {QStringLiteral("alc"), QJsonObject{ {QStringLiteral("value"), m->alcUpdatedAtMs() > 0 ? QJsonValue(m->alcValue()) : QJsonValue()}, {QStringLiteral("unit"), m->alcUnit()}, @@ -6842,6 +6877,11 @@ QJsonObject AutomationServer::doTxTest(const QString& action) return QJsonObject{{QStringLiteral("ok"), true}, {QStringLiteral("txtest"), QStringLiteral("off")}}; } if (action == QLatin1String("twotone")) { + // Icom setTune() generates one sine wave. The Flex tune_mode command + // has no Icom route; accepting this verb falsely certifies two-tone RF. + if (m_radioModel->family() == QLatin1String("icom")) { + return err(QStringLiteral("Icom two-tone generation is not implemented; use ordinary TUNE for a single tone")); + } if (!m_txAllowed) return err(QStringLiteral("blocked: txtest keys the transmitter — " "set AETHER_AUTOMATION_ALLOW_TX=1 to allow")); @@ -8437,6 +8477,8 @@ QJsonObject AutomationServer::doRadioCert(const QString& phaseArg, const QString {QStringLiteral("settingsDirectory"), SettingsPaths::configDir()}, {QStringLiteral("family"), m_radioModel->family()}, {QStringLiteral("clientSettingsDomains"), static_cast(caps.clientSettingsDomains)}, + {QStringLiteral("backendDiagnostics"), m_radioModel->family() == QLatin1String("icom") + ? doCiv(QStringLiteral("scheduler"), {}) : QJsonObject{}}, {QStringLiteral("radio"), radioSnapshot(m_radioModel)}, {QStringLiteral("slices"), doGet(QStringLiteral("slices"), {}, {}).value(QStringLiteral("slices"))}, {QStringLiteral("pans"), doGet(QStringLiteral("pans"), {}, {}).value(QStringLiteral("pans"))}, diff --git a/src/core/backends/icom/IcomCivBackend.cpp b/src/core/backends/icom/IcomCivBackend.cpp index 787a27171..0eb607219 100644 --- a/src/core/backends/icom/IcomCivBackend.cpp +++ b/src/core/backends/icom/IcomCivBackend.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include @@ -26,6 +27,11 @@ namespace AetherSDR::icom { namespace { +constexpr std::pair kTrackedStateFields[] = { + {"frequency", "frequencyHz"}, {"mode", "modeDataFilter"}, + {"civ.20.3", "squelchPercent"}, {"civ.22.18", "agcCode"}, + {"civ.20.10", "rfPowerPercent"}, {"ptt", "ptt"}}; + // The pan intents are the two that most need to say what they DECIDED rather // than what they were asked, because both of them deliberately do something // other than the literal request: one refuses, the other quantises. @@ -238,6 +244,7 @@ IcomCivBackend::IcomCivBackend(QObject* parent) // freeze — meters, controls, PTT poll and operator writes alike — // recoverable only by reconnecting. QElapsedTimer cannot step backwards. m_clock.start(); + m_diagnosticInstanceId = QUuid::createUuid().toString(QUuid::WithoutBraces); // TUNE is its own audio source. In particular it must keep producing when // PC Audio is disabled and AudioEngine has no capture callback to deliver. @@ -1000,6 +1007,8 @@ void IcomCivBackend::disconnectRadio() // they say across a reconnect. m_controlsValueKnown.clear(); m_controlsSeen.clear(); + m_confirmedState.clear(); + ++m_stateContext; m_controlsSent.clear(); m_controlsScheduled.clear(); m_framesObserved = 0; @@ -1887,6 +1896,7 @@ void IcomCivBackend::onCivFrame(const CivFrame& frame, // operator turns the dial; 0x03 is the answer to our poll. Same payload, // and both are the truth — which is why they share a case. if (auto hz = decodeFreq(frame.data)) { + confirmState(QStringLiteral("frequency"), QVariant::fromValue(*hz)); m_frequencyHz = *hz; SliceDelta s; s.frequency = static_cast(*hz) / 1e6; @@ -2097,6 +2107,7 @@ void IcomCivBackend::onCivFrame(const CivFrame& frame, return; } case level::kRfPower: { + confirmState(QStringLiteral("civ.20.10"), pct); m_txPowerPercent = pct; TransmitDelta t; t.rfPower = pct; emit transmitChanged(t); @@ -2136,6 +2147,7 @@ void IcomCivBackend::onCivFrame(const CivFrame& frame, return; } case level::kSquelch: { + confirmState(QStringLiteral("civ.20.3"), pct); m_squelchPercent = pct; SliceDelta d; d.squelchLevel = pct; @@ -2323,6 +2335,10 @@ void IcomCivBackend::onCivFrame(const CivFrame& frame, } case func::kAgc: { // 01 FAST, 02 MID, 03 SLOW. + if (frame.data.size() != 1 || v < 1 || v > 3) { + return; + } + confirmState(QStringLiteral("civ.22.18"), v); SliceDelta d; d.agcMode = v == 1 ? QStringLiteral("fast") : v == 3 ? QStringLiteral("slow") @@ -2393,6 +2409,8 @@ void IcomCivBackend::onCivFrame(const CivFrame& frame, const auto st = decodeVfoMode(frame.data); if (!st) return; + confirmState(QStringLiteral("mode"), QStringLiteral("%1/%2/%3") + .arg(static_cast(st->mode)).arg(st->dataMode).arg(st->filter)); const bool previousData = m_dataMode; m_mode = st->mode; m_dataMode = st->dataMode; @@ -2884,7 +2902,8 @@ void IcomCivBackend::onCivFrame(const CivFrame& frame, } case cmd::kControl: { - if (frame.hasSub && frame.sub == control::kPtt && !frame.data.empty()) { + if (frame.hasSub && frame.sub == control::kPtt && frame.data.size() == 1 + && frame.data[0] <= 1) { const bool keyed = frame.data[0] != 0; // A read can already be on the wire when the operator keys. Its // pre-write OFF answer then arrives after the newer ON request. @@ -2961,6 +2980,7 @@ void IcomCivBackend::onCivFrame(const CivFrame& frame, // Republishing unchanged state is never merely wasteful on a path // this hot: it is indistinguishable, to every consumer, from the // state having just changed. + confirmState(QStringLiteral("ptt"), keyed); if (keyed == m_keyed && !republishContradiction) { if (acceptedReadback) { emit keyingStateConfirmed(keyed); @@ -3472,6 +3492,19 @@ void IcomCivBackend::queueWrite(const std::vector& frame, request.priority = priority; request.expectsReply = true; request.acceptsGenericReply = true; + const QString stateKey = QString::fromStdString(request.key); + const auto parsed = parseFrame(frame); + if (stateKey == QLatin1String("mode") || stateKey == QLatin1String("frequency") + || (parsed && parsed->cmd == 0x07)) { // Official CI-V: VFO select/exchange. + ++m_stateContext; + } + for (const auto& [trackedKey, label] : kTrackedStateFields) { + Q_UNUSED(label); + if (stateKey == QLatin1String(trackedKey)) { + m_confirmedState[stateKey].pending = true; + break; + } + } request.supersedes = supersedes; request.coalesce = coalesce; m_civScheduler.enqueue(std::move(request), nowMs()); @@ -3528,10 +3561,66 @@ void IcomCivBackend::pumpCiv(qint64 nowMs) serviceSchedulerWaiters(nowMs); } +void IcomCivBackend::confirmState(const QString& key, const QVariant& value) +{ + // Called only after decode and stale-generation/PTT-intent rejection. + // ACKs, setters and control-map "seen" counters cannot confirm a value. + const auto previous = m_confirmedState.constFind(key); + if ((key == QLatin1String("frequency") || key == QLatin1String("mode")) + && previous != m_confirmedState.cend() && previous->value != value) { + ++m_stateContext; + } + m_confirmedState[key] = {value, nowMs(), m_sessionGeneration, m_stateContext, false}; +} + +QVariantMap IcomCivBackend::stateFreshness() const +{ + // Diagnostic budget, not a change to polling or a transmit permission. + constexpr qint64 kFreshMs = 5000; + QVariantMap fields; + bool ready = m_connected && m_civReported != 0 && !m_civAmbiguous; + for (const auto& [key, label] : kTrackedStateFields) { + const auto it = m_confirmedState.constFind(QString::fromLatin1(key)); + const bool known = it != m_confirmedState.cend() && it->value.isValid(); + const qint64 age = known ? std::max(0, nowMs() - it->atMs) : -1; + const bool current = known && m_connected && it->session == m_sessionGeneration + && it->context == m_stateContext; + const QString status = it != m_confirmedState.cend() && it->pending ? QStringLiteral("pending") + : !known ? QStringLiteral("never-confirmed") + : !current ? QStringLiteral("previous-context") + : age > kFreshMs ? QStringLiteral("stale") : QStringLiteral("confirmed"); + ready = ready && status == QLatin1String("confirmed"); + fields.insert(QString::fromLatin1(label), QVariantMap{ + {QStringLiteral("status"), status}, {QStringLiteral("ageMs"), age}, + {QStringLiteral("value"), known ? it->value : QVariant()}, + {QStringLiteral("semanticKey"), QString::fromLatin1(key)}}); + } + return {{QStringLiteral("backendInstanceId"), m_diagnosticInstanceId}, + {QStringLiteral("transportConnected"), m_connected}, + {QStringLiteral("identified"), m_civReported != 0 && !m_civAmbiguous}, + {QStringLiteral("trackedStateReady"), ready}, + {QStringLiteral("freshnessBudgetMs"), kFreshMs}, + {QStringLiteral("sessionGeneration"), QVariant::fromValue(m_sessionGeneration)}, + {QStringLiteral("contextGeneration"), QVariant::fromValue(m_stateContext)}, + {QStringLiteral("fields"), fields}, + {QStringLiteral("limitation"), QStringLiteral( + "Selected-VFO receive publications only; untracked fields have no freshness claim. " + "CI-V has no transaction IDs; delayed unsolicited replies cannot be correlated to physical intent. " + "Readiness is diagnostic, not TX authorization.")}}; +} + QVariantMap IcomCivBackend::schedulerDiagnostics() const { const IcomCivScheduler::Stats stats = m_civScheduler.stats(); QVariantMap out; + out.insert(QStringLiteral("backendInstanceId"), m_diagnosticInstanceId); + out.insert(QStringLiteral("stateFreshness"), stateFreshness()); + const auto& history = m_civScheduler.recentTransactions(); + out.insert(QStringLiteral("transactions"), schedulerTransactionTrace(128)); + out.insert(QStringLiteral("firstRetainedEventId"), + QVariant::fromValue(history.empty() ? 0 : history.front().eventId)); + out.insert(QStringLiteral("lastRetainedEventId"), + QVariant::fromValue(history.empty() ? 0 : history.back().eventId)); out.insert(QStringLiteral("idle"), m_civScheduler.idle()); out.insert(QStringLiteral("slotMs"), IcomCivScheduler::kSlotMs); out.insert(QStringLiteral("readTimeoutMs"), IcomCivScheduler::kReadTimeoutMs); @@ -3591,6 +3680,7 @@ QVariantList IcomCivBackend::schedulerTransactionTrace(std::size_t limit) const for (std::size_t i = begin; i < events.size(); ++i) { const IcomCivScheduler::TransactionEvent& event = events[i]; QVariantMap row; + row.insert(QStringLiteral("eventId"), QVariant::fromValue(event.eventId)); row.insert(QStringLiteral("key"), QString::fromStdString(event.key)); row.insert(QStringLiteral("priority"), priorityName(event.priority)); row.insert(QStringLiteral("generation"), diff --git a/src/core/backends/icom/IcomCivBackend.h b/src/core/backends/icom/IcomCivBackend.h index 5e5416992..54a93a7da 100644 --- a/src/core/backends/icom/IcomCivBackend.h +++ b/src/core/backends/icom/IcomCivBackend.h @@ -1,5 +1,7 @@ #pragma once +#include + #include #include #include @@ -289,6 +291,18 @@ private slots: [[nodiscard]] std::optional> confirmationFor(std::span frame) const; [[nodiscard]] QVariantMap schedulerDiagnostics() const; + void confirmState(const QString& key, const QVariant& value); + [[nodiscard]] QVariantMap stateFreshness() const; + struct ConfirmedState { + QVariant value; + qint64 atMs = -1; + std::uint64_t session = 0; + std::uint64_t context = 0; + bool pending = false; + }; + QString m_diagnosticInstanceId; + QMap m_confirmedState; + std::uint64_t m_stateContext = 0; [[nodiscard]] QVariantList schedulerTransactionTrace( std::size_t limit = 32) const; [[nodiscard]] QVariantMap incidentSnapshot(const QString& kind, diff --git a/src/core/backends/icom/IcomCivScheduler.cpp b/src/core/backends/icom/IcomCivScheduler.cpp index 26ecb48da..8a16767ef 100644 --- a/src/core/backends/icom/IcomCivScheduler.cpp +++ b/src/core/backends/icom/IcomCivScheduler.cpp @@ -11,6 +11,7 @@ void IcomCivScheduler::recordTransaction(const Queued& request, std::int64_t responseMs) { TransactionEvent event; + event.eventId = ++m_transactionEventId; event.key = request.request.key; event.priority = request.request.priority; event.generation = request.generation; diff --git a/src/core/backends/icom/IcomCivScheduler.h b/src/core/backends/icom/IcomCivScheduler.h index e8d93aab0..07fe83333 100644 --- a/src/core/backends/icom/IcomCivScheduler.h +++ b/src/core/backends/icom/IcomCivScheduler.h @@ -85,6 +85,7 @@ class IcomCivScheduler { }; struct TransactionEvent { + std::uint64_t eventId = 0; // Lifetime-unique, including after reset/history clear. std::string key; Priority priority = Priority::Control; std::uint64_t generation = 0; @@ -224,6 +225,7 @@ class IcomCivScheduler { // meters stays a delay rather than an indefinite hold. std::int64_t m_lastBackgroundDispatchMs = 0; Stats m_stats; + std::uint64_t m_transactionEventId = 0; std::deque m_recentTransactions; }; diff --git a/tests/automation_persist_diagnostics_test.cpp b/tests/automation_persist_diagnostics_test.cpp new file mode 100644 index 000000000..4e5059f8c --- /dev/null +++ b/tests/automation_persist_diagnostics_test.cpp @@ -0,0 +1,93 @@ +// Socket-free bridge contract: diagnostics must not turn missing data into proof. +#include "TestSettingsProfile.h" +#include "core/AudioEngine.h" +#include "core/QsoRecorder.h" +#include "core/AutomationServer.h" +#include "core/backends/IRadioBackend.h" +#include "models/RadioModel.h" +#include +#include +#include +#include +#include +namespace AetherSDR { +class AutomationServerTestAccess { +public: + static QJsonObject request(AutomationServer& server, const QByteArray& line) { + return server.handleLine(line, nullptr); + } +}; +} +using namespace AetherSDR; +class StubBackend : public IRadioBackend +{ +public: + explicit StubBackend(QVariantList chains) : m_chains(std::move(chains)) {} + + RadioCapabilities capabilities() const override { return {}; } + void connectRadio(const RadioConnectRequest&) override {} + void disconnectRadio() override {} + bool isConnected() const override { return false; } + void setSliceFrequency(int, double) override {} + void setSliceMode(int, const QString&) override {} + void setSliceFilter(int, int, int) override {} + void setSliceAgc(int, const QString&, int) override {} + void setPanCenter(const QString&, double, PanCenterIntent) override {} + // Deliberately inert. This test never calls it, and there is no wire behind + // it if it did. + void setKeying(bool) override {} + void invokeExtension(const QString&, const QString&, quint64 id, + const QVariant&) override { + emit extensionResult(id, QVariantMap{{"stateFreshness", QVariantMap{{"trackedStateReady", false}}}}); + } + + QVariantList dspChains() const override { return m_chains; } + +private: + QVariantList m_chains; +}; + + +int main(int argc, char** argv) { + TestSettingsProfile profile(QStringLiteral("automation-persist-diagnostics")); + qputenv("AETHER_AUTOMATION", "1"); + qputenv("AETHER_AUTOMATION_ALLOW_TX", "1"); + QCoreApplication app(argc, argv); + if (!profile.isValid()) { return 1; } + RadioModel radio; + AutomationServer server; + server.setRadioModel(&radio); + const auto request = [&](const QByteArray& line) { + return AutomationServerTestAccess::request(server, line); + }; + int failures = 0; + const auto check = [&](bool ok, const char* message) { + std::printf("%s %s\n", ok ? "PASS" : "FAIL", message); + if (!ok) { ++failures; } + }; + radio.setBackendForTest(std::make_unique(QVariantList{}), "icom"); + const auto persist = request("radiocert persist"); + check(persist.value("ok").toBool() && persist.value("backendDiagnostics").toObject() + .value("result").toObject().value("stateFreshness").toObject().contains("trackedStateReady"), + "persist carries backend confirmation evidence without replacing model state"); + const auto meters = [&]() { return request("get meters").value("meters").toObject(); }; + check(meters().value("paTemp").isNull() + && meters().value("temperature").toObject().value("status") == "unsupported", + "undefined temperature is null and unsupported, not zero degrees"); + MeterDef def; + def.index = 1; def.source = "RAD"; def.name = "PATEMP"; def.unit = "degC"; + radio.meterModel().defineMeter(def); + check(meters().value("paTemp").isNull() + && meters().value("temperature").toObject().value("status") == "never-fed", + "defined but never-fed temperature stays unknown"); + radio.meterModel().updateValueByName("RAD", "PATEMP", 0.0f); + check(meters().value("paTemp").isDouble() && meters().value("paTemp").toDouble() == 0 + && meters().value("temperature").toObject().value("status") == "fresh", + "a real zero-degree sample is distinguished from an absent reading"); + const auto twoTone = request("txtest twotone"); + check(!twoTone.value("ok").toBool() + && twoTone.value("error").toString().contains("not implemented") + && !radio.transmitModel().isTuning() && !radio.transmitModel().isMox(), + "Icom cannot label its single-tone generator as two-tone or key on refusal"); + return failures ? 1 : 0; +} diff --git a/tests/icom_civ_scheduler_test.cpp b/tests/icom_civ_scheduler_test.cpp index d2d974d4d..acf4177bb 100644 --- a/tests/icom_civ_scheduler_test.cpp +++ b/tests/icom_civ_scheduler_test.cpp @@ -500,6 +500,27 @@ int main() "failure accounting preserves each request identity and lifecycle state"); } + { + IcomCivScheduler scheduler; + for (int i = 0; i < 140; ++i) { + scheduler.enqueue(read("same", 0x15, 0x02, Priority::ActiveMeter), i * 30); + (void)scheduler.takeNext(i * 30); + (void)scheduler.observe(reply(0x15, 0x02, 0), i * 30 + 1); + } + const auto& events = scheduler.recentTransactions(); + check(events.size() == 128 && events.front().eventId == 13 + && events.back().eventId == 140, + "repeated same-generation polls have unique IDs across ring eviction"); + check(events.front().generation == events.back().generation, + "event identity is independent of semantic generation"); + scheduler.clearTransactionHistory(); + (void)scheduler.reset(); + scheduler.enqueue(read("same", 0x15, 0x02, Priority::ActiveMeter), 5000); + (void)scheduler.takeNext(5000); + (void)scheduler.observe(reply(0x15, 0x02, 0), 5001); + check(scheduler.recentTransactions().back().eventId == 141, + "history clear and scheduler reset never reuse event IDs"); + } if (g_failures == 0) { std::printf("icom_civ_scheduler_test: all checks passed\n"); return 0; diff --git a/tests/icom_incident_telemetry_test.cpp b/tests/icom_incident_telemetry_test.cpp index b4f4b67eb..d2b94a963 100644 --- a/tests/icom_incident_telemetry_test.cpp +++ b/tests/icom_incident_telemetry_test.cpp @@ -37,6 +37,19 @@ struct IcomCivBackendTestAccess { backend.onCivFrame(frame, generation); } + static QVariantMap freshness(const IcomCivBackend& backend) + { + return backend.stateFreshness(); + } + static void age(IcomCivBackend& backend, const QString& key) + { + backend.m_confirmedState[key].atMs = backend.nowMs() - 6000; + } + static void intent(IcomCivBackend& backend, const std::vector& frame) + { + backend.queueWrite(frame, {}, IcomCivScheduler::Priority::Operator, true, true); + } + static void identify(IcomCivBackend& backend) { backend.m_civReported = 0xA4; } static QVariantMap incident(const IcomCivBackend& backend) { return backend.m_lastIncident; @@ -141,5 +154,69 @@ int main(int argc, char** argv) check(confirmations.size() == 1 && !confirmations.front(), "only an accepted CI-V PTT-off readback publishes confirmation"); + IcomCivBackend freshBackend; + IcomCivBackendTestAccess::prepareAcceptedPttRead(freshBackend, *ic705, kGeneration); + IcomCivBackendTestAccess::identify(freshBackend); + const auto snapshot = [&]() { return IcomCivBackendTestAccess::freshness(freshBackend); }; + const auto field = [&](const char* name) { + return snapshot().value("fields").toMap().value(QLatin1String(name)).toMap(); + }; + check(!snapshot().value("trackedStateReady").toBool() + && field("squelchPercent").value("status") == "never-confirmed", + "transport and identity do not bless construction defaults"); + const auto deliver = [&](std::uint8_t command, bool hasSub, std::uint8_t sub, + std::vector data, std::uint64_t generation = 1) { + IcomCivBackendTestAccess::deliver(freshBackend, + CivFrame{kControllerAddress, ic705->civAddress, command, hasSub, sub, data}, generation); + }; + deliver(0x14, true, 0x03, {0xFA}); + deliver(0x14, true, 0x03, {0x00, 0x50}, 99); + check(field("squelchPercent").value("status") == "never-confirmed", + "malformed and previous-session frames cannot establish freshness"); + deliver(0x14, true, 0x03, {0x00, 0x51}); + check(field("squelchPercent").value("value").toInt() == 20 + && field("squelchPercent").value("status") == "confirmed", + "decoded SQL reply confirms the radio value"); + IcomCivBackendTestAccess::age(freshBackend, QStringLiteral("civ.20.3")); + check(field("squelchPercent").value("status") == "stale", + "unchanged values still age out"); + deliver(0x14, true, 0x03, {0x00, 0x51}); + check(field("squelchPercent").value("status") == "confirmed", + "unchanged valid replies refresh their own field"); + IcomCivBackendTestAccess::intent(freshBackend, cmdSetLevel(ic705->civAddress, level::kSquelch, 60)); + check(field("squelchPercent").value("status") == "pending", + "write intent cannot masquerade as radio confirmation"); + deliver(0xFB, false, 0, {}); + check(field("squelchPercent").value("status") == "pending", + "generic ACK cannot confirm a state value"); + deliver(0x14, true, 0x03, {0x00, 0x60}); + deliver(0x03, false, 0, {0x00, 0x00, 0x20, 0x07, 0x00}); + deliver(0x26, true, 0, {0x01, 0x00, 0x01}); + deliver(0x16, true, 0x12, {0x02}); + deliver(0x14, true, 0x0A, {0x00, 0x13}); + deliver(0x1C, true, 0, {0}); + check(snapshot().value("trackedStateReady").toBool(), + "all six decoded fields establish bounded diagnostic readiness"); + deliver(0x26, true, 0, {0x02, 0x00, 0x01}); + check(!snapshot().value("trackedStateReady").toBool() + && field("agcCode").value("status") == "previous-context", + "radio-originated mode change invalidates old-context controls"); + deliver(0x16, true, 0x12, {0xFF}); + check(field("agcCode").value("status") == "previous-context", + "out-of-range AGC cannot refresh the context"); + IcomCivBackendTestAccess::intent(freshBackend, + buildFrameSub(ic705->civAddress, 0x07, 0x01)); + check(field("modeDataFilter").value("status") == "previous-context", + "outgoing VFO selection invalidates even an identical mode and frequency"); + IcomCivBackend neverConfirmed; + IcomCivBackendTestAccess::intent(neverConfirmed, + cmdSetLevel(ic705->civAddress, level::kSquelch, 60)); + const QVariantMap unknownSql = IcomCivBackendTestAccess::freshness(neverConfirmed) + .value("fields").toMap().value("squelchPercent").toMap(); + check(unknownSql.value("status") == "pending" && !unknownSql.value("value").isValid(), + "a first write records pending intent without inventing a confirmed value"); + check(!snapshot().value("backendInstanceId").toString().isEmpty() + && snapshot().value("backendInstanceId") != IcomCivBackendTestAccess::freshness(neverConfirmed).value("backendInstanceId"), + "backend replacement in one process has a distinct diagnostic ID namespace"); return failures == 0 ? 0 : 1; } diff --git a/tests/tests.cmake b/tests/tests.cmake index 6a95ec6ec..85631551f 100644 --- a/tests/tests.cmake +++ b/tests/tests.cmake @@ -4267,6 +4267,11 @@ target_link_libraries(automation_dsp_backend_readback_test PRIVATE ) add_test(NAME automation_dsp_backend_readback_test COMMAND automation_dsp_backend_readback_test) +# Socket-free bridge diagnostics: injected backend and meter model, no server/peer. +add_executable(automation_persist_diagnostics_test tests/automation_persist_diagnostics_test.cpp) +target_include_directories(automation_persist_diagnostics_test PRIVATE src tests) +target_link_libraries(automation_persist_diagnostics_test PRIVATE aethercore Qt6::Core Qt6::Network) +add_test(NAME automation_persist_diagnostics_test COMMAND automation_persist_diagnostics_test) # Socket-free HL2 gain persistence: boardMaxRx bypasses discovery; the test # never pumps events and cancels DSP setup before it can start Metis UDP. add_executable(hl2_gain_restore_test tests/hl2_gain_restore_test.cpp) @@ -4638,6 +4643,7 @@ target_link_libraries(CAT_Flex_test PRIVATE Qt6::Core Qt6::Network) set(AETHER_SETTINGS_CONSUMERS rx_applet_squelch_reconciliation_test rtl_slice_settings_test + automation_persist_diagnostics_test weather_radar_loading_test hl2_gain_restore_test icom_identity_test @@ -4716,6 +4722,7 @@ endforeach() # library as AetherSDR so moving QtWidgets out of aethercore cannot silently # leave these harnesses with unresolved bridge symbols. set(AETHER_AUTOMATION_SERVER_TESTS + automation_persist_diagnostics_test automation_server_gesture_test automation_device_diagnostics_test automation_json_id_test diff --git a/tools/test_tx_meter_test.py b/tools/test_tx_meter_test.py index f39578ee3..7ac65af45 100644 --- a/tools/test_tx_meter_test.py +++ b/tools/test_tx_meter_test.py @@ -193,7 +193,76 @@ def request(self, request): "missing flags did not attempt semantic unkey") +def test_cw_swr_gap_requires_fresh_zero_carrier_and_prior_ratio(): + m = meter_snapshot(fwd=0, swr=None, fwd_age=50, swr_age=80) + check(subject.swr_gap_is_observed(m, 1000, True), "fresh zero-carrier gap rejected") + check(not subject.swr_gap_is_observed(m, 1000, False), "never-established SWR accepted") + for key, value in (("fwdPowerInstant", 1), ("fwdPowerInstant", float("nan")), + ("fwdPowerAgeMs", 600), ("swrAgeMs", 600), ("swrAgeMs", -1)): + changed = {**m, key: value} + check(not subject.swr_gap_is_observed(changed, 1000, True), changed) + + +def test_icom_unkey_requires_new_confirmed_ptt_off(): + from unittest.mock import patch + class IcomBridge: + def __init__(self, ptt): self.ptt = ptt + def request(self, request): + if request.get("cmd") == "get": + return {"ok": True, "value": "icom:test" if request.get("property") == "serial" else False} + return {"ok": True, "result": {"stateFreshness": {"fields": {"ptt": self.ptt}}}} + with patch.object(subject.time, "sleep"): + for state in ({}, {"status": "pending", "value": False, "ageMs": 0}, + {"status": "confirmed", "value": True, "ageMs": 0}, + {"status": "confirmed", "value": False, "ageMs": 400}): + check(not subject.Tx(IcomBridge(state)).ensure_unkeyed(), state) + check(subject.Tx(IcomBridge({"status": "confirmed", "value": False, "ageMs": 0})).ensure_unkeyed(), + "new confirmed PTT-off was rejected") + + +def test_native_meter_reporting(): + meters = meter_snapshot() + meters.update(paTemp=0, swAlc=-42) + missing = subject.reported_meter(meters, "PATEMP") + check(missing["status"] == "unsupported" and missing["value"] is None, missing) + meters["all"].append({"name": "ALC", "source": "TX", "unit": "Percent", + "has_value": True, "age_ms": 0, "value": 63.5}) + result = run_once(FakeTx(meters)) + check(result["alc"] == 63.5 and result["alcUnit"] == "Percent", result) + check(result["paTemp"] is None, result) + row = meters["all"][-1] + row["has_value"] = False + check(subject.reported_meter(meters, "ALC")["status"] == "never-fed", row) + row["has_value"] = True + row["age_ms"] = 2000 + check(subject.reported_meter(meters, "ALC")["status"] == "stale", row) + row["reliable"] = False + check(subject.reported_meter(meters, "ALC")["status"] == "unreliable", row) + + +def test_previous_burst_sample_cannot_satisfy_safety(): + from unittest.mock import patch + # Age 400ms looks fresh under the old 500ms check, but predates this key. + with patch.object(subject, "POWER_SAMPLE_DEADLINE_S", 0): + result = run_once(FakeTx(meter_snapshot(fwd_age=400, swr_age=400)), max_watts=10) + check("no fresh calibrated FWDPWR" in result["stopReason"], result) + check(result["fwd"] is None and result["swr"] is None, "prior-burst samples leaked into aggregates") + + +def test_old_swr_cannot_qualify_a_later_carrier_gap(): + from unittest.mock import patch + with patch.object(subject.time, "monotonic", side_effect=[1, 1, 1.1, 2]): + result = subject.sample_window(FakeTx(meter_snapshot(swr_age=600)), dur=1.5, settle=-1, keyed_at=0) + check(result["swr"] is None and result["peakSwr"] is None, result) + check(result["stopReason"] == "no fresh SWR sample before safety deadline", result) + + if __name__ == "__main__": + test_cw_swr_gap_requires_fresh_zero_carrier_and_prior_ratio() + test_icom_unkey_requires_new_confirmed_ptt_off() + test_native_meter_reporting() + test_previous_burst_sample_cannot_satisfy_safety() + test_old_swr_cannot_qualify_a_later_carrier_gap() test_over_watt_unkeys() test_high_swr_unkeys() test_missing_power_unkeys() diff --git a/tools/tx_meter_test.py b/tools/tx_meter_test.py index b80e6ebb4..aaa45871b 100644 --- a/tools/tx_meter_test.py +++ b/tools/tx_meter_test.py @@ -68,11 +68,24 @@ def mox(self): return bool(self.g("transmit", "mox")) def txing(self): return bool(self.g("radio", "transmitting")) def ensure_unkeyed(self): + started = time.monotonic() for _ in range(8): flags = [self.g("transmit", field) for field in ("tuning", "mox", "transmitting")] flags.append(self.g("radio", "transmitting")) if all(value is False for value in flags): - return True + serial = self.g("radio", "serial") + if not isinstance(serial, str) or not serial.startswith("icom:"): + return True + # Icom model flags may lead the CI-V OFF reply. Require a new + # accepted publication in this observation window, not an old + # idle baseline. Polling/ACKs alone cannot release the guard. + diagnostics = self.cmd(cmd="civ", action="scheduler").get("result", {}) + ptt = diagnostics.get("stateFreshness", {}).get("fields", {}).get("ptt", {}) + age = ptt.get("ageMs") + if (ptt.get("status") == "confirmed" and ptt.get("value") is False + and type(age) in (int, float) and 0 <= age < SAFETY_FRESH_MS + and age <= (time.monotonic() - started) * 1000): + return True # Semantic unkey is always allowed and survives applet redesigns; # do not depend only on finding a particular MOX widget. self.cmd(cmd="key", action="ptt", value="off") @@ -108,7 +121,43 @@ def atu_bypassed(self): return st in ("bypass", "manual_bypass"), st -def sample_window(tx, dur=1.4, settle=0.2, max_watts=None, max_swr=2.5, guard=None): +def reported_meter(meters, name, *, max_age_ms=FRESH_MS): + """Use declared producer rows, never scalar defaults or legacy conversions.""" + rows = [row for row in meters.get("all", []) if row.get("name") == name + and row.get("source") != "AMP"] + if not rows: + return {"status": "unsupported", "value": None, "unit": None, "ageMs": None} + if any(row.get("reliable") is False for row in rows): + return {"status": "unreliable", "value": None, "unit": None, "ageMs": None} + fed = [row for row in rows if row.get("has_value") is True + and type(row.get("age_ms")) in (int, float) and row["age_ms"] >= 0 + and type(row.get("value")) in (int, float) and math.isfinite(row["value"])] + if not fed: + return {"status": "never-fed", "value": None, "unit": rows[0].get("unit"), "ageMs": None} + row = min(fed, key=lambda item: item["age_ms"]) + fresh = row["age_ms"] < max_age_ms + return {"status": "fresh" if fresh else "stale", + "value": row["value"] if fresh else None, + "unit": row.get("unit"), "ageMs": row["age_ms"]} + + +def swr_gap_is_observed(meters, elapsed_ms, had_qualified_swr): + """A zero-carrier gap hides the ratio, but must still have fresh telemetry. + + MeterModel intentionally nulls SWR without forward power. This does not + excuse missing replies, positive-power/no-ratio samples, or a burst that + never established a meaningful SWR. No timing budget changes here. + """ + power = meters.get("fwdPowerInstant") + power_age = meters.get("fwdPowerAgeMs") + swr_age = meters.get("swrAgeMs") + return (had_qualified_swr and type(power) in (int, float) and power == 0 + and all(type(age) in (int, float) and 0 <= age < SAFETY_FRESH_MS + and age <= elapsed_ms for age in (power_age, swr_age))) + + +def sample_window(tx, dur=1.4, settle=0.2, max_watts=None, max_swr=2.5, guard=None, + keyed_at=None): """Key-down meter sampling. Collect fresh fwd/swr/temp/volts and the freshest PACURRENT/ALC over the window. Returns a dict of aggregates + freshness.""" fwd, swr, temp, volts, alc = [], [], [], [], [] @@ -117,7 +166,9 @@ def sample_window(tx, dur=1.4, settle=0.2, max_watts=None, max_swr=2.5, guard=No micp = [] stop_reason = None peaks, samples = [], [] - t0 = time.monotonic() + t0 = time.monotonic() if keyed_at is None else keyed_at + meter_reports = {} + alc_unit = None while time.monotonic() - t0 < dur: elapsed = time.monotonic() - t0 if elapsed > settle: @@ -129,10 +180,11 @@ def sample_window(tx, dur=1.4, settle=0.2, max_watts=None, max_swr=2.5, guard=No fwd_val = m.get("fwdPower", 0) instant = m.get("fwdPowerInstant") fresh_peak = (type(instant) in (int, float) and math.isfinite(instant) - and type(fwd_age) in (int, float) and 0 <= fwd_age < SAFETY_FRESH_MS) + and type(fwd_age) in (int, float) and 0 <= fwd_age < SAFETY_FRESH_MS + and fwd_age <= elapsed * 1000) if fresh_peak: peaks.append(instant) - if 0 <= fwd_age < FRESH_MS and fwd_val > 0.3: + if fresh_peak and fwd_val > 0.3: fwd.append(fwd_val) if max_watts is not None and fresh_peak and instant > max_watts: stop_reason = (f"measured forward power {instant:.1f} W exceeds " @@ -142,31 +194,41 @@ def sample_window(tx, dur=1.4, settle=0.2, max_watts=None, max_swr=2.5, guard=No # and -1 must not pass a < FRESH_MS check (#4536). swr_age = m.get("swrAgeMs", 1e9) swr_val = m.get("swr") - if 0 <= swr_age < FRESH_MS and swr_val is not None: + if 0 <= swr_age < min(SAFETY_FRESH_MS, elapsed * 1000) and swr_val is not None: swr.append(swr_val) if swr_val > max_swr: stop_reason = f"SWR {swr_val:.2f} exceeds {max_swr:.2f} ceiling" - temp.append(m.get("paTemp", 0)); volts.append(m.get("supplyVolts", 0)) - alc.append(m.get("swAlc", -150)) + for name, values in (("PATEMP", temp), ("+13.8A", volts), ("ALC", alc)): + report = reported_meter(m, name, max_age_ms=min(FRESH_MS, elapsed * 1000)) + meter_reports[name] = report + if report["value"] is not None: + if name == "ALC": + if alc_unit is not None and alc_unit != report["unit"]: + raise RuntimeError("ALC unit changed during TX window") + alc_unit = report["unit"] + values.append(report["value"]) pv, pa, prel = Tx.meter_from_all(m, "PACURRENT") if not prel: pacur_reliable = False - if pv is not None and pa is not None and pa < FRESH_MS: + if pv is not None and pa is not None and pa < min(FRESH_MS, elapsed * 1000): if pacur is None or pv > pacur[0]: pacur = (pv, pa) - micp.append(m.get("micPeak", -150)) + if elapsed >= POWER_SAMPLE_DEADLINE_S and (not fresh_peak or not any( - 0 <= x.get("age_ms", -1) < SAFETY_FRESH_MS + 0 <= x.get("age_ms", -1) < min(SAFETY_FRESH_MS, elapsed * 1000) and x.get("name") == "FWDPWR" and x.get("has_value") and str(x.get("unit", "")).lower() in ("dbm", "w", "watts") and x.get("reliable") is not False for x in m.get("all", []))): stop_reason = "no fresh calibrated FWDPWR sample before safety deadline" - if elapsed >= POWER_SAMPLE_DEADLINE_S and (swr_val is None or not 0 <= swr_age < SAFETY_FRESH_MS): + if (elapsed >= POWER_SAMPLE_DEADLINE_S + and (swr_val is None or not 0 <= swr_age < min(SAFETY_FRESH_MS, elapsed * 1000)) + and not swr_gap_is_observed(m, elapsed * 1000, bool(swr))): stop_reason = stop_reason or "no fresh SWR sample before safety deadline" if not stop_reason and guard: stop_reason = guard() - samples.append({"elapsed": elapsed, "meters": m, "connected": link_alive}) + samples.append({"elapsed": elapsed, "meters": m, "connected": link_alive, + "swrCarrierGap": swr_val is None and swr_gap_is_observed(m, elapsed * 1000, bool(swr))}) if stop_reason: tx.cmd(cmd="txtest", action="off") tx.ensure_unkeyed() @@ -183,6 +245,8 @@ def sample_window(tx, dur=1.4, settle=0.2, max_watts=None, max_swr=2.5, guard=No "paTemp": round(max(temp), 1) if temp else None, "volts": round(statistics.median(volts), 2) if volts else None, "alc": round(max(alc), 1) if alc else None, + "alcUnit": alc_unit, + "meterReports": meter_reports, "paCurrent": pa_out, "n_fwd": len(fwd), "stopReason": stop_reason, @@ -315,18 +379,19 @@ def main(): args.max_control, widgets=True) if problem or not tx.ensure_unkeyed(): raise RuntimeError(problem or "unkey state could not be established") - tx.inv("Tune", "click") - t0 = time.monotonic() - while time.monotonic() - t0 < 1.5 and not tx.tuning(): - time.sleep(0.05) - agg = sample_window(tx, max_watts=args.max_watts, max_swr=args.max_swr, guard=guard) + keyed_at = time.monotonic() + response = tx.inv("Tune", "click") + if response.get("ok") is not True: + raise RuntimeError("Tune command failed") + agg = sample_window(tx, max_watts=args.max_watts, max_swr=args.max_swr, + guard=guard, keyed_at=keyed_at) tx.cmd(cmd="txtest", action="off") tx.cmd(cmd="key", action="ptt", value="off") ok_unkey = tx.ensure_unkeyed() agg.update(set=tp, unkeyed=ok_unkey, atu=tx.g("transmit", "atuStatus")) rows.append(agg) print(f"{tp:>5} {str(agg['fwd']):>6} {str(agg['swr']):>5} {str(agg['paCurrent']):>7} " - f"{str(agg['paTemp']):>7} {str(agg['volts']):>6} {str(agg['alc']):>7} {agg['n_fwd']:>3}") + f"{str(agg['paTemp']):>7} {str(agg['volts']):>6} {str(agg['alc']):>7} {agg['alcUnit']} {agg['n_fwd']:>3}") if not ok_unkey: print(" *** UNKEY FAILED — ABORT ***"); abort = True; break if agg["stopReason"]: @@ -348,15 +413,13 @@ def main(): args.max_control, widgets=True) if problem or not tx.ensure_unkeyed(): raise RuntimeError(problem or "unkey state could not be established") + keyed_at = time.monotonic() r = tx.cmd(cmd="txtest", action="twotone") if r.get("ok"): - t0 = time.monotonic() - while time.monotonic() - t0 < 1.5 and not tx.txing() and not tx.tuning(): - time.sleep(0.05) agg = sample_window(tx, dur=1.2, max_watts=args.max_watts, - max_swr=args.max_swr, guard=guard) + max_swr=args.max_swr, guard=guard, keyed_at=keyed_at) tx.cmd(cmd="txtest", action="off"); ok = tx.ensure_unkeyed() - print(f" two-tone: fwd={agg['fwd']}W swr={agg['swr']} ALC={agg['alc']}dBFS " + print(f" two-tone: fwd={agg['fwd']}W swr={agg['swr']} ALC={agg['alc']} {agg['alcUnit']} " f"PAcur={agg['paCurrent']} unkeyed={ok}") rows.append({"set": "two-tone", **agg, "unkeyed": ok}) if agg["stopReason"]: