diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttSubscriptionManager.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttSubscriptionManager.cs index 8a4ed603..4d28f0da 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttSubscriptionManager.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttSubscriptionManager.cs @@ -755,6 +755,10 @@ public sealed class MqttSubscriptionManager // sink write) per message. The cache is still freshened — a read must serve the current // timestamp — and only the notification is skipped. "Cached but not raised" is already a valid // state on this path (see Raise: an authored-but-unsubscribed tag does exactly that). + // + // Sparkplug bypasses this entirely. SparkplugIngestor.cs updates Values and raises directly + // (its own Values.Update()/Raise() calls, not through this Deliver method), so Sparkplug B + // ingestion gets no unchanged-value suppression — deliberately out of scope here. var prior = Values.Read(def.Name); var unchanged = prior.StatusCode == snapshot.StatusCode && !ValuesAreDifferent(prior.Value, snapshot.Value); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttSubscriptionManagerTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttSubscriptionManagerTests.cs index 6ee9255a..558d4477 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttSubscriptionManagerTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttSubscriptionManagerTests.cs @@ -1032,6 +1032,13 @@ public sealed class MqttSubscriptionManagerTests /// DegradeTopic: a Good value followed by a refused subscription must reach the /// subscriber as Bad, or the client keeps a stale Good forever. /// + /// + /// This pins the DegradeTopic path specifically — DegradeTopic's own + /// Raise call was already unconditional before the suppression change and is untouched + /// by it. It does NOT exercise the prior.StatusCode == snapshot.StatusCode comparison + /// added inside ; see + /// for that. + /// [Fact] public async Task A_status_transition_still_raises() { @@ -1057,6 +1064,39 @@ public sealed class MqttSubscriptionManagerTests mgr.Values.Read(OvenTempPath).StatusCode.ShouldBe(BadCommunicationError); } + /// + /// Pins the status-code half of Deliver's suppression comparison + /// (prior.StatusCode == snapshot.StatusCode) through the LIVE + /// path, which + /// does not reach (that test goes through the + /// unconditional DegradeTopic path instead). Two different malformed payloads on the + /// SAME topic each decode to a null value but a DIFFERENT Bad status code — a decode + /// failure (BadDecodingError) followed by a value that decodes but fails type coercion + /// (BadTypeMismatch). A value-only comparison (ignoring status) would see + /// null == null on both transitions and wrongly suppress the second. + /// + [Fact] + public async Task Different_malformed_payloads_with_the_same_null_value_both_raise() + { + var mgr = NewManager(); + mgr.Register([Tag(OvenTempPath, $$"""{"topic":"{{OvenTopic}}","payloadFormat":"Json","jsonPath":"$.value","dataType":"Float64"}""")]); + await mgr.SubscribeAsync([OvenTempPath], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + + var raised = new List(); + mgr.OnDataChange += (_, e) => raised.Add(e.Snapshot); + + // Decode failure: not valid JSON at all. + mgr.HandleMessage(OvenTopic, "{not json"u8.ToArray(), retained: false); + // Decodes fine, but the selected value does not fit the declared Float64 type. + mgr.HandleMessage(OvenTopic, """{"value":"not-a-number"}"""u8.ToArray(), retained: false); + + raised.Count.ShouldBe(2); + raised[0].StatusCode.ShouldBe(BadDecodingError); + raised[0].Value.ShouldBeNull(); + raised[1].StatusCode.ShouldBe(BadTypeMismatch); + raised[1].Value.ShouldBeNull(); + } + /// Records every subscribe call so dedupe / re-subscribe can be asserted without a broker. private sealed class FakeTransport : IMqttSubscribeTransport {