767e7031b3
Adds a project-owned, controllable Sparkplug B edge node (SparkplugEdgeNode) that
encodes with the SAME generated Tahu schema the driver decodes with — so the live
gate checks encode/decode symmetry rather than the driver against itself — and
drives the §3.6 matrix end-to-end over the real TLS+auth Mosquitto fixture.
The simulator frames its topics independently of SparkplugTopic.Format: a
simulator borrowing the parser's formatter could not detect a bug in it, because
both sides of the comparison would be wrong together. It registers a real NDEATH
Last Will at CONNECT, restarts seq at 0 on NBIRTH, publishes DATA metrics as
alias-only, carries bdSeq per session, and encodes signed ints as two's
complement in the unsigned proto field.
SparkplugLiveTests (Category=LiveIntegration, env-gated, skip-clean) covers:
birth -> alias-only data -> OnDataChange under the RawPath; late-join rebirth
NCMD honoured by an independent decoder; alias reuse across a rebirth routing by
metric NAME; a stale-bdSeq NDEATH ignored while the current one stales; the real
broker-published Will staling and the next birth restoring; seq wrap 255->0
requesting no rebirth while a deliberate gap does; and the browser's passive
window asserted ON THE WIRE (a third client watching spBv1.0/{group}/NCMD/#),
plus RequestRebirthAsync node-vs-group enumeration.
Two things make the suite falsifiable rather than decorative: the seq-wrap test
builds its ingestor with rebirthDebounce: TimeSpan.Zero (at the shipped 10s
default a wrap-triggered NCMD would be swallowed and the test would pass for the
wrong reason) and pairs the silence with a deliberate gap that must produce one;
and the passivity assertion observes the broker rather than the session's own
publish counter, which can only see the seam it guards.
Docker/docker-compose.yml gains a profile-gated `sparkplug-sim` service running
the same engine standalone against the same broker (TLS, CA-pinned) — a manual
driving aid for the AdminUI picker, deliberately NOT a test dependency, since the
matrix needs an edge node it can command mid-test. Its app directory is publish
output (publish-simulator.sh), gitignored like secrets/.
Offline: 15 skipped, 0 failed with no env set. Live: 15/15 passed against
10.100.0.35:8883. Falsifiability spot-check: mutating AliasTable.RebuildFromBirth
to merge instead of replace, and neutering SparkplugCodec.ReinterpretSigned for
Int32, turned exactly the two corresponding tests red and nothing else; both
mutations reverted.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
980 lines
44 KiB
C#
980 lines
44 KiB
C#
using System.Buffers;
|
||
using System.Collections.Concurrent;
|
||
using System.Text.Json;
|
||
using MQTTnet;
|
||
using Org.Eclipse.Tahu.Protobuf;
|
||
using Shouldly;
|
||
using Xunit;
|
||
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser;
|
||
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
|
||
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.SparkplugSimulator;
|
||
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests;
|
||
|
||
/// <summary>
|
||
/// The Sparkplug B live gate: the §3.6 state-machine matrix driven end-to-end over the real
|
||
/// Mosquitto fixture, against a real edge node (<see cref="SparkplugEdgeNode"/>) that encodes with
|
||
/// the same generated Tahu schema the driver decodes with.
|
||
/// <para>
|
||
/// <b>What the live gate adds over the offline suite.</b> Every case here already has an
|
||
/// offline pin driven through <c>SparkplugIngestor.Dispatch</c> with hand-built decoded
|
||
/// payloads. Those prove the <i>logic</i>. They cannot prove the <b>wire</b>: that a negative
|
||
/// Int32 really does arrive as an unsigned two's-complement <c>int_value</c>, that a DATA
|
||
/// metric really does carry an alias and nothing else, that an NDEATH really is a broker-issued
|
||
/// Will with no <c>seq</c>, or that the NCMD this driver encodes is one an independent decoder
|
||
/// accepts as a rebirth request. A hand-built <c>SparkplugPayload</c> shaped by the same
|
||
/// assumptions as the decoder cannot falsify any of that; a byte stream through a broker can.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Env-gated + skip-clean</b>, exactly as <see cref="PlainMqttLiveTests"/>: without
|
||
/// <c>MQTT_FIXTURE_ENDPOINT</c> (and credentials) every test calls <c>Assert.Skip</c>. See
|
||
/// <see cref="MqttFixture"/> for the env-var list.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Every test uses its own Sparkplug group id.</b> The fixture broker is shared and the
|
||
/// driver subscribes <c>spBv1.0/{group}/#</c>; a fixed group would let one test's edge node
|
||
/// feed another's driver, and — worse — let a previous run's traffic satisfy an assertion.
|
||
/// </para>
|
||
/// </summary>
|
||
[Collection(MqttFixtureCollection.Name)]
|
||
[Trait("Category", "LiveIntegration")]
|
||
public sealed class SparkplugLiveTests(MqttFixture fixture)
|
||
{
|
||
/// <summary>OPC UA <c>Good</c>.</summary>
|
||
private const uint Good = 0x00000000u;
|
||
|
||
/// <summary>
|
||
/// OPC UA <c>BadCommunicationError</c> — the quality the ingest state machine stamps on a tag
|
||
/// whose source announced it is offline (NDEATH/DDEATH).
|
||
/// </summary>
|
||
private const uint BadCommunicationError = 0x80050000u;
|
||
|
||
/// <summary>
|
||
/// Bound on every wait for broker traffic. Generous enough to absorb a TLS handshake plus a
|
||
/// rebirth round-trip; short enough that a genuinely dark path fails rather than hangs.
|
||
/// </summary>
|
||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30);
|
||
|
||
/// <summary>
|
||
/// How long a "this must NOT happen" assertion watches for. Long enough that the message would
|
||
/// have arrived (a broker round-trip on this rig is single-digit milliseconds), short enough not
|
||
/// to dominate the suite.
|
||
/// </summary>
|
||
private static readonly TimeSpan NegativeWindow = TimeSpan.FromSeconds(4);
|
||
|
||
private readonly MqttFixture _fx = fixture;
|
||
|
||
// -----------------------------------------------------------------------
|
||
// birth → data → alias resolve → OnDataChange (+ §3.6 case iv: negative Int32)
|
||
// -----------------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// The spine of the driver: an NBIRTH/DBIRTH builds the alias table, an alias-only NDATA/DDATA
|
||
/// resolves through it, and the value is published under the tag's <b>RawPath</b> — the v3
|
||
/// driver wire reference, not the Sparkplug tuple and not the topic.
|
||
/// <para>
|
||
/// It simultaneously carries <b>§3.6 matrix case (iv), the negative Int32</b>. The metric's
|
||
/// value of −1234 travels as <c>int_value = 4294966062</c> because that is what Sparkplug
|
||
/// says; a driver that skipped <c>ReinterpretSigned</c> would publish 4294966062 at Good
|
||
/// quality — a plausible number, a wrong one, and invisible to any assertion that only
|
||
/// checks "a value arrived".
|
||
/// </para>
|
||
/// </summary>
|
||
[Fact]
|
||
public async Task Birth_then_alias_only_data_publishes_under_rawpath_with_signed_ints_intact()
|
||
{
|
||
SkipIfUnconfigured();
|
||
|
||
var ct = TestContext.Current.CancellationToken;
|
||
var group = NewGroup("birth");
|
||
const string node = "EdgeA";
|
||
const string device = "Filler1";
|
||
|
||
const string tempPath = "Fixture/Sparkplug/EdgeA/Temperature";
|
||
const string countPath = "Fixture/Sparkplug/EdgeA/Count";
|
||
const string levelPath = "Fixture/Sparkplug/EdgeA/Filler1/Level";
|
||
|
||
await using var sim = new SparkplugEdgeNode(SimOptions(group, node));
|
||
sim.SetNodeMetrics(
|
||
new SparkplugMetricSeed("Temperature", 1, DataType.Float, 21.5f),
|
||
new SparkplugMetricSeed("Count", 2, DataType.Int32, -42));
|
||
sim.SetDeviceMetrics(device, new SparkplugMetricSeed("Level", 10, DataType.Double, 3.5d));
|
||
|
||
await sim.ConnectAsync(ct);
|
||
await sim.BirthAsync(ct);
|
||
|
||
await using var driver = new MqttDriver(
|
||
SparkplugOptions(
|
||
group,
|
||
Tag(tempPath, group, node, null, "Temperature"),
|
||
Tag(countPath, group, node, null, "Count"),
|
||
Tag(levelPath, group, node, device, "Level")),
|
||
"mqtt-live-sp-birth");
|
||
var recorder = new SparkplugRecorder(driver);
|
||
|
||
await driver.InitializeAsync(string.Empty, ct);
|
||
await driver.SubscribeAsync([tempPath, countPath, levelPath], TimeSpan.FromSeconds(1), ct);
|
||
|
||
// The birth is a full snapshot, so these are the BIRTH's own values.
|
||
var birthTemp = await recorder.WaitAsync(tempPath, v => Equals(v.Value, 21.5f), Timeout, ct);
|
||
birthTemp.FullReference.ShouldBe(
|
||
tempPath,
|
||
"the driver must publish under the tag's RawPath, never the Sparkplug tuple or the topic");
|
||
birthTemp.Snapshot.StatusCode.ShouldBe(Good);
|
||
|
||
await recorder.WaitAsync(countPath, v => Equals(v.Value, -42), Timeout, ct);
|
||
await recorder.WaitAsync(levelPath, v => Equals(v.Value, 3.5d), Timeout, ct);
|
||
|
||
// …and now DATA: alias only, no name, no datatype — the shape a bind-by-alias driver survives
|
||
// and a bind-by-name driver must too.
|
||
await sim.PublishNodeDataAsync([("Temperature", 33.25f), ("Count", -1234)], ct);
|
||
await sim.PublishDeviceDataAsync(device, [("Level", 9.75d)], ct);
|
||
|
||
var temp = await recorder.WaitAsync(tempPath, v => Equals(v.Value, 33.25f), Timeout, ct);
|
||
temp.Snapshot.StatusCode.ShouldBe(Good);
|
||
|
||
// The predicate names the DATA value, not just "an int arrived": the birth already delivered a
|
||
// negative Int32 (−42), so a laxer wait would be satisfied by the earlier message and the
|
||
// assertion below would compare the wrong notification.
|
||
var count = await recorder.WaitAsync(countPath, v => Equals(v.Value, -1234), Timeout, ct);
|
||
count.Snapshot.StatusCode.ShouldBe(Good);
|
||
count.Snapshot.Value.ShouldBe(
|
||
-1234,
|
||
"a Sparkplug Int32 rides as two's complement in the UNSIGNED int_value field (−1234 goes on "
|
||
+ "the wire as 4294966062); without ReinterpretSigned that is what would be published, at "
|
||
+ "Good quality");
|
||
|
||
await recorder.WaitAsync(levelPath, v => Equals(v.Value, 9.75d), Timeout, ct);
|
||
|
||
// The read path serves the same last values under the same keys.
|
||
var read = await driver.ReadAsync([tempPath, countPath, levelPath], ct);
|
||
read.Select(r => r.StatusCode).ShouldAllBe(s => s == Good);
|
||
read[1].Value.ShouldBe(-1234);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// rebirth recovery (§3.6 #5)
|
||
// -----------------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// Late join: the driver connects to a node that has already birthed and gone quiet, so it holds
|
||
/// no alias table at all. It must request a rebirth, and the request must be one a real edge
|
||
/// node accepts.
|
||
/// <para>
|
||
/// <b>This is the NCMD encode/decode symmetry check.</b> The driver's
|
||
/// <c>RebirthRequester.Build</c> output is decoded here by the simulator — an independent
|
||
/// reader that looks for a Boolean metric literally named <c>Node Control/Rebirth</c> set to
|
||
/// true. Offline tests assert the driver's own bytes against the driver's own expectations;
|
||
/// only this one proves the command means what the spec says to a party that did not write it.
|
||
/// </para>
|
||
/// </summary>
|
||
[Fact]
|
||
public async Task Late_join_requests_a_rebirth_the_edge_node_honours_and_metadata_recovers()
|
||
{
|
||
SkipIfUnconfigured();
|
||
|
||
var ct = TestContext.Current.CancellationToken;
|
||
var group = NewGroup("rebirth");
|
||
const string node = "EdgeA";
|
||
const string rawPath = "Fixture/Sparkplug/Rebirth/Temperature";
|
||
|
||
await using var sim = new SparkplugEdgeNode(SimOptions(group, node));
|
||
sim.SetNodeMetrics(new SparkplugMetricSeed("Temperature", 7, DataType.Double, 64.5d));
|
||
|
||
// Connected and listening for NCMD, but DELIBERATELY not birthed: the driver joins a stream it
|
||
// has no metadata for, which is exactly the state a restart or a long outage leaves it in.
|
||
await sim.ConnectAsync(ct);
|
||
|
||
await using var driver = new MqttDriver(
|
||
SparkplugOptions(group, Tag(rawPath, group, node, null, "Temperature")),
|
||
"mqtt-live-sp-rebirth");
|
||
var recorder = new SparkplugRecorder(driver);
|
||
|
||
await driver.InitializeAsync(string.Empty, ct);
|
||
await driver.SubscribeAsync([rawPath], TimeSpan.FromSeconds(1), ct);
|
||
|
||
var honoured = await sim.WaitForRebirthAsync(1, Timeout, ct);
|
||
honoured.ShouldBeGreaterThanOrEqualTo(
|
||
1,
|
||
"the driver must issue a late-join rebirth NCMD on connect (§3.6 #5), and it must be one a "
|
||
+ "spec-reading edge node recognises");
|
||
|
||
var change = await recorder.WaitAsync(rawPath, v => Equals(v.Value, 64.5d), Timeout, ct);
|
||
change.Snapshot.StatusCode.ShouldBe(
|
||
Good,
|
||
"the rebirth's NBIRTH is a full snapshot — its values are what restores the tag");
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// §3.6 case (i) — alias reuse across a rebirth
|
||
// -----------------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// <b>The single most dangerous Sparkplug defect, on the wire.</b> An edge node may reassign an
|
||
/// alias to a <i>different</i> metric across a rebirth — legal, and something real edge nodes do
|
||
/// whenever their metric set changes. A driver that bound its tags to aliases keeps routing by
|
||
/// the old mapping and publishes Pressure's value into the Temperature tag: Good quality,
|
||
/// entirely plausible values, wrong tag, and nothing anywhere reports an error.
|
||
/// <para>
|
||
/// The test swaps aliases 5 and 6 between Temperature and Pressure at the rebirth, then
|
||
/// publishes a DATA message carrying <b>alias 5 only</b>. Bind-by-name puts it in Pressure;
|
||
/// bind-by-alias puts it in Temperature. The assertion checks both tags, because "Pressure
|
||
/// got the value" alone would still pass if the driver had fed both.
|
||
/// </para>
|
||
/// </summary>
|
||
[Fact]
|
||
public async Task Alias_reused_across_a_rebirth_routes_by_metric_name_not_by_alias()
|
||
{
|
||
SkipIfUnconfigured();
|
||
|
||
var ct = TestContext.Current.CancellationToken;
|
||
var group = NewGroup("alias");
|
||
const string node = "EdgeA";
|
||
const string tempPath = "Fixture/Sparkplug/Alias/Temperature";
|
||
const string pressPath = "Fixture/Sparkplug/Alias/Pressure";
|
||
|
||
await using var sim = new SparkplugEdgeNode(SimOptions(group, node));
|
||
sim.SetNodeMetrics(
|
||
new SparkplugMetricSeed("Temperature", 5, DataType.Double, 1d),
|
||
new SparkplugMetricSeed("Pressure", 6, DataType.Double, 2d));
|
||
|
||
await sim.ConnectAsync(ct);
|
||
await sim.BirthAsync(ct);
|
||
|
||
await using var driver = new MqttDriver(
|
||
SparkplugOptions(
|
||
group,
|
||
Tag(tempPath, group, node, null, "Temperature"),
|
||
Tag(pressPath, group, node, null, "Pressure")),
|
||
"mqtt-live-sp-alias");
|
||
var recorder = new SparkplugRecorder(driver);
|
||
|
||
await driver.InitializeAsync(string.Empty, ct);
|
||
await driver.SubscribeAsync([tempPath, pressPath], TimeSpan.FromSeconds(1), ct);
|
||
|
||
await recorder.WaitAsync(tempPath, v => Equals(v.Value, 1d), Timeout, ct);
|
||
await recorder.WaitAsync(pressPath, v => Equals(v.Value, 2d), Timeout, ct);
|
||
|
||
// Sanity, pre-swap: alias 5 IS Temperature right now.
|
||
await sim.PublishNodeDataAsync([("Temperature", 11d)], ct);
|
||
await recorder.WaitAsync(tempPath, v => Equals(v.Value, 11d), Timeout, ct);
|
||
|
||
// The rebirth: aliases 5 and 6 change hands, and the birth republishes both values.
|
||
sim.SetNodeMetrics(
|
||
new SparkplugMetricSeed("Temperature", 6, DataType.Double, 100d),
|
||
new SparkplugMetricSeed("Pressure", 5, DataType.Double, 200d));
|
||
await sim.BirthAsync(ct);
|
||
|
||
await recorder.WaitAsync(tempPath, v => Equals(v.Value, 100d), Timeout, ct);
|
||
await recorder.WaitAsync(pressPath, v => Equals(v.Value, 200d), Timeout, ct);
|
||
|
||
// …and now the message that discriminates: alias 5, which is Pressure NOW and was Temperature
|
||
// a moment ago.
|
||
await sim.PublishNodeDataAsync([("Pressure", 777d)], ct);
|
||
|
||
var pressure = await recorder.WaitAsync(pressPath, v => Equals(v.Value, 777d), Timeout, ct);
|
||
pressure.Snapshot.StatusCode.ShouldBe(Good);
|
||
|
||
// The other half of the assertion. Without it, a driver that fed BOTH tags would pass.
|
||
var read = await driver.ReadAsync([tempPath], ct);
|
||
read[0].Value.ShouldBe(
|
||
100d,
|
||
"alias 5 now belongs to Pressure; a driver still routing it to Temperature would show 777 "
|
||
+ "here — Good quality, plausible value, wrong tag");
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// §3.6 case (ii) — a stale-bdSeq NDEATH arriving after the reconnect NBIRTH
|
||
// -----------------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// An edge node's link drops, it reconnects and births under a new <c>bdSeq</c>, and only
|
||
/// <i>then</i> does the broker get round to delivering the old connection's Will. Without the
|
||
/// <c>bdSeq</c> tie that stale death marks a live, freshly-born node dead and drives every one
|
||
/// of its tags Bad, with nothing coming to correct it.
|
||
/// <para>
|
||
/// Both arms are asserted, and they must be: a driver that ignored <b>every</b> NDEATH would
|
||
/// pass the first half. So the matching-token death follows immediately and must land.
|
||
/// </para>
|
||
/// </summary>
|
||
[Fact]
|
||
public async Task Ndeath_with_a_stale_bdseq_is_ignored_and_the_current_one_stales()
|
||
{
|
||
SkipIfUnconfigured();
|
||
|
||
var ct = TestContext.Current.CancellationToken;
|
||
var group = NewGroup("bdseq");
|
||
const string node = "EdgeA";
|
||
const string rawPath = "Fixture/Sparkplug/BdSeq/Temperature";
|
||
|
||
await using var sim = new SparkplugEdgeNode(SimOptions(group, node));
|
||
sim.SetNodeMetrics(new SparkplugMetricSeed("Temperature", 3, DataType.Double, 10d));
|
||
|
||
await sim.ConnectAsync(ct); // session 0
|
||
sim.BdSeq.ShouldBe(0UL);
|
||
await sim.BirthAsync(ct);
|
||
|
||
await using var driver = new MqttDriver(
|
||
SparkplugOptions(group, Tag(rawPath, group, node, null, "Temperature")),
|
||
"mqtt-live-sp-bdseq");
|
||
var recorder = new SparkplugRecorder(driver);
|
||
|
||
await driver.InitializeAsync(string.Empty, ct);
|
||
await driver.SubscribeAsync([rawPath], TimeSpan.FromSeconds(1), ct);
|
||
await recorder.WaitAsync(rawPath, v => Equals(v.Value, 10d), Timeout, ct);
|
||
|
||
// The node "reconnects": a clean DISCONNECT (so no Will fires) followed by a new session, whose
|
||
// bdSeq is 1, and a fresh birth carrying a distinguishable value.
|
||
sim.SetNodeMetrics(new SparkplugMetricSeed("Temperature", 3, DataType.Double, 20d));
|
||
await sim.ConnectAsync(ct); // session 1
|
||
sim.BdSeq.ShouldBe(1UL);
|
||
await sim.BirthAsync(ct);
|
||
await recorder.WaitAsync(rawPath, v => Equals(v.Value, 20d), Timeout, ct);
|
||
|
||
// The old session's Will, delivered late. bdSeq 0 ≠ the live session's 1 ⇒ it must be discarded.
|
||
await sim.PublishNodeDeathAsync(bdSeq: 0UL, ct);
|
||
|
||
var deadline = DateTime.UtcNow + NegativeWindow;
|
||
while (DateTime.UtcNow < deadline)
|
||
{
|
||
var sample = await driver.ReadAsync([rawPath], ct);
|
||
sample[0].StatusCode.ShouldBe(
|
||
Good,
|
||
"an NDEATH carrying a PREVIOUS session's bdSeq is a stale Will; acting on it kills a live node");
|
||
await Task.Delay(200, ct);
|
||
}
|
||
|
||
// …and the falsifiability control: the SAME message with the CURRENT token must stale the tag.
|
||
// Without this leg, a driver that ignored every death would pass the assertion above.
|
||
await sim.PublishNodeDeathAsync(bdSeq: 1UL, ct);
|
||
|
||
var stale = await recorder.WaitAsync(
|
||
rawPath,
|
||
v => v.StatusCode == BadCommunicationError,
|
||
Timeout,
|
||
ct);
|
||
stale.Snapshot.Value.ShouldBeNull();
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// death → STALE, through the real Last-Will path
|
||
// -----------------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// The realistic death: the edge node's session ends and the <b>broker</b> publishes the NDEATH
|
||
/// Will it registered at CONNECT. Nothing in this test publishes the death message — which is
|
||
/// the point, because a Will is the only death an edge node that crashed can produce, and it is
|
||
/// also the one that carries no <c>seq</c>.
|
||
/// <para>
|
||
/// The recovery half matters as much: §3.6 #4 says the next birth restores Good, so a driver
|
||
/// that latched Bad on death would be wrong in the direction nobody notices until a plant
|
||
/// comes back and its tags do not.
|
||
/// </para>
|
||
/// </summary>
|
||
[Fact]
|
||
public async Task Broker_published_ndeath_will_stales_the_tags_and_the_next_birth_restores_them()
|
||
{
|
||
SkipIfUnconfigured();
|
||
|
||
var ct = TestContext.Current.CancellationToken;
|
||
var group = NewGroup("death");
|
||
const string node = "EdgeA";
|
||
const string rawPath = "Fixture/Sparkplug/Death/Temperature";
|
||
|
||
await using var sim = new SparkplugEdgeNode(SimOptions(group, node));
|
||
sim.SetNodeMetrics(new SparkplugMetricSeed("Temperature", 4, DataType.Double, 42d));
|
||
|
||
await sim.ConnectAsync(ct);
|
||
await sim.BirthAsync(ct);
|
||
|
||
await using var driver = new MqttDriver(
|
||
SparkplugOptions(group, Tag(rawPath, group, node, null, "Temperature")),
|
||
"mqtt-live-sp-death");
|
||
var recorder = new SparkplugRecorder(driver);
|
||
|
||
await driver.InitializeAsync(string.Empty, ct);
|
||
await driver.SubscribeAsync([rawPath], TimeSpan.FromSeconds(1), ct);
|
||
await recorder.WaitAsync(rawPath, v => Equals(v.Value, 42d), Timeout, ct);
|
||
|
||
// The broker fires the Will — we publish nothing.
|
||
await sim.KillAsync(ct);
|
||
|
||
var stale = await recorder.WaitAsync(rawPath, v => v.StatusCode == BadCommunicationError, Timeout, ct);
|
||
stale.FullReference.ShouldBe(rawPath);
|
||
stale.Snapshot.Value.ShouldBeNull("a staled tag must not keep serving the dead node's last value");
|
||
|
||
// §3.6 #4: the next birth restores Good.
|
||
sim.SetNodeMetrics(new SparkplugMetricSeed("Temperature", 4, DataType.Double, 43d));
|
||
await sim.ConnectAsync(ct);
|
||
await sim.BirthAsync(ct);
|
||
|
||
var revived = await recorder.WaitAsync(rawPath, v => Equals(v.Value, 43d), Timeout, ct);
|
||
revived.Snapshot.StatusCode.ShouldBe(Good);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// §3.6 case (iii) — seq wrap 255 → 0, and its falsifiability control
|
||
// -----------------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// <b>255 → 0 is the sequence continuing, not a gap.</b> Read the wrap as a gap and every edge
|
||
/// node is commanded to rebirth once per 256 messages — a busy node would spend its life
|
||
/// republishing metadata. This drives a full lap of the counter over a real broker and asserts
|
||
/// that <b>no NCMD appears on the wire</b>.
|
||
/// <para>
|
||
/// <b>Two things make this test able to fail.</b> First, the ingestor is built with
|
||
/// <c>rebirthDebounce: TimeSpan.Zero</c>: at the shipped 10 s default a wrap-triggered
|
||
/// request would be swallowed by the debounce and the test would pass for the wrong reason.
|
||
/// Second, the negative control — a deliberately skipped <c>seq</c> immediately afterwards
|
||
/// <b>must</b> produce an NCMD. Silence proves nothing unless noise is also proven.
|
||
/// </para>
|
||
/// <para>
|
||
/// The assertion is made on the wire, by a third MQTT client watching
|
||
/// <c>spBv1.0/{group}/NCMD/#</c> — not on a counter inside the driver.
|
||
/// </para>
|
||
/// </summary>
|
||
[Fact]
|
||
public async Task Seq_wrap_255_to_0_requests_no_rebirth_but_a_real_gap_does()
|
||
{
|
||
SkipIfUnconfigured();
|
||
|
||
var ct = TestContext.Current.CancellationToken;
|
||
var group = NewGroup("seq");
|
||
const string node = "EdgeA";
|
||
const string rawPath = "Fixture/Sparkplug/Seq/Counter";
|
||
|
||
await using var watcher = await NcmdWatcher.StartAsync(_fx, group, ct);
|
||
|
||
// QoS 1 for the 256-message lap: the seq semantics under test are identical at either QoS, and
|
||
// a broker-side drop at QoS 0 would surface as a "gap" — a real detection, but a flaky test.
|
||
await using var sim = new SparkplugEdgeNode(SimOptions(group, node) with { DataQos = 1 });
|
||
sim.SetNodeMetrics(new SparkplugMetricSeed("Counter", 1, DataType.Double, 0d));
|
||
|
||
await sim.ConnectAsync(ct);
|
||
await sim.BirthAsync(ct);
|
||
|
||
var options = SparkplugOptions(group, Tag(rawPath, group, node, null, "Counter"));
|
||
|
||
// The production ingest path — AttachTo + EstablishAsync is exactly what MqttDriver does — but
|
||
// constructed directly so the rebirth debounce can be taken out of the picture.
|
||
await using var connection = new MqttConnection(options, "mqtt-live-sp-seq");
|
||
var ingestor = new SparkplugIngestor(
|
||
options,
|
||
"mqtt-live-sp-seq",
|
||
logger: null,
|
||
maxPayloadBytes: options.MaxPayloadBytes,
|
||
rebirthDebounce: TimeSpan.Zero);
|
||
ingestor.Register(options.RawTags);
|
||
ingestor.AttachTo(connection);
|
||
|
||
var recorder = new SparkplugRecorder(ingestor);
|
||
await connection.ConnectAsync(ct);
|
||
await ingestor.EstablishAsync(ct);
|
||
await ingestor.SubscribeAsync([rawPath], TimeSpan.FromSeconds(1), ct);
|
||
|
||
// Establish issues the late-join NCMD; let the node answer it and settle before measuring.
|
||
await sim.WaitForRebirthAsync(1, Timeout, ct);
|
||
await recorder.WaitAsync(rawPath, v => v.StatusCode == Good, Timeout, ct);
|
||
await Task.Delay(500, ct);
|
||
|
||
var beforeWrap = watcher.Count;
|
||
|
||
// A full lap: the birth left seq at 0, so these carry seq 1…255 and then 0 — the wrap.
|
||
for (var i = 1; i <= 256; i++)
|
||
{
|
||
await sim.PublishNodeDataAsync([("Counter", (double)i)], ct);
|
||
}
|
||
|
||
sim.LastSeq.ShouldBe((byte)0, "256 messages after a birth at seq 0 must land exactly on the wrap");
|
||
|
||
await recorder.WaitAsync(rawPath, v => Equals(v.Value, 256d), Timeout, ct);
|
||
await Task.Delay(500, ct); // any NCMD the wrap provoked would have arrived by now
|
||
|
||
watcher.Count.ShouldBe(
|
||
beforeWrap,
|
||
$"a seq wrap 255 → 0 is the stream continuing, not a gap; NCMDs seen: "
|
||
+ $"{string.Join(", ", watcher.Topics)}");
|
||
|
||
// ---- the falsifiability control -------------------------------------------------
|
||
// Burn one sequence number without publishing it. That IS a lost message from the host's point
|
||
// of view, and it MUST produce a rebirth request — otherwise the silence above proves only that
|
||
// this driver never asks for anything.
|
||
sim.SkipSeq();
|
||
await sim.PublishNodeDataAsync([("Counter", 999d)], ct);
|
||
|
||
await watcher.WaitForCountAsync(beforeWrap + 1, Timeout, ct);
|
||
|
||
watcher.Topics.ShouldAllBe(t => t == $"spBv1.0/{group}/NCMD/{node}");
|
||
|
||
// A gap resynchronizes rather than latching: the message that exposed it still lands.
|
||
await recorder.WaitAsync(rawPath, v => Equals(v.Value, 999d), Timeout, ct);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// browser: passive window
|
||
// -----------------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// <b>Opening an address picker against a live plant must publish nothing.</b> The browse
|
||
/// session counts its own publishes, and the offline suite asserts that counter — but a counter
|
||
/// can only see messages that went through the seam it guards. This asserts the property where
|
||
/// it actually matters: on the broker, with an independent client subscribed to the group's NCMD
|
||
/// topic while the picker opens, roots, expands every level and reads every attribute.
|
||
/// </summary>
|
||
[Fact]
|
||
public async Task Browser_open_and_full_tree_walk_publish_no_ncmd()
|
||
{
|
||
SkipIfUnconfigured();
|
||
|
||
var ct = TestContext.Current.CancellationToken;
|
||
var group = NewGroup("browse");
|
||
|
||
await using var watcher = await NcmdWatcher.StartAsync(_fx, group, ct);
|
||
await using var edgeA = await StartBrowseNodeAsync(group, "EdgeA", withDevice: true, ct);
|
||
await using var edgeB = await StartBrowseNodeAsync(group, "EdgeB", withDevice: false, ct);
|
||
|
||
var browser = new MqttDriverBrowser();
|
||
await using var session = await browser.OpenAsync(BrowseConfigJson(group), ct);
|
||
|
||
// The window is passive, so it can only see births published AFTER it subscribed — Sparkplug
|
||
// births are never retained. This is the same round-trip the picker's "Request rebirth" button
|
||
// exists for; here the test plays the edge node's part directly, so the browser stays untouched.
|
||
await edgeA.BirthAsync(ct);
|
||
await edgeB.BirthAsync(ct);
|
||
|
||
var roots = await WaitForRootAsync(session, group, ct);
|
||
roots.ShouldContain(n => n.NodeId == group);
|
||
|
||
// Walk everything the picker could walk: every level expanded, every node's attributes read.
|
||
var visited = await WalkAsync(session, roots, ct);
|
||
|
||
visited.ShouldContain($"{group}/EdgeA");
|
||
visited.ShouldContain($"{group}/EdgeB");
|
||
visited.ShouldContain($"{group}/EdgeA/Filler1");
|
||
visited.ShouldContain($"{group}/EdgeA::Temperature");
|
||
visited.ShouldContain($"{group}/EdgeA/Filler1::Level");
|
||
|
||
// Give anything the walk might have kicked off time to reach the broker before concluding.
|
||
await Task.Delay(NegativeWindow, ct);
|
||
|
||
watcher.Count.ShouldBe(
|
||
0,
|
||
"no browse path may publish — an operator opening a picker must not be able to command a "
|
||
+ $"running plant. Published: {string.Join(", ", watcher.Topics)}");
|
||
edgeA.RebirthCount.ShouldBe(0);
|
||
edgeB.RebirthCount.ShouldBe(0);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// browser: explicit rebirth, node vs. group scope
|
||
// -----------------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// The one browse-session member that publishes, and the enumeration it performs: an edge-node
|
||
/// scope addresses exactly that node, a group scope fans out to every edge node observed beneath
|
||
/// it. Asserted on the wire (which topics were published) and at the far end (which simulated
|
||
/// nodes actually honoured a rebirth), because "the call returned 2" says nothing about where
|
||
/// the commands went.
|
||
/// </summary>
|
||
[Fact]
|
||
public async Task Browser_request_rebirth_addresses_one_node_or_every_node_in_the_group()
|
||
{
|
||
SkipIfUnconfigured();
|
||
|
||
var ct = TestContext.Current.CancellationToken;
|
||
var group = NewGroup("rbscope");
|
||
|
||
await using var watcher = await NcmdWatcher.StartAsync(_fx, group, ct);
|
||
await using var edgeA = await StartBrowseNodeAsync(group, "EdgeA", withDevice: true, ct);
|
||
await using var edgeB = await StartBrowseNodeAsync(group, "EdgeB", withDevice: false, ct);
|
||
|
||
var browser = new MqttDriverBrowser();
|
||
await using var session = await browser.OpenAsync(BrowseConfigJson(group), ct);
|
||
var rebirthable = session.ShouldBeAssignableTo<IRebirthCapableBrowseSession>();
|
||
|
||
await edgeA.BirthAsync(ct);
|
||
await edgeB.BirthAsync(ct);
|
||
await WaitForRootAsync(session, group, ct);
|
||
|
||
// Both edge nodes must be in the tree before the group scope is exercised — it enumerates what
|
||
// the window OBSERVED, so asserting the fan-out against a half-built tree would prove nothing.
|
||
await WaitForChildrenAsync(session, group, expected: 2, ct);
|
||
|
||
// ---- node scope ----
|
||
var nodeTargets = await rebirthable.RequestRebirthAsync($"{group}/EdgeA", ct);
|
||
nodeTargets.ShouldBe(1);
|
||
|
||
await watcher.WaitForCountAsync(1, Timeout, ct);
|
||
watcher.Topics.ShouldBe([$"spBv1.0/{group}/NCMD/EdgeA"]);
|
||
await edgeA.WaitForRebirthAsync(1, Timeout, ct);
|
||
edgeB.RebirthCount.ShouldBe(0, "a node-scoped rebirth must not touch the node next to it");
|
||
|
||
// ---- group scope ----
|
||
var groupTargets = await rebirthable.RequestRebirthAsync(group, ct);
|
||
groupTargets.ShouldBe(2);
|
||
|
||
await watcher.WaitForCountAsync(3, Timeout, ct);
|
||
watcher.Topics.ShouldBe(
|
||
[
|
||
$"spBv1.0/{group}/NCMD/EdgeA",
|
||
$"spBv1.0/{group}/NCMD/EdgeA",
|
||
$"spBv1.0/{group}/NCMD/EdgeB",
|
||
],
|
||
"a group scope addresses every observed edge node, once each, in a deterministic order");
|
||
|
||
await edgeA.WaitForRebirthAsync(2, Timeout, ct);
|
||
await edgeB.WaitForRebirthAsync(1, Timeout, ct);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// helpers
|
||
// -----------------------------------------------------------------------
|
||
|
||
private void SkipIfUnconfigured()
|
||
{
|
||
if (_fx.NotConfigured)
|
||
{
|
||
Assert.Skip(_fx.SkipReason!);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// A Sparkplug group id unique to this run. The fixture broker is shared and the driver
|
||
/// subscribes the whole group, so a fixed id would let tests — and previous runs — feed each
|
||
/// other.
|
||
/// </summary>
|
||
private static string NewGroup(string label) => $"OtOpcUaLive-{label}-{Guid.NewGuid():N}"[..40];
|
||
|
||
/// <summary>Driver options for Sparkplug mode over the fixture's TLS listener.</summary>
|
||
private MqttDriverOptions SparkplugOptions(string groupId, params RawTagEntry[] rawTags) => new()
|
||
{
|
||
Host = _fx.TlsHost,
|
||
Port = _fx.TlsPort,
|
||
UseTls = true,
|
||
CaCertificatePath = _fx.CaCertificatePath,
|
||
AllowUntrustedServerCertificate = _fx.CaCertificatePath is null,
|
||
Username = _fx.Username,
|
||
Password = _fx.Password,
|
||
ClientId = $"otopcua-live-sp-{Guid.NewGuid():N}",
|
||
ConnectTimeoutSeconds = 15,
|
||
Mode = MqttMode.SparkplugB,
|
||
Sparkplug = new MqttSparkplugOptions { GroupId = groupId, RequestRebirthOnGap = true },
|
||
RawTags = rawTags,
|
||
};
|
||
|
||
/// <summary>Simulator connection settings, pointed at the same listener with the same credentials.</summary>
|
||
private SparkplugEdgeNodeOptions SimOptions(string groupId, string edgeNodeId) => new()
|
||
{
|
||
Host = _fx.TlsHost,
|
||
Port = _fx.TlsPort,
|
||
UseTls = true,
|
||
CaCertificatePath = _fx.CaCertificatePath,
|
||
AllowUntrustedServerCertificate = _fx.CaCertificatePath is null,
|
||
Username = _fx.Username,
|
||
Password = _fx.Password,
|
||
GroupId = groupId,
|
||
EdgeNodeId = edgeNodeId,
|
||
};
|
||
|
||
/// <summary>An authored Sparkplug raw tag — the binding tuple, with the datatype left to the birth.</summary>
|
||
private static RawTagEntry Tag(
|
||
string rawPath,
|
||
string groupId,
|
||
string edgeNodeId,
|
||
string? deviceId,
|
||
string metricName)
|
||
{
|
||
var device = deviceId is null ? "" : $"""
|
||
"deviceId":"{deviceId}",
|
||
""";
|
||
return new RawTagEntry(
|
||
rawPath,
|
||
$$"""
|
||
{"groupId":"{{groupId}}","edgeNodeId":"{{edgeNodeId}}",{{device}}"metricName":"{{metricName}}"}
|
||
""",
|
||
WriteIdempotent: false);
|
||
}
|
||
|
||
/// <summary>The browse configuration blob, parsed through the driver's ONE shared JSON options.</summary>
|
||
private string BrowseConfigJson(string groupId) =>
|
||
JsonSerializer.Serialize(SparkplugOptions(groupId), MqttJson.Options);
|
||
|
||
/// <summary>Brings up one simulated edge node for the browse legs, connected but not yet birthed.</summary>
|
||
private async Task<SparkplugEdgeNode> StartBrowseNodeAsync(
|
||
string group,
|
||
string node,
|
||
bool withDevice,
|
||
CancellationToken ct)
|
||
{
|
||
var sim = new SparkplugEdgeNode(SimOptions(group, node));
|
||
sim.SetNodeMetrics(
|
||
new SparkplugMetricSeed("Temperature", 1, DataType.Float, 21.5f),
|
||
new SparkplugMetricSeed("Count", 2, DataType.Int32, -42));
|
||
|
||
if (withDevice)
|
||
{
|
||
sim.SetDeviceMetrics("Filler1", new SparkplugMetricSeed("Level", 10, DataType.Double, 3.5d));
|
||
}
|
||
|
||
await sim.ConnectAsync(ct);
|
||
return sim;
|
||
}
|
||
|
||
/// <summary>Polls <c>RootAsync</c> until the observation window has recorded the group.</summary>
|
||
private static async Task<IReadOnlyList<BrowseNode>> WaitForRootAsync(
|
||
IBrowseSession session,
|
||
string group,
|
||
CancellationToken ct)
|
||
{
|
||
var deadline = DateTime.UtcNow + Timeout;
|
||
while (DateTime.UtcNow < deadline)
|
||
{
|
||
var roots = await session.RootAsync(ct);
|
||
if (roots.Any(n => n.NodeId == group))
|
||
{
|
||
return roots;
|
||
}
|
||
|
||
await Task.Delay(200, ct);
|
||
}
|
||
|
||
throw new TimeoutException(
|
||
$"The browse window never observed Sparkplug group '{group}'. A birth must be published "
|
||
+ "AFTER the window opens — Sparkplug births are not retained.");
|
||
}
|
||
|
||
/// <summary>Polls until a node reports at least <paramref name="expected"/> children.</summary>
|
||
private static async Task WaitForChildrenAsync(
|
||
IBrowseSession session,
|
||
string nodeId,
|
||
int expected,
|
||
CancellationToken ct)
|
||
{
|
||
var deadline = DateTime.UtcNow + Timeout;
|
||
while (DateTime.UtcNow < deadline)
|
||
{
|
||
var children = await session.ExpandAsync(nodeId, ct);
|
||
if (children.Count >= expected)
|
||
{
|
||
return;
|
||
}
|
||
|
||
await Task.Delay(200, ct);
|
||
}
|
||
|
||
throw new TimeoutException($"'{nodeId}' never reported {expected} children in the browse window.");
|
||
}
|
||
|
||
/// <summary>Expands every node depth-first and reads every node's attributes; returns the node ids seen.</summary>
|
||
private static async Task<List<string>> WalkAsync(
|
||
IBrowseSession session,
|
||
IReadOnlyList<BrowseNode> level,
|
||
CancellationToken ct)
|
||
{
|
||
var seen = new List<string>();
|
||
foreach (var node in level)
|
||
{
|
||
seen.Add(node.NodeId);
|
||
await session.AttributesAsync(node.NodeId, ct);
|
||
|
||
var children = await session.ExpandAsync(node.NodeId, ct);
|
||
if (children.Count > 0)
|
||
{
|
||
seen.AddRange(await WalkAsync(session, children, ct));
|
||
}
|
||
}
|
||
|
||
return seen;
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// fixtures used by the tests
|
||
// -----------------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// Records every <c>OnDataChange</c> raised by a driver or an ingestor and lets a test await a
|
||
/// specific RawPath reaching a specific state.
|
||
/// <para>
|
||
/// <b>Awaits a predicate, not merely an arrival.</b> A Sparkplug tag is fed by births,
|
||
/// rebirths and data alike, so "a notification arrived for this RawPath" is satisfied by the
|
||
/// wrong message roughly half the time; every wait here names the value or quality it is
|
||
/// waiting for. It also pre-scans, so a test that registers after the message landed is
|
||
/// served rather than timing out.
|
||
/// </para>
|
||
/// </summary>
|
||
private sealed class SparkplugRecorder
|
||
{
|
||
private readonly List<DataChangeEventArgs> _events = [];
|
||
private readonly Lock _gate = new();
|
||
|
||
public SparkplugRecorder(MqttDriver driver) => driver.OnDataChange += Record;
|
||
|
||
public SparkplugRecorder(SparkplugIngestor ingestor) => ingestor.OnDataChange += Record;
|
||
|
||
public async Task<DataChangeEventArgs> WaitAsync(
|
||
string rawPath,
|
||
Func<DataValueSnapshot, bool> predicate,
|
||
TimeSpan timeout,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var deadline = DateTime.UtcNow + timeout;
|
||
while (true)
|
||
{
|
||
lock (_gate)
|
||
{
|
||
foreach (var candidate in _events)
|
||
{
|
||
if (candidate.FullReference == rawPath && predicate(candidate.Snapshot))
|
||
{
|
||
return candidate;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (DateTime.UtcNow >= deadline)
|
||
{
|
||
throw new TimeoutException(
|
||
$"No notification for RawPath '{rawPath}' satisfied the expected state within "
|
||
+ $"{timeout.TotalSeconds:0}s. Recorded: {Describe()}");
|
||
}
|
||
|
||
await Task.Delay(100, cancellationToken).ConfigureAwait(false);
|
||
}
|
||
}
|
||
|
||
private string Describe()
|
||
{
|
||
lock (_gate)
|
||
{
|
||
return _events.Count == 0
|
||
? "(nothing)"
|
||
: string.Join(
|
||
" | ",
|
||
_events
|
||
.GroupBy(e => e.FullReference)
|
||
.Select(g =>
|
||
$"{g.Key}: [{string.Join(", ", g.Select(e => $"{e.Snapshot.Value ?? "null"}@0x{e.Snapshot.StatusCode:X8}"))}]"));
|
||
}
|
||
}
|
||
|
||
private void Record(object? sender, DataChangeEventArgs args)
|
||
{
|
||
lock (_gate)
|
||
{
|
||
_events.Add(args);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// An independent MQTT client subscribed to <c>spBv1.0/{group}/NCMD/#</c>, recording every
|
||
/// command published into the group.
|
||
/// <para>
|
||
/// <b>This is what makes the passivity assertions mean something.</b> The browse session's
|
||
/// own <c>PublishCountForTest</c> can only observe messages that pass through the seam it
|
||
/// guards; this observes the broker. If a future change published an NCMD by some other
|
||
/// route — a raw client, a Last Will, a stray retained command — the counter would still
|
||
/// read zero and this would not.
|
||
/// </para>
|
||
/// </summary>
|
||
private sealed class NcmdWatcher : IAsyncDisposable
|
||
{
|
||
private readonly IMqttClient _client;
|
||
private readonly ConcurrentQueue<string> _topics = new();
|
||
|
||
private NcmdWatcher(IMqttClient client) => _client = client;
|
||
|
||
/// <summary>Every NCMD topic observed, in arrival order.</summary>
|
||
public IReadOnlyList<string> Topics => [.. _topics];
|
||
|
||
/// <summary>How many NCMDs have been observed.</summary>
|
||
public int Count => _topics.Count;
|
||
|
||
public static async Task<NcmdWatcher> StartAsync(MqttFixture fixture, string group, CancellationToken ct)
|
||
{
|
||
var client = new MqttClientFactory().CreateMqttClient();
|
||
var watcher = new NcmdWatcher(client);
|
||
|
||
client.ApplicationMessageReceivedAsync += args =>
|
||
{
|
||
// Only a genuine rebirth command counts. A malformed or unrelated NCMD would be a
|
||
// different defect, and lumping them together would make the count uninterpretable.
|
||
if (watcher.IsRebirth(args.ApplicationMessage))
|
||
{
|
||
watcher._topics.Enqueue(args.ApplicationMessage.Topic);
|
||
}
|
||
|
||
return Task.CompletedTask;
|
||
};
|
||
|
||
var options = new MqttClientOptionsBuilder()
|
||
.WithTcpServer(fixture.PlainHost, fixture.PlainPort)
|
||
.WithCredentials(fixture.Username, fixture.Password)
|
||
.WithClientId($"otopcua-live-ncmdwatch-{Guid.NewGuid():N}")
|
||
.WithTimeout(TimeSpan.FromSeconds(10))
|
||
.Build();
|
||
|
||
await client.ConnectAsync(options, ct).ConfigureAwait(false);
|
||
await client.SubscribeAsync(
|
||
new MqttClientSubscribeOptionsBuilder()
|
||
.WithTopicFilter(f => f.WithTopic($"spBv1.0/{group}/NCMD/#"))
|
||
.Build(),
|
||
ct).ConfigureAwait(false);
|
||
|
||
return watcher;
|
||
}
|
||
|
||
public async Task WaitForCountAsync(int count, TimeSpan timeout, CancellationToken ct)
|
||
{
|
||
var deadline = DateTime.UtcNow + timeout;
|
||
while (Count < count)
|
||
{
|
||
if (DateTime.UtcNow >= deadline)
|
||
{
|
||
throw new TimeoutException(
|
||
$"Only {Count} rebirth NCMD(s) reached the broker within {timeout.TotalSeconds:0}s; "
|
||
+ $"expected {count}. Seen: {string.Join(", ", Topics)}");
|
||
}
|
||
|
||
await Task.Delay(100, ct).ConfigureAwait(false);
|
||
}
|
||
}
|
||
|
||
public async ValueTask DisposeAsync()
|
||
{
|
||
try
|
||
{
|
||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||
await _client.DisconnectAsync(new MqttClientDisconnectOptions(), cts.Token).ConfigureAwait(false);
|
||
}
|
||
catch (Exception)
|
||
{
|
||
// Teardown is best-effort; the broker may already have gone.
|
||
}
|
||
|
||
_client.Dispose();
|
||
}
|
||
|
||
private bool IsRebirth(MqttApplicationMessage message)
|
||
{
|
||
var body = message.Payload;
|
||
var bytes = body.IsSingleSegment ? body.FirstSpan.ToArray() : body.ToArray();
|
||
|
||
try
|
||
{
|
||
var payload = Payload.Parser.ParseFrom(bytes);
|
||
return payload.Metrics.Any(m =>
|
||
m.Name == SparkplugEdgeNode.RebirthMetricName
|
||
&& m.ValueCase == Payload.Types.Metric.ValueOneofCase.BooleanValue
|
||
&& m.BooleanValue);
|
||
}
|
||
catch (Google.Protobuf.InvalidProtocolBufferException)
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
}
|
||
}
|