diff --git a/src/core/backends/flex/FlexBackend.cpp b/src/core/backends/flex/FlexBackend.cpp index 0bbd7a659..06431cc5c 100644 --- a/src/core/backends/flex/FlexBackend.cpp +++ b/src/core/backends/flex/FlexBackend.cpp @@ -938,10 +938,11 @@ void FlexBackend::decodeAtuStatus(const QMap& kvs) void FlexBackend::decodeAmplifierStatus(const QString& handle, const QString& model, const QMap& kvs, bool removed) { - // Stateless translation of the SmartSDR "amplifier …" wire → AmpDelta - // (#4094). The presence latch, operate change-gating, and handle matching are - // the model's job (AmpModel::applyChanges) — this only reports what the wire - // said. Command/encode is the reverse path — invokeExtension("flex", + // Translation of the SmartSDR "amplifier …" wire → AmpDelta + // (#4094). Placeholder handles are normalized here so the vendor-neutral + // model never needs SmartSDR sentinel knowledge. The presence latch, operate + // change-gating, and handle matching are the model's job + // (AmpModel::applyChanges). Command/encode is the reverse path — invokeExtension("flex", // "amp.operate", …) below translates AmpModel's neutral intent (#4094). AmpDelta d; d.handle = handle; @@ -955,14 +956,18 @@ void FlexBackend::decodeAmplifierStatus(const QString& handle, const QString& mo emit amplifierChanged(d); return; } + if (handle == QLatin1String("0x00000000")) { + d.handle.clear(); + } // RadioModel routes only power amps (PGXL) into this decode, so the handle is // the amp's — cache it for the encode path (#4198). Ignore the placeholder // handle a first status can carry before the real one is assigned. Defense in // depth (#4203): a pre-existing routing edge — a model-less TGXL status arriving // before its handle is known — can fall through to here; refuse to cache a // known-tuner handle so a later amp.operate can never mis-target the TGXL. - if (!handle.isEmpty() && handle != QLatin1String("0x00000000") && handle != m_tunerHandle) - m_ampHandle = handle; + if (!d.handle.isEmpty() && d.handle != m_tunerHandle) { + m_ampHandle = d.handle; + } // A non-empty, non-TGXL model marks a power amp (PGXL); the TunerGeniusXL is // the tuner and routes to TunerModel, not here. if (!model.isEmpty() && model != QLatin1String("TunerGeniusXL")) { diff --git a/src/models/AmpModel.cpp b/src/models/AmpModel.cpp index 7214423ca..1ad8e04e4 100644 --- a/src/models/AmpModel.cpp +++ b/src/models/AmpModel.cpp @@ -17,8 +17,24 @@ void AmpModel::applyChanges(const AmpDelta& d) } // Presence latch: a detected (non-TGXL) power-amp model marks us present. - if (d.detectedModel) { + if (d.detectedModel && !d.handle.isEmpty()) { m_handle = d.handle; + } + + const bool appliesToAmp = d.detectedModel.has_value() + || (!m_handle.isEmpty() && d.handle == m_handle); + bool stateDidChange = false; + if (appliesToAmp) { + // Apply state before publishing first presence. The presence signal + // makes the applet visible and reads operate() immediately; publishing + // first used to paint a real operating PGXL as STANDBY during startup. + if (d.operate && m_operate != *d.operate) { + m_operate = *d.operate; + stateDidChange = true; + } + } + + if (d.detectedModel) { if (!m_present) { m_present = true; // Strict parity with the prior applyStatus (m_ip = kvs.value("ip"), @@ -29,12 +45,11 @@ void AmpModel::applyChanges(const AmpDelta& d) } } - if (!m_handle.isEmpty() && d.handle == m_handle) { - // Operate is change-gated; a status without a "state" leaves it as-is. - if (d.operate && m_operate != *d.operate) { - m_operate = *d.operate; - emit stateChanged(); - } + if (stateDidChange) { + emit stateChanged(); + } + + if (appliesToAmp) { // Forward telemetry (drain current, mains voltage, meffa, temp, …) so // the GUI updates without a direct PGXL TCP connection. emit telemetryUpdated(d.telemetry); diff --git a/tests/aetherd_amp_decode_test.cpp b/tests/aetherd_amp_decode_test.cpp index 33e81cf64..3eec3c072 100644 --- a/tests/aetherd_amp_decode_test.cpp +++ b/tests/aetherd_amp_decode_test.cpp @@ -75,6 +75,20 @@ int main(int argc, char** argv) CHECK(!d.detectedModel.has_value() && !d.operate.has_value()); } + // ---- SmartSDR's startup placeholder is hidden from the neutral model ---- + { + const AmpDelta d = decode(b, "0x00000000", "PowerGeniusXL", + {{"state", "OPERATE"}}, false); + CHECK(d.handle.isEmpty()); + CHECK(d.detectedModel.has_value() && *d.detectedModel == "PowerGeniusXL"); + CHECK(d.operate.has_value() && *d.operate == true); + + // Removal keeps the wire handle so cache/removal matching retains its + // existing semantics; only live status is normalized. + const AmpDelta removed = decode(b, "0x00000000", QString(), {}, true); + CHECK(removed.removed && removed.handle == "0x00000000"); + } + // ---- #4203: a known-tuner handle mis-routed into the amp decode must NOT be // cached as m_ampHandle. Observe via the encode path: with no amp handle // cached, amp.operate fails closed (extensionError), never targeting the TGXL. diff --git a/tests/amp_model_test.cpp b/tests/amp_model_test.cpp index 7988d58eb..67f6a35de 100644 --- a/tests/amp_model_test.cpp +++ b/tests/amp_model_test.cpp @@ -51,19 +51,60 @@ int main(int argc, char** argv) { AmpModel amp; QSignalSpy presence(&, &AmpModel::presenceChanged); - amp.applyChanges(detected("0x1000", "PowerGeniusXL", "192.168.1.50", false)); + QSignalSpy state(&, &AmpModel::stateChanged); + bool operateAtPresence = false; + bool stateWasDeferred = false; + QObject::connect(&, &AmpModel::presenceChanged, &, + [&, &state, &operateAtPresence, &stateWasDeferred](bool present) { + if (present) { + operateAtPresence = amp.operate(); + stateWasDeferred = state.count() == 0; + } + }); + amp.applyChanges(detected("0x1000", "PowerGeniusXL", "192.168.1.50", true)); CHECK(amp.present()); CHECK(amp.handle() == "0x1000"); CHECK(amp.ip() == "192.168.1.50"); CHECK(amp.modelName() == "PowerGeniusXL"); - CHECK(!amp.operate()); + CHECK(amp.operate() && operateAtPresence && stateWasDeferred); CHECK(presence.count() == 1 && presence.takeFirst().at(0).toBool() == true); + CHECK(state.count() == 1); // A second detect does not re-latch ip/model or re-emit presence. amp.applyChanges(detected("0x1000", "PowerGeniusXL", "10.0.0.9", true)); CHECK(amp.ip() == "192.168.1.50"); // unchanged CHECK(presence.count() == 0); } + // ---- unidentified detection cannot adopt a model-less TGXL handle ---- + { + AmpModel amp; + bool operateAtPresence = false; + QObject::connect(&, &AmpModel::presenceChanged, &, + [&, &operateAtPresence](bool present) { + if (present) { + operateAtPresence = amp.operate(); + } + }); + // FlexBackend normalizes the SmartSDR placeholder to an empty handle. + amp.applyChanges(detected(QString(), "PowerGeniusXL", + "192.168.1.50", true)); + CHECK(amp.present() && amp.handle().isEmpty()); + CHECK(amp.operate() && operateAtPresence); + + QSignalSpy state(&, &AmpModel::stateChanged); + QSignalSpy telemetry(&, &AmpModel::telemetryUpdated); + amp.applyChanges(update("0x2000", false, {{"state", "STANDBY"}})); + CHECK(amp.handle().isEmpty()); + CHECK(amp.operate() && state.count() == 0 && telemetry.count() == 0); + + // A later model-bearing PGXL status safely establishes identity and + // applies its state; model-less updates can only match after that. + amp.applyChanges(detected("0x1000", "PowerGeniusXL", QString(), false, + {{"state", "STANDBY"}})); + CHECK(amp.handle() == "0x1000"); + CHECK(!amp.operate() && state.count() == 1 && telemetry.count() == 1); + } + // ---- a delta with no detectedModel + unknown handle is a no-op (TGXL case) ---- { AmpModel amp;