Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions src/core/backends/flex/FlexBackend.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -938,10 +938,11 @@ void FlexBackend::decodeAtuStatus(const QMap<QString, QString>& kvs)
void FlexBackend::decodeAmplifierStatus(const QString& handle, const QString& model,
const QMap<QString, QString>& kvs, bool removed)
{
// Stateless translation of the SmartSDR "amplifier <handle> …" 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 <handle> …" 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;
Expand All @@ -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
Comment thread
rfoust marked this conversation as resolved.
// 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")) {
Expand Down
29 changes: 22 additions & 7 deletions src/models/AmpModel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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);
Expand Down
14 changes: 14 additions & 0 deletions tests/aetherd_amp_decode_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
45 changes: 43 additions & 2 deletions tests/amp_model_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -51,19 +51,60 @@ int main(int argc, char** argv)
{
AmpModel amp;
QSignalSpy presence(&amp, &AmpModel::presenceChanged);
amp.applyChanges(detected("0x1000", "PowerGeniusXL", "192.168.1.50", false));
QSignalSpy state(&amp, &AmpModel::stateChanged);
bool operateAtPresence = false;
bool stateWasDeferred = false;
QObject::connect(&amp, &AmpModel::presenceChanged, &amp,
[&amp, &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(&amp, &AmpModel::presenceChanged, &amp,
[&amp, &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(&amp, &AmpModel::stateChanged);
QSignalSpy telemetry(&amp, &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;
Expand Down
Loading