Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/MqttWriteLiveTests.cs
T
Joseph Doherty 72be19225d feat(mqtt): write-through (IWritable) — Plain publish + Sparkplug NCMD/DCMD (#508)
MqttDriver implemented no IWritable, so every MQTT node materialized read-only
regardless of what the operator authored. It now publishes.

  Plain      -> the tag's writeTopic; UTF-8 text (Raw/Scalar) or a bare JSON
                literal (Json)
  Sparkplug  -> DCMD for a device metric, NCMD for an edge-node metric, carrying
                the metric BY NAME with an explicit Datatype and no seq

Both at QoS 0, retain:false. Retain is deliberately not configurable — a retained
command is replayed by the broker on every future subscribe, including the
device's own reconnect, so one stale write would be re-applied forever.

writeTopic is a new, separate TagConfig key, and its ABSENCE is meaningful: the
write is refused BadNotWritable and the node is discovered ViewOnly. The issue's
"do not skip" warning drove the design — the one outcome worse than read-only
nodes is a node that looks writable, returns Good and publishes nothing. Reusing
the read topic would be worse still: it would echo back and read as a confirmed
device value. Browse-time writability is computed by the SAME encoder the write
path uses, so a node cannot advertise writability it would then refuse.

Good means published, not applied — MQTT has no application-level write ack, so
this is Galaxy's optimistic-Good posture and inherits its property: a failed
DEVICE write can never be surfaced. A failed PUBLISH is reported Bad; the
optimism covers what the device did with the command, never whether it was sent.
WriteIdempotent stays default-off.

The encoder is pure and lives in .Contracts, so both wire shapes are testable
without a broker. Unrepresentable values and types with no Sparkplug encoding are
refused rather than coerced — a Reference must not silently become a String on
the wire, and "not-a-number" must not become 0 on a live plant.

LIVE GATE PASSED against the real Mosquitto fixture on 10.100.0.35: 21/21
integration tests, including 6 new ones that observe the published message with
an INDEPENDENT broker subscriber — a fake asserting "publish was called" could
not tell the feature working from the feature publishing to the wrong topic.

The gate earned its keep: the NCMD test initially failed because NCMD is a SHARED
channel — the driver publishes its own rebirth request (Node Control/Rebirth) on
the very topic an edge-node metric write uses, and it arrived first. Correct
driver behaviour, wrong test; the watcher now matches on metric name. Documented,
because anything consuming that topic needs to know. DCMD has no contention.

Offline: 24 encoder + 9 driver tests. The four new status constants are NAMED so
StatusCodeParityTests can see them (#497/#519) — it went 258 -> 262 and validated
each against the pinned SDK.

Also corrects the Mqtt.md "Known gaps" table: prunes the six rows closed in this
round, and fixes the #507 row, which still described rediscovery as inert after
that issue was closed as superseded and its injection path deleted.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-07-31 06:33:23 -04:00

388 lines
17 KiB
C#

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;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// <para>
/// <b>Observed on the broker, not on a fake.</b> 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.
/// </para>
/// <para>
/// <b>Env-gated + skip-clean</b>, like every suite here: without <c>MQTT_FIXTURE_ENDPOINT</c> and
/// credentials each test calls <c>Assert.Skip</c>. See <see cref="MqttFixture"/>.
/// </para>
/// </remarks>
[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,
};
/// <summary>
/// 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.
/// </summary>
private sealed class TopicWatcher : IAsyncDisposable
{
private readonly IMqttClient _client;
private readonly ConcurrentQueue<byte[]> _payloads = new();
private TopicWatcher(IMqttClient client) => _client = client;
/// <summary>How many messages have been observed on the watched topic.</summary>
public int Count => _payloads.Count;
public static async Task<TopicWatcher> 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;
}
/// <summary>How many observed messages satisfy <paramref name="predicate"/>.</summary>
/// <param name="predicate">The payload test.</param>
/// <returns>The matching count.</returns>
public int CountMatching(Func<byte[], bool> predicate) => _payloads.Count(p => Safe(predicate, p));
/// <summary>
/// Waits for a message satisfying <paramref name="predicate"/>. Needed where the topic is
/// shared — an NCMD topic also carries the driver's own rebirth requests.
/// </summary>
/// <param name="predicate">The payload test.</param>
/// <param name="timeout">Bound on the wait.</param>
/// <param name="ct">Caller cancellation.</param>
/// <returns>The first matching payload.</returns>
public async Task<byte[]> WaitForMatchAsync(Func<byte[], bool> 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);
}
}
/// <summary>Applies a payload predicate, treating an undecodable payload as "no match".</summary>
/// <param name="predicate">The payload test.</param>
/// <param name="payload">The observed bytes.</param>
/// <returns><see langword="true"/> when the payload matches.</returns>
private static bool Safe(Func<byte[], bool> predicate, byte[] payload)
{
try { return predicate(payload); }
catch { return false; }
}
public async Task<byte[]> 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();
}
}
}