using System.Buffers; using System.Collections.Concurrent; using System.Text; using MQTTnet; using Org.Eclipse.Tahu.Protobuf; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using TahuDataType = Org.Eclipse.Tahu.Protobuf.DataType; namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests; /// /// Live validation of MQTT write-through (Gitea #508) against the real Mosquitto fixture: a Plain /// publish to the tag's write topic, and Sparkplug NCMD/DCMD commands, observed on the broker by an /// independent subscriber. /// /// /// /// Observed on the broker, not on a fake. The whole risk this feature carries is a write that /// reports Good and reaches nothing, so the assertion has to be that a real message arrived on a /// real broker at the topic the operator authored. A test double asserting "publish was called" /// could not distinguish the feature working from the feature publishing to the wrong topic. /// /// /// Env-gated + skip-clean, like every suite here: without MQTT_FIXTURE_ENDPOINT and /// credentials each test calls Assert.Skip. See . /// /// [Collection(MqttFixtureCollection.Name)] [Trait("Category", "LiveIntegration")] public sealed class MqttWriteLiveTests(MqttFixture fixture) { private const uint Good = 0x00000000u; private const uint BadNotWritable = 0x803B0000u; private static readonly TimeSpan MessageTimeout = TimeSpan.FromSeconds(30); private readonly MqttFixture _fx = fixture; private void SkipIfUnconfigured() => Assert.SkipWhen(_fx.SkipReason is not null, _fx.SkipReason ?? ""); // ----------------------------------------------------------------------- // Plain // ----------------------------------------------------------------------- [Fact] public async Task Plain_write_publishes_the_value_on_the_authored_write_topic() { SkipIfUnconfigured(); var ct = TestContext.Current.CancellationToken; const string rawPath = "Fixture/Mqtt/Line1/Setpoint"; var readTopic = _fx.Topic($"write/{Guid.NewGuid():N}/state"); var writeTopic = _fx.Topic($"write/{Guid.NewGuid():N}/set"); await using var watcher = await TopicWatcher.StartAsync(_fx, writeTopic, ct); var tag = new RawTagEntry( rawPath, $$"""{"topic":"{{readTopic}}","writeTopic":"{{writeTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""", WriteIdempotent: false); await using var driver = new MqttDriver(PlainOptions(tag), "mqtt-live-write"); await driver.InitializeAsync(string.Empty, ct); var results = await driver.WriteAsync([new WriteRequest(rawPath, 42.5)], ct); results.ShouldHaveSingleItem().StatusCode.ShouldBe(Good); var payload = await watcher.WaitForOneAsync(MessageTimeout, ct); Encoding.UTF8.GetString(payload).ShouldBe( "42.5", "the write must arrive on the authored write topic carrying the value, in invariant form"); } [Fact] public async Task Plain_write_never_lands_on_the_tags_read_topic() { SkipIfUnconfigured(); var ct = TestContext.Current.CancellationToken; const string rawPath = "Fixture/Mqtt/Line1/SetpointSplit"; var suffix = Guid.NewGuid().ToString("N"); var readTopic = _fx.Topic($"write/{suffix}/state"); var writeTopic = _fx.Topic($"write/{suffix}/set"); // Both watchers are live BEFORE the write, so "nothing on the read topic" is a real observation // over the same window rather than an absence that was never looked for. await using var readWatcher = await TopicWatcher.StartAsync(_fx, readTopic, ct); await using var writeWatcher = await TopicWatcher.StartAsync(_fx, writeTopic, ct); var tag = new RawTagEntry( rawPath, $$"""{"topic":"{{readTopic}}","writeTopic":"{{writeTopic}}","payloadFormat":"Scalar","dataType":"Float64"}""", WriteIdempotent: false); await using var driver = new MqttDriver(PlainOptions(tag), "mqtt-live-write-split"); await driver.InitializeAsync(string.Empty, ct); (await driver.WriteAsync([new WriteRequest(rawPath, 7.25)], ct))[0].StatusCode.ShouldBe(Good); // Positive control first: once the write HAS been seen, the read topic has had its chance. await writeWatcher.WaitForOneAsync(MessageTimeout, ct); readWatcher.Count.ShouldBe( 0, "a write echoed onto the tag's own read topic would read back as a confirmed device value"); } [Fact] public async Task Plain_write_to_a_tag_with_no_write_topic_is_refused_end_to_end() { SkipIfUnconfigured(); var ct = TestContext.Current.CancellationToken; const string rawPath = "Fixture/Mqtt/Line1/ReadOnly"; var tag = new RawTagEntry( rawPath, $$"""{"topic":"{{_fx.Topic("line1/temperature")}}","payloadFormat":"Json","jsonPath":"$.value","dataType":"Float64"}""", WriteIdempotent: false); await using var driver = new MqttDriver(PlainOptions(tag), "mqtt-live-write-readonly"); await driver.InitializeAsync(string.Empty, ct); // Connected and resolvable — so BadNotWritable here is specifically "you never said where", // not a connection failure wearing the same status. (await driver.WriteAsync([new WriteRequest(rawPath, 1.0)], ct))[0].StatusCode.ShouldBe(BadNotWritable); } // ----------------------------------------------------------------------- // Sparkplug B // ----------------------------------------------------------------------- [Fact] public async Task Sparkplug_write_to_a_device_metric_publishes_a_DCMD() { SkipIfUnconfigured(); var ct = TestContext.Current.CancellationToken; var group = $"WriteGate{Guid.NewGuid():N}"[..20]; const string node = "EdgeW"; const string device = "Filler1"; const string metric = "Setpoint"; const string rawPath = "Fixture/Mqtt/Sp/Setpoint"; var topic = $"spBv1.0/{group}/DCMD/{node}/{device}"; await using var watcher = await TopicWatcher.StartAsync(_fx, topic, ct); var tag = new RawTagEntry( rawPath, $$"""{"groupId":"{{group}}","edgeNodeId":"{{node}}","deviceId":"{{device}}","metricName":"{{metric}}","dataType":"Float64"}""", WriteIdempotent: false); await using var driver = new MqttDriver(SparkplugOptions(group, tag), "mqtt-live-write-dcmd"); await driver.InitializeAsync(string.Empty, ct); (await driver.WriteAsync([new WriteRequest(rawPath, 12.5)], ct))[0].StatusCode.ShouldBe(Good); var bytes = await watcher.WaitForOneAsync(MessageTimeout, ct); var payload = Payload.Parser.ParseFrom(bytes); payload.Metrics.ShouldHaveSingleItem(); payload.Metrics[0].Name.ShouldBe(metric, "a command must address the metric by NAME, never by alias"); payload.Metrics[0].Datatype.ShouldBe((uint)TahuDataType.Double); payload.Metrics[0].DoubleValue.ShouldBe(12.5); payload.HasSeq.ShouldBeFalse("seq belongs to the edge node's own outbound stream, not to a command"); } [Fact] public async Task Sparkplug_write_to_an_edge_node_metric_publishes_an_NCMD() { SkipIfUnconfigured(); var ct = TestContext.Current.CancellationToken; var group = $"WriteGate{Guid.NewGuid():N}"[..20]; const string node = "EdgeW"; const string metric = "NodeSetpoint"; const string rawPath = "Fixture/Mqtt/Sp/NodeSetpoint"; await using var watcher = await TopicWatcher.StartAsync(_fx, $"spBv1.0/{group}/NCMD/{node}", ct); var tag = new RawTagEntry( rawPath, $$"""{"groupId":"{{group}}","edgeNodeId":"{{node}}","metricName":"{{metric}}","dataType":"Int32"}""", WriteIdempotent: false); await using var driver = new MqttDriver(SparkplugOptions(group, tag), "mqtt-live-write-ncmd"); await driver.InitializeAsync(string.Empty, ct); (await driver.WriteAsync([new WriteRequest(rawPath, 7)], ct))[0].StatusCode.ShouldBe(Good); // NCMD is a SHARED channel: the driver publishes its own rebirth request // ("Node Control/Rebirth") on this exact topic when it connects, and the live gate caught it // arriving first. Wait for the command carrying OUR metric rather than for the first NCMD — // the DCMD case has no such contention, which is why only this one needed it. var payload = Payload.Parser.ParseFrom( await watcher.WaitForMatchAsync( bytes => Payload.Parser.ParseFrom(bytes).Metrics.Any(m => m.Name == metric), MessageTimeout, ct)); var written = payload.Metrics.ShouldHaveSingleItem(); written.Name.ShouldBe(metric); written.Datatype.ShouldBe((uint)TahuDataType.Int32); written.IntValue.ShouldBe(7u); } [Fact] public async Task A_sparkplug_command_is_not_retained() { SkipIfUnconfigured(); var ct = TestContext.Current.CancellationToken; var group = $"WriteGate{Guid.NewGuid():N}"[..20]; const string node = "EdgeW"; const string rawPath = "Fixture/Mqtt/Sp/Retain"; var topic = $"spBv1.0/{group}/NCMD/{node}"; await using var firstWatcher = await TopicWatcher.StartAsync(_fx, topic, ct); var tag = new RawTagEntry( rawPath, $$"""{"groupId":"{{group}}","edgeNodeId":"{{node}}","metricName":"Cmd","dataType":"Boolean"}""", WriteIdempotent: false); await using var driver = new MqttDriver(SparkplugOptions(group, tag), "mqtt-live-write-retain"); await driver.InitializeAsync(string.Empty, ct); (await driver.WriteAsync([new WriteRequest(rawPath, true)], ct))[0].StatusCode.ShouldBe(Good); await firstWatcher.WaitForOneAsync(MessageTimeout, ct); // A retained command would be replayed by the broker to every LATER subscriber — including the // device's own reconnect — so a single stale write would be re-applied forever. Subscribing // fresh, after the publish, is the only way to observe that. await using var lateWatcher = await TopicWatcher.StartAsync(_fx, topic, ct); await Task.Delay(TimeSpan.FromSeconds(2), ct); // Counting OUR command only. The driver's own rebirth NCMD shares this topic, so a bare count // would make this assertion depend on rebirth timing rather than on the retain flag. lateWatcher.CountMatching(bytes => Payload.Parser.ParseFrom(bytes).Metrics.Any(m => m.Name == "Cmd")) .ShouldBe(0, "a retained NCMD/DCMD would drive a device into a permanent command loop"); } // ----------------------------------------------------------------------- // Helpers // ----------------------------------------------------------------------- private MqttDriverOptions PlainOptions(params RawTagEntry[] rawTags) => new() { Host = _fx.PlainHost, Port = _fx.PlainPort, UseTls = false, Username = _fx.Username, Password = _fx.Password, ClientId = $"otopcua-live-{Guid.NewGuid():N}", ConnectTimeoutSeconds = 15, Mode = MqttMode.Plain, Plain = new MqttPlainOptions { DefaultQos = 1 }, RawTags = rawTags, }; private MqttDriverOptions SparkplugOptions(string groupId, params RawTagEntry[] rawTags) => new() { Host = _fx.PlainHost, Port = _fx.PlainPort, UseTls = false, Username = _fx.Username, Password = _fx.Password, ClientId = $"otopcua-live-{Guid.NewGuid():N}", ConnectTimeoutSeconds = 15, Mode = MqttMode.SparkplugB, Sparkplug = new MqttSparkplugOptions { GroupId = groupId }, RawTags = rawTags, }; /// /// An independent broker subscriber that records the payloads seen on one concrete topic — the /// third party that makes "the write actually reached the broker" an observation rather than an /// assumption about the driver's own code. /// private sealed class TopicWatcher : IAsyncDisposable { private readonly IMqttClient _client; private readonly ConcurrentQueue _payloads = new(); private TopicWatcher(IMqttClient client) => _client = client; /// How many messages have been observed on the watched topic. public int Count => _payloads.Count; public static async Task StartAsync(MqttFixture fixture, string topic, CancellationToken ct) { var client = new MqttClientFactory().CreateMqttClient(); var watcher = new TopicWatcher(client); client.ApplicationMessageReceivedAsync += args => { watcher._payloads.Enqueue(args.ApplicationMessage.Payload.ToArray()); return Task.CompletedTask; }; var options = new MqttClientOptionsBuilder() .WithTcpServer(fixture.PlainHost, fixture.PlainPort) .WithCredentials(fixture.Username, fixture.Password) .WithClientId($"otopcua-live-watch-{Guid.NewGuid():N}") .WithTimeout(TimeSpan.FromSeconds(10)) .Build(); await client.ConnectAsync(options, ct).ConfigureAwait(false); await client.SubscribeAsync( new MqttClientSubscribeOptionsBuilder().WithTopicFilter(f => f.WithTopic(topic)).Build(), ct).ConfigureAwait(false); return watcher; } /// How many observed messages satisfy . /// The payload test. /// The matching count. public int CountMatching(Func predicate) => _payloads.Count(p => Safe(predicate, p)); /// /// Waits for a message satisfying . Needed where the topic is /// shared — an NCMD topic also carries the driver's own rebirth requests. /// /// The payload test. /// Bound on the wait. /// Caller cancellation. /// The first matching payload. public async Task WaitForMatchAsync(Func predicate, TimeSpan timeout, CancellationToken ct) { var deadline = DateTime.UtcNow + timeout; while (true) { if (_payloads.FirstOrDefault(p => Safe(predicate, p)) is { } match) { return match; } if (DateTime.UtcNow >= deadline) { throw new TimeoutException( $"No matching message arrived on the watched topic within {timeout} " + $"({_payloads.Count} message(s) seen, none matching)."); } await Task.Delay(100, ct).ConfigureAwait(false); } } /// Applies a payload predicate, treating an undecodable payload as "no match". /// The payload test. /// The observed bytes. /// when the payload matches. private static bool Safe(Func predicate, byte[] payload) { try { return predicate(payload); } catch { return false; } } public async Task WaitForOneAsync(TimeSpan timeout, CancellationToken ct) { var deadline = DateTime.UtcNow + timeout; while (!_payloads.TryPeek(out _)) { if (DateTime.UtcNow >= deadline) { throw new TimeoutException( $"No message arrived on the watched topic within {timeout}. The write reported success " + "but nothing reached the broker — the exact failure this gate exists to catch."); } await Task.Delay(100, ct).ConfigureAwait(false); } _payloads.TryDequeue(out var payload); return payload!; } public async ValueTask DisposeAsync() { try { await _client.DisconnectAsync(cancellationToken: default).ConfigureAwait(false); } catch { /* teardown must not mask a test failure */ } _client.Dispose(); } } }