test(mqtt): C# Sparkplug edge-node simulator + §3.6 live matrix
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
This commit is contained in:
@@ -5,6 +5,11 @@
|
||||
# git; its output is regenerated per machine.
|
||||
secrets/
|
||||
|
||||
# Build output of publish-simulator.sh — the `sparkplug-sim` service's bind-mounted app
|
||||
# directory. Regenerated per machine from the SparkplugSimulator project, exactly like
|
||||
# secrets/ is regenerated by gen-fixture-material.sh; the SCRIPT is the artifact in git.
|
||||
simulator/app/
|
||||
|
||||
# Belt-and-braces: catch key/cert material written directly into this directory by a
|
||||
# hand-run openssl command that forgot the secrets/ prefix.
|
||||
*.key
|
||||
|
||||
+52
-4
@@ -1,4 +1,5 @@
|
||||
# MQTT integration-test fixture — Eclipse Mosquitto (auth + TLS) + a JSON publisher.
|
||||
# MQTT integration-test fixture — Eclipse Mosquitto (auth + TLS) + a JSON publisher
|
||||
# + an optional Sparkplug B edge-node simulator.
|
||||
#
|
||||
# PREREQUISITE — run this first, in this directory:
|
||||
#
|
||||
@@ -15,9 +16,10 @@
|
||||
#
|
||||
# Endpoints: 10.100.0.35:8883 (TLS + auth) · 10.100.0.35:1883 (plaintext + auth, smoke)
|
||||
#
|
||||
# Unlike the Modbus / S7 fixtures there are no compose profiles: both services are
|
||||
# always wanted together (a broker with nothing publishing into it tests nothing), and
|
||||
# they bind different ports so nothing contends.
|
||||
# `mosquitto` + `publisher` carry no profile: they are always wanted together (a broker
|
||||
# with nothing publishing into it tests nothing) and they bind different ports so nothing
|
||||
# contends. `sparkplug-sim` DOES carry one (`--profile sparkplug`), because it is a
|
||||
# manual-driving aid rather than a test dependency — see its own banner.
|
||||
services:
|
||||
mosquitto:
|
||||
image: eclipse-mosquitto:2.0.22
|
||||
@@ -75,3 +77,49 @@ services:
|
||||
MQTT_FIXTURE_USERNAME: ${MQTT_FIXTURE_USERNAME:?set MQTT_FIXTURE_USERNAME}
|
||||
MQTT_FIXTURE_PASSWORD: ${MQTT_FIXTURE_PASSWORD:?set MQTT_FIXTURE_PASSWORD}
|
||||
entrypoint: ["/bin/sh", "/publish.sh"]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sparkplug B edge-node simulator — the SAME `SparkplugEdgeNode` engine the live
|
||||
# tests drive, run standalone (see SparkplugSimulator/Program.cs).
|
||||
#
|
||||
# WHAT IT IS FOR, AND WHAT IT IS NOT FOR. It exists so an operator (and the Task-26
|
||||
# AdminUI `/run` verification) has a live Sparkplug plant to point the address picker
|
||||
# at: NBIRTH/DBIRTH then N/DDATA every couple of seconds, honouring rebirth NCMDs.
|
||||
# `SparkplugLiveTests` does NOT use it and must not — the §3.6 matrix needs an edge
|
||||
# node it can command mid-test ("now reuse that alias", "now skip a seq", "now die"),
|
||||
# which a container cannot be. Hence the profile: the default `docker compose up`
|
||||
# leaves it out, and nothing in the test suite depends on it.
|
||||
#
|
||||
# PREREQUISITE: `./publish-simulator.sh` — the app directory below is build output,
|
||||
# gitignored and rsynced like the rest of this folder. Without it the container has
|
||||
# nothing to run.
|
||||
# ---------------------------------------------------------------------------
|
||||
sparkplug-sim:
|
||||
image: mcr.microsoft.com/dotnet/runtime:10.0
|
||||
container_name: otopcua-sparkplug-sim
|
||||
profiles: ["sparkplug"]
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
project: lmxopcua
|
||||
depends_on:
|
||||
mosquitto:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
# Framework-dependent publish output: portable IL, so it runs under the stock
|
||||
# runtime image whatever this file was rsynced from.
|
||||
- ./simulator/app:/app:ro
|
||||
# The fixture CA, so the simulator PINS the broker exactly as the driver does
|
||||
# rather than trusting anything. The cert's SAN list includes DNS:mosquitto (see
|
||||
# gen-fixture-material.sh), which is the host name used below.
|
||||
- ./secrets/ca.crt:/secrets/ca.crt:ro
|
||||
environment:
|
||||
MQTT_SIM_HOST: mosquitto
|
||||
MQTT_SIM_PORT: "8883"
|
||||
MQTT_SIM_USE_TLS: "true"
|
||||
MQTT_SIM_CA_CERT: /secrets/ca.crt
|
||||
MQTT_SIM_GROUP: ${MQTT_SIM_GROUP:-OtOpcUaSim}
|
||||
MQTT_SIM_NODES: ${MQTT_SIM_NODES:-EdgeA,EdgeB}
|
||||
MQTT_SIM_INTERVAL_SECONDS: ${MQTT_SIM_INTERVAL_SECONDS:-2}
|
||||
MQTT_FIXTURE_USERNAME: ${MQTT_FIXTURE_USERNAME:?set MQTT_FIXTURE_USERNAME}
|
||||
MQTT_FIXTURE_PASSWORD: ${MQTT_FIXTURE_PASSWORD:?set MQTT_FIXTURE_PASSWORD}
|
||||
command: ["dotnet", "/app/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.SparkplugSimulator.dll"]
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env sh
|
||||
# ---------------------------------------------------------------------------
|
||||
# Publishes the Sparkplug edge-node simulator into ./simulator/app/, which the
|
||||
# compose file's `sparkplug-sim` service bind-mounts and runs.
|
||||
#
|
||||
# WHY A PUBLISHED DIRECTORY RATHER THAN AN IMAGE BUILD. This fixture is deployed by
|
||||
# rsyncing THIS directory to /opt/otopcua-mqtt on the Docker host (see the compose
|
||||
# banner and infra/README.md §1) — the host never sees the repo, so a Dockerfile whose
|
||||
# build context is the solution root could not run there. A framework-dependent publish
|
||||
# is portable IL: it runs unchanged under the stock dotnet/runtime image on the host,
|
||||
# whatever this machine's architecture is.
|
||||
#
|
||||
# Usage, from a checkout (needs the .NET 10 SDK):
|
||||
#
|
||||
# ./publish-simulator.sh
|
||||
# rsync -av --exclude 'secrets/' ./ dohertj2@10.100.0.35:/opt/otopcua-mqtt/
|
||||
# ssh dohertj2@10.100.0.35 'cd /opt/otopcua-mqtt \
|
||||
# && MQTT_FIXTURE_USERNAME=otopcua MQTT_FIXTURE_PASSWORD=<same> \
|
||||
# docker compose --profile sparkplug up -d'
|
||||
#
|
||||
# ./simulator/app/ is gitignored: it is build output, regenerated per machine, exactly
|
||||
# like ./secrets/.
|
||||
# ---------------------------------------------------------------------------
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
PROJECT="$SCRIPT_DIR/../SparkplugSimulator/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.SparkplugSimulator.csproj"
|
||||
OUT="$SCRIPT_DIR/simulator/app"
|
||||
|
||||
command -v dotnet >/dev/null 2>&1 || { echo "error: dotnet SDK not found on PATH." >&2; exit 2; }
|
||||
[ -f "$PROJECT" ] || { echo "error: simulator project not found at $PROJECT" >&2; exit 2; }
|
||||
|
||||
echo "==> publishing $(basename "$PROJECT") -> $OUT"
|
||||
rm -rf "$OUT"
|
||||
dotnet publish "$PROJECT" -c Release -o "$OUT" --nologo
|
||||
|
||||
echo "==> done:"
|
||||
ls -1 "$OUT" | head -20
|
||||
echo
|
||||
echo "Next: rsync this directory to the Docker host and bring the stack up with"
|
||||
echo " docker compose --profile sparkplug up -d"
|
||||
@@ -0,0 +1,979 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
using Org.Eclipse.Tahu.Protobuf;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.SparkplugSimulator;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Standalone entry point for the `sparkplug-sim` fixture container.
|
||||
//
|
||||
// Runs one or more simulated edge nodes against the fixture broker, forever: NBIRTH + DBIRTH once,
|
||||
// then N/DDATA on a cadence, honouring rebirth NCMDs throughout. It exists so an operator (and Task
|
||||
// 26's AdminUI `/run` verification) has a live Sparkplug plant to browse, NOT so the live tests have
|
||||
// one — those drive `SparkplugEdgeNode` in process, because the §3.6 matrix needs an edge node it
|
||||
// can command mid-test.
|
||||
//
|
||||
// Every setting comes from the environment; nothing is defaulted to a credential.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var host = Env("MQTT_SIM_HOST", "mosquitto");
|
||||
var port = int.TryParse(Env("MQTT_SIM_PORT", "8883"), out var p) ? p : 8883;
|
||||
var useTls = !string.Equals(Env("MQTT_SIM_USE_TLS", "true"), "false", StringComparison.OrdinalIgnoreCase);
|
||||
var group = Env("MQTT_SIM_GROUP", "OtOpcUaSim");
|
||||
var nodes = Env("MQTT_SIM_NODES", "EdgeA,EdgeB").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var interval = int.TryParse(Env("MQTT_SIM_INTERVAL_SECONDS", "2"), out var i) && i > 0 ? i : 2;
|
||||
|
||||
var username = Environment.GetEnvironmentVariable("MQTT_FIXTURE_USERNAME");
|
||||
var password = Environment.GetEnvironmentVariable("MQTT_FIXTURE_PASSWORD");
|
||||
var caPath = Environment.GetEnvironmentVariable("MQTT_SIM_CA_CERT");
|
||||
var allowUntrusted = string.Equals(Env("MQTT_SIM_ALLOW_UNTRUSTED", "false"), "true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (string.IsNullOrEmpty(password))
|
||||
{
|
||||
// Same fail-loud posture as the compose file's `${VAR:?}` guards: the broker runs
|
||||
// allow_anonymous=false, so a blank password would produce an unexplained connect refusal.
|
||||
Console.Error.WriteLine(
|
||||
"sparkplug-sim: MQTT_FIXTURE_PASSWORD is not set. The fixture broker runs allow_anonymous=false; "
|
||||
+ "supply the password gen-fixture-material.sh was run with.");
|
||||
return 2;
|
||||
}
|
||||
|
||||
using var stopping = new CancellationTokenSource();
|
||||
Console.CancelKeyPress += (_, e) =>
|
||||
{
|
||||
e.Cancel = true;
|
||||
stopping.Cancel();
|
||||
};
|
||||
AppDomain.CurrentDomain.ProcessExit += (_, _) => stopping.Cancel();
|
||||
|
||||
Console.WriteLine(
|
||||
$"sparkplug-sim: broker={host}:{port} tls={useTls} group={group} nodes=[{string.Join(", ", nodes)}] "
|
||||
+ $"interval={interval}s");
|
||||
|
||||
var running = new List<Task>();
|
||||
var edges = new List<SparkplugEdgeNode>();
|
||||
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
var edge = new SparkplugEdgeNode(
|
||||
new SparkplugEdgeNodeOptions
|
||||
{
|
||||
Host = host,
|
||||
Port = port,
|
||||
UseTls = useTls,
|
||||
CaCertificatePath = caPath,
|
||||
AllowUntrustedServerCertificate = allowUntrusted,
|
||||
Username = username,
|
||||
Password = password,
|
||||
GroupId = group,
|
||||
EdgeNodeId = node,
|
||||
},
|
||||
Console.WriteLine);
|
||||
|
||||
// A small but genuinely heterogeneous catalog: a float, a double, a NEGATIVE int32 (the two's
|
||||
// complement case), a bool and a string — plus one device, so the picker's
|
||||
// Group → EdgeNode → Device → Metric tree has every level populated.
|
||||
edge.SetNodeMetrics(
|
||||
new SparkplugMetricSeed("Temperature", 1, DataType.Float, 21.5f),
|
||||
new SparkplugMetricSeed("Pressure", 2, DataType.Double, 101.325d),
|
||||
new SparkplugMetricSeed("Count", 3, DataType.Int32, -42),
|
||||
new SparkplugMetricSeed("Running", 4, DataType.Boolean, true),
|
||||
new SparkplugMetricSeed("Serial", 5, DataType.String, $"{node}-001"));
|
||||
|
||||
edge.SetDeviceMetrics(
|
||||
"Filler1",
|
||||
new SparkplugMetricSeed("Temperature", 10, DataType.Float, 55.5f),
|
||||
new SparkplugMetricSeed("FillCount", 11, DataType.Int64, 1000L),
|
||||
new SparkplugMetricSeed("Jammed", 12, DataType.Boolean, false));
|
||||
|
||||
edges.Add(edge);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
await edge.ConnectAsync(stopping.Token);
|
||||
running.Add(edge.RunAsync(TimeSpan.FromSeconds(interval), stopping.Token));
|
||||
}
|
||||
|
||||
await Task.WhenAll(running);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Console.WriteLine("sparkplug-sim: stopping.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
await edge.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
|
||||
static string Env(string name, string fallback) =>
|
||||
Environment.GetEnvironmentVariable(name) is { Length: > 0 } value ? value : fallback;
|
||||
+917
@@ -0,0 +1,917 @@
|
||||
using System.Buffers;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Google.Protobuf;
|
||||
using MQTTnet;
|
||||
using MQTTnet.Protocol;
|
||||
using Org.Eclipse.Tahu.Protobuf;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.SparkplugSimulator;
|
||||
|
||||
/// <summary>
|
||||
/// A controllable Sparkplug B <b>edge node</b> — the driver's counterparty on a real broker.
|
||||
/// Registers an NDEATH Last Will at CONNECT, publishes NBIRTH/DBIRTH then N/DDATA, honours a
|
||||
/// <c>Node Control/Rebirth</c> NCMD, and exposes the §3.6 pathologies (alias reuse across a
|
||||
/// rebirth, a seq gap, a stale-<c>bdSeq</c> death) as explicit, on-demand operations.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Encodes with the generated Tahu types, frames topics independently.</b> The payloads are
|
||||
/// built from <see cref="Payload"/> — the same generated schema the driver decodes with, which
|
||||
/// is what makes this fixture a genuine encode/decode symmetry check. The <i>topic</i> strings
|
||||
/// are composed here from literals rather than through the driver's own
|
||||
/// <c>SparkplugTopic.Format</c>: a simulator that borrowed the parser's formatter could not
|
||||
/// detect a bug in it, because both sides of the comparison would be wrong together.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>What "the way a real edge node does it" means, concretely, and why each detail matters:</b>
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item>
|
||||
/// <b>NDEATH is the broker-published Last Will, registered at CONNECT</b> — not a message
|
||||
/// this class publishes on the way out. It carries a <c>bdSeq</c> metric and <b>no
|
||||
/// <c>seq</c></b>, because it is not a member of the node's sequence.
|
||||
/// <see cref="KillAsync"/> makes the broker fire it.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <b><c>bdSeq</c> increments once per CONNECT</b> and appears in both the NBIRTH and the
|
||||
/// NDEATH of that session — that pairing is the only thing that lets a host tell a
|
||||
/// just-delivered stale will from a real death.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <b>NBIRTH restarts <c>seq</c> at 0</b>; DBIRTH/NDATA/DDATA/DDEATH continue it, wrapping
|
||||
/// 255 → 0.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <b>DATA metrics carry an alias and nothing else</b> — no name, no datatype. That is what
|
||||
/// every real post-birth DATA message looks like, and a driver that binds tags to aliases
|
||||
/// rather than names passes every test written against a friendlier simulator.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <b>Signed integers ride as two's complement in the UNSIGNED proto field.</b> A
|
||||
/// <c>DataType.Int32</c> metric holding −1234 goes on the wire as <c>int_value =
|
||||
/// 4294966062</c>. Encoding it "helpfully" as a positive number would silently retire the
|
||||
/// driver's <c>ReinterpretSigned</c> path from the live gate.
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Not thread-safe for concurrent publishes: <c>seq</c> is a single counter and interleaving
|
||||
/// publishes would manufacture the very gaps the tests are measuring. Drive one node from one
|
||||
/// test at a time; the NCMD-triggered rebirth is serialized against callers by
|
||||
/// <see cref="_publishGate"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SparkplugEdgeNode : IAsyncDisposable
|
||||
{
|
||||
/// <summary>The well-known Sparkplug node-control metric an NCMD sets to trigger a rebirth.</summary>
|
||||
public const string RebirthMetricName = "Node Control/Rebirth";
|
||||
|
||||
/// <summary>The Sparkplug session-token metric name, carried by NBIRTH and NDEATH.</summary>
|
||||
public const string BdSeqMetricName = "bdSeq";
|
||||
|
||||
private const string Namespace = "spBv1.0";
|
||||
|
||||
private readonly SparkplugEdgeNodeOptions _options;
|
||||
private readonly Action<string>? _log;
|
||||
private readonly SemaphoreSlim _publishGate = new(1, 1);
|
||||
|
||||
/// <summary>Device id → its metric catalog. Ordered so a birth's metric order is deterministic.</summary>
|
||||
private readonly ConcurrentDictionary<string, IReadOnlyList<SparkplugMetricSeed>> _devices = new(StringComparer.Ordinal);
|
||||
|
||||
private IMqttClient? _client;
|
||||
private IReadOnlyList<SparkplugMetricSeed> _nodeMetrics = [];
|
||||
private ulong _bdSeq;
|
||||
private int _sessions;
|
||||
private byte _seq;
|
||||
private bool _seqStarted;
|
||||
private int _rebirthCount;
|
||||
private int _disposed;
|
||||
private TaskCompletionSource<int> _rebirthSignal = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
/// <summary>Initializes a new simulated edge node. Does no I/O — call <see cref="ConnectAsync"/>.</summary>
|
||||
/// <param name="options">Broker connection + Sparkplug identity.</param>
|
||||
/// <param name="log">Optional line sink for the standalone runner's console output.</param>
|
||||
public SparkplugEdgeNode(SparkplugEdgeNodeOptions options, Action<string>? log = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(options.GroupId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(options.EdgeNodeId);
|
||||
|
||||
_options = options;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
/// <summary>The Sparkplug group id.</summary>
|
||||
public string GroupId => _options.GroupId;
|
||||
|
||||
/// <summary>The Sparkplug edge-node id.</summary>
|
||||
public string EdgeNodeId => _options.EdgeNodeId;
|
||||
|
||||
/// <summary>The current session token — incremented on every <see cref="ConnectAsync"/>.</summary>
|
||||
public ulong BdSeq => Volatile.Read(ref _bdSeq);
|
||||
|
||||
/// <summary>The <c>seq</c> of the most recently published sequenced message.</summary>
|
||||
public byte LastSeq => Volatile.Read(ref _seq);
|
||||
|
||||
/// <summary>How many rebirth NCMDs this node has honoured since construction.</summary>
|
||||
public int RebirthCount => Volatile.Read(ref _rebirthCount);
|
||||
|
||||
/// <summary>Whether the underlying MQTT client currently holds a session.</summary>
|
||||
public bool IsConnected => _client?.IsConnected ?? false;
|
||||
|
||||
/// <summary>The device ids this node currently publishes for.</summary>
|
||||
public IReadOnlyCollection<string> Devices => [.. _devices.Keys];
|
||||
|
||||
/// <summary>
|
||||
/// Replaces this node's own (NBIRTH-level) metric catalog. Takes effect at the next birth —
|
||||
/// which is exactly how an edge node reassigns an alias across a rebirth.
|
||||
/// </summary>
|
||||
/// <param name="metrics">The catalog.</param>
|
||||
public void SetNodeMetrics(params SparkplugMetricSeed[] metrics) =>
|
||||
_nodeMetrics = [.. metrics ?? []];
|
||||
|
||||
/// <summary>Replaces one device's (DBIRTH-level) metric catalog. Takes effect at the next birth.</summary>
|
||||
/// <param name="deviceId">The device id.</param>
|
||||
/// <param name="metrics">The catalog.</param>
|
||||
public void SetDeviceMetrics(string deviceId, params SparkplugMetricSeed[] metrics)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(deviceId);
|
||||
_devices[deviceId] = [.. metrics ?? []];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connects to the broker with an NDEATH Last Will registered for this session, and subscribes
|
||||
/// the node's NCMD topic so a rebirth request can be honoured.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>The Will is registered here or not at all.</b> MQTT fixes a client's will at CONNECT; a
|
||||
/// "death message" published later by the client itself is a different thing entirely and would
|
||||
/// never fire for the case that matters — the node dying without getting to say so.
|
||||
/// </remarks>
|
||||
/// <param name="cancellationToken">Cancellation for the connect.</param>
|
||||
/// <returns>A task that completes when the session is established and NCMD is subscribed.</returns>
|
||||
public async Task ConnectAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
|
||||
|
||||
await DropClientAsync(graceful: true).ConfigureAwait(false);
|
||||
|
||||
// A NEW session ⇒ a new session token, starting at 0 and incrementing per CONNECT. Both the
|
||||
// Will registered below and the next NBIRTH carry it, which is the pairing a host uses to
|
||||
// discard a previous session's late-delivered will.
|
||||
Volatile.Write(ref _bdSeq, (ulong)(Interlocked.Increment(ref _sessions) - 1));
|
||||
|
||||
var client = new MqttClientFactory().CreateMqttClient();
|
||||
client.ApplicationMessageReceivedAsync += OnMessageAsync;
|
||||
|
||||
var clientId = _options.ClientId is { Length: > 0 } id ? id : $"sim-{Guid.NewGuid():N}";
|
||||
|
||||
var builder = new MqttClientOptionsBuilder()
|
||||
.WithTcpServer(_options.Host, _options.Port)
|
||||
.WithProtocolVersion(MQTTnet.Formatter.MqttProtocolVersion.V500)
|
||||
.WithCleanSession(true)
|
||||
.WithClientId(clientId)
|
||||
.WithKeepAlivePeriod(TimeSpan.FromSeconds(30))
|
||||
.WithTimeout(TimeSpan.FromSeconds(_options.TimeoutSeconds))
|
||||
|
||||
// NDEATH: QoS 1, retain false, no seq, carries this session's bdSeq. Sparkplug B v3.0 §6.
|
||||
.WithWillTopic(NodeTopic("NDEATH"))
|
||||
.WithWillPayload(BuildDeathPayload(CurrentBdSeq()).ToByteArray())
|
||||
.WithWillQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce)
|
||||
.WithWillRetain(false);
|
||||
|
||||
if (!string.IsNullOrEmpty(_options.Username))
|
||||
{
|
||||
builder = builder.WithCredentials(_options.Username, _options.Password ?? string.Empty);
|
||||
}
|
||||
|
||||
builder = builder.WithTlsOptions(ConfigureTls);
|
||||
|
||||
_client = client;
|
||||
await client.ConnectAsync(builder.Build(), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var subscribe = new MqttClientSubscribeOptionsBuilder()
|
||||
.WithTopicFilter(f => f
|
||||
.WithTopic(NodeTopic("NCMD"))
|
||||
.WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce))
|
||||
.Build();
|
||||
await client.SubscribeAsync(subscribe, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
Log($"connected as '{clientId}' (bdSeq {CurrentBdSeq()}), listening on {NodeTopic("NCMD")}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes the node's birth certificate — NBIRTH at <c>seq = 0</c>, then one DBIRTH per
|
||||
/// device continuing the same sequence.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation for the publishes.</param>
|
||||
/// <returns>A task that completes when every birth message has been published.</returns>
|
||||
public async Task BirthAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _publishGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await BirthCoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_publishGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes an NDATA carrying the named node-level metrics, encoded the way a real edge node
|
||||
/// does: <b>alias only</b>, resolved from the current birth catalog.
|
||||
/// </summary>
|
||||
/// <param name="values">Metric name → new value. A name not in the catalog throws.</param>
|
||||
/// <param name="cancellationToken">Cancellation for the publish.</param>
|
||||
/// <returns>A task that completes when the message has been published.</returns>
|
||||
public Task PublishNodeDataAsync(
|
||||
IReadOnlyList<(string Metric, object? Value)> values,
|
||||
CancellationToken cancellationToken) =>
|
||||
PublishDataAsync(device: null, values, cancellationToken);
|
||||
|
||||
/// <summary>Publishes a DDATA for one device — alias only, same sequence as the node's own stream.</summary>
|
||||
/// <param name="deviceId">The device id.</param>
|
||||
/// <param name="values">Metric name → new value.</param>
|
||||
/// <param name="cancellationToken">Cancellation for the publish.</param>
|
||||
/// <returns>A task that completes when the message has been published.</returns>
|
||||
public Task PublishDeviceDataAsync(
|
||||
string deviceId,
|
||||
IReadOnlyList<(string Metric, object? Value)> values,
|
||||
CancellationToken cancellationToken) =>
|
||||
PublishDataAsync(deviceId, values, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Publishes an NDATA whose metrics carry an alias the current birth does <b>not</b> declare —
|
||||
/// the "unknown alias" arm of §3.6.
|
||||
/// </summary>
|
||||
/// <param name="alias">The undeclared alias.</param>
|
||||
/// <param name="value">A double value, encoded as <c>double_value</c>.</param>
|
||||
/// <param name="cancellationToken">Cancellation for the publish.</param>
|
||||
/// <returns>A task that completes when the message has been published.</returns>
|
||||
public async Task PublishUnknownAliasDataAsync(ulong alias, double value, CancellationToken cancellationToken)
|
||||
{
|
||||
await _publishGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var payload = NewSequencedPayload();
|
||||
payload.Metrics.Add(new Payload.Types.Metric
|
||||
{
|
||||
Alias = alias,
|
||||
Timestamp = NowMs(),
|
||||
DoubleValue = value,
|
||||
});
|
||||
|
||||
await PublishAsync(NodeTopic("NDATA"), payload, _options.DataQos, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_publishGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Burns <paramref name="count"/> sequence numbers without publishing them — the next message
|
||||
/// therefore arrives with a <c>seq</c> the host was not expecting. This is how a lost message
|
||||
/// looks from the receiving end, produced deterministically instead of by hoping for packet loss.
|
||||
/// </summary>
|
||||
/// <param name="count">How many sequence numbers to skip; must be at least 1.</param>
|
||||
public void SkipSeq(int count = 1)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(count, 1);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
NextSeq();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes an NDEATH <b>directly</b>, carrying <paramref name="bdSeq"/>. Two uses: a stale
|
||||
/// token (a previous session's will, delivered late) which a correct host must ignore, and the
|
||||
/// current token, which it must act on.
|
||||
/// </summary>
|
||||
/// <param name="bdSeq">The session token to stamp; <see langword="null"/> uses the current one.</param>
|
||||
/// <param name="cancellationToken">Cancellation for the publish.</param>
|
||||
/// <returns>A task that completes when the message has been published.</returns>
|
||||
/// <remarks>
|
||||
/// A real NDEATH is broker-issued (see <see cref="KillAsync"/>); this is the deterministic
|
||||
/// equivalent for the ordering case a test cannot otherwise stage — a will that arrives
|
||||
/// <i>after</i> the reconnect's NBIRTH.
|
||||
/// </remarks>
|
||||
public async Task PublishNodeDeathAsync(ulong? bdSeq, CancellationToken cancellationToken)
|
||||
{
|
||||
await _publishGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
// QoS 1 and NO seq — an NDEATH is not a member of the node's sequence.
|
||||
await PublishAsync(
|
||||
NodeTopic("NDEATH"),
|
||||
BuildDeathPayload(bdSeq ?? CurrentBdSeq()),
|
||||
qos: 1,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_publishGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Publishes a DDEATH for one device — sequenced, unlike an NDEATH.</summary>
|
||||
/// <param name="deviceId">The device id.</param>
|
||||
/// <param name="cancellationToken">Cancellation for the publish.</param>
|
||||
/// <returns>A task that completes when the message has been published.</returns>
|
||||
public async Task PublishDeviceDeathAsync(string deviceId, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(deviceId);
|
||||
|
||||
await _publishGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await PublishAsync(
|
||||
DeviceTopic("DDEATH", deviceId),
|
||||
NewSequencedPayload(),
|
||||
_options.DataQos,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_publishGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ends the session in a way that makes the <b>broker</b> publish this node's registered
|
||||
/// NDEATH Will — the realistic death path.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation for the disconnect.</param>
|
||||
/// <returns>A task that completes once the client has gone.</returns>
|
||||
/// <remarks>
|
||||
/// Uses the MQTT 5 <c>DISCONNECT</c> reason code <c>0x04 Disconnect with Will Message</c>, which
|
||||
/// is the protocol's own "I am going away, publish my will" signal. Deliberately chosen over
|
||||
/// yanking the socket: both produce the will, but only this one does so <i>deterministically</i>
|
||||
/// and immediately — a killed socket leaves the broker waiting out the keep-alive, which would
|
||||
/// make the death test depend on a 30-second timer.
|
||||
/// </remarks>
|
||||
public async Task KillAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var client = _client;
|
||||
if (client is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var options = new MqttClientDisconnectOptionsBuilder()
|
||||
.WithReason(MqttClientDisconnectOptionsReason.DisconnectWithWillMessage)
|
||||
.Build();
|
||||
|
||||
try
|
||||
{
|
||||
await client.DisconnectAsync(options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"kill: disconnect threw ({ex.GetType().Name}); the socket is gone either way");
|
||||
}
|
||||
|
||||
client.ApplicationMessageReceivedAsync -= OnMessageAsync;
|
||||
client.Dispose();
|
||||
_client = null;
|
||||
Log($"killed session (bdSeq {CurrentBdSeq()}); the broker owns the NDEATH now");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Awaits the <paramref name="count"/>-th honoured rebirth NCMD. Pre-checks what has already
|
||||
/// happened, so a caller that registers after the command landed is served immediately.
|
||||
/// </summary>
|
||||
/// <param name="count">The rebirth count to wait for.</param>
|
||||
/// <param name="timeout">Bound on the wait.</param>
|
||||
/// <param name="cancellationToken">Caller cancellation.</param>
|
||||
/// <returns>The rebirth count once it has reached <paramref name="count"/>.</returns>
|
||||
public async Task<int> WaitForRebirthAsync(int count, TimeSpan timeout, CancellationToken cancellationToken)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (true)
|
||||
{
|
||||
var observed = RebirthCount;
|
||||
if (observed >= count)
|
||||
{
|
||||
return observed;
|
||||
}
|
||||
|
||||
var remaining = deadline - DateTime.UtcNow;
|
||||
if (remaining <= TimeSpan.Zero)
|
||||
{
|
||||
throw new TimeoutException(
|
||||
$"Simulated edge node '{GroupId}/{EdgeNodeId}' honoured {observed} rebirth request(s) "
|
||||
+ $"within {timeout.TotalSeconds:0}s; expected at least {count}. Is the driver's "
|
||||
+ "requestRebirthOnGap on, and is the node's NCMD subscription live?");
|
||||
}
|
||||
|
||||
var signal = Volatile.Read(ref _rebirthSignal);
|
||||
try
|
||||
{
|
||||
await signal.Task.WaitAsync(remaining, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
// Loop; the deadline check above produces the diagnostic message.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The standalone (container) loop: birth once, then publish node + device data every
|
||||
/// <paramref name="interval"/> until cancelled. Rebirth NCMDs are honoured throughout.
|
||||
/// </summary>
|
||||
/// <param name="interval">Publish cadence.</param>
|
||||
/// <param name="cancellationToken">Stops the loop.</param>
|
||||
/// <returns>A task that completes when the loop is cancelled.</returns>
|
||||
public async Task RunAsync(TimeSpan interval, CancellationToken cancellationToken)
|
||||
{
|
||||
await BirthAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var tick = 0;
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(interval, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
tick++;
|
||||
try
|
||||
{
|
||||
await PublishNodeDataAsync(
|
||||
[.. _nodeMetrics
|
||||
.Where(m => m.Name is not BdSeqMetricName and not RebirthMetricName)
|
||||
.Select(m => (m.Name, Advance(m, tick)))],
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var (device, metrics) in _devices)
|
||||
{
|
||||
await PublishDeviceDataAsync(
|
||||
device,
|
||||
[.. metrics.Select(m => (m.Name, Advance(m, tick)))],
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
Log($"publish failed: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await DropClientAsync(graceful: true).ConfigureAwait(false);
|
||||
_publishGate.Dispose();
|
||||
}
|
||||
|
||||
// ---- publishing ----
|
||||
|
||||
private async Task BirthCoreAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// An NBIRTH restarts the sequence: seq = 0, by spec, on the birth itself.
|
||||
Volatile.Write(ref _seq, 0);
|
||||
_seqStarted = true;
|
||||
|
||||
var nbirth = new Payload { Timestamp = NowMs(), Seq = 0UL };
|
||||
|
||||
// bdSeq first, as Tahu's own reference edge node emits it: it is the session token the
|
||||
// matching NDEATH will carry.
|
||||
nbirth.Metrics.Add(new Payload.Types.Metric
|
||||
{
|
||||
Name = BdSeqMetricName,
|
||||
Datatype = (uint)DataType.Int64,
|
||||
Timestamp = NowMs(),
|
||||
LongValue = CurrentBdSeq(),
|
||||
});
|
||||
|
||||
// Every conformant edge node declares the rebirth control point in its NBIRTH.
|
||||
nbirth.Metrics.Add(new Payload.Types.Metric
|
||||
{
|
||||
Name = RebirthMetricName,
|
||||
Datatype = (uint)DataType.Boolean,
|
||||
Timestamp = NowMs(),
|
||||
BooleanValue = false,
|
||||
});
|
||||
|
||||
foreach (var metric in _nodeMetrics)
|
||||
{
|
||||
nbirth.Metrics.Add(EncodeBirthMetric(metric));
|
||||
}
|
||||
|
||||
await PublishAsync(NodeTopic("NBIRTH"), nbirth, _options.DataQos, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var (device, metrics) in _devices)
|
||||
{
|
||||
// A DBIRTH is a sequenced member of the edge node's stream — it does NOT restart seq, and
|
||||
// it carries no bdSeq.
|
||||
var dbirth = NewSequencedPayload();
|
||||
foreach (var metric in metrics)
|
||||
{
|
||||
dbirth.Metrics.Add(EncodeBirthMetric(metric));
|
||||
}
|
||||
|
||||
await PublishAsync(DeviceTopic("DBIRTH", device), dbirth, _options.DataQos, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
Log($"birthed (bdSeq {CurrentBdSeq()}, {_nodeMetrics.Count} node metric(s), {_devices.Count} device(s))");
|
||||
}
|
||||
|
||||
private async Task PublishDataAsync(
|
||||
string? device,
|
||||
IReadOnlyList<(string Metric, object? Value)> values,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(values);
|
||||
|
||||
var catalog = device is null
|
||||
? _nodeMetrics
|
||||
: _devices.TryGetValue(device, out var found) ? found : [];
|
||||
|
||||
await _publishGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var payload = NewSequencedPayload();
|
||||
foreach (var (name, value) in values)
|
||||
{
|
||||
var seed = catalog.FirstOrDefault(m => string.Equals(m.Name, name, StringComparison.Ordinal))
|
||||
?? throw new InvalidOperationException(
|
||||
$"Metric '{name}' is not in the current birth catalog for "
|
||||
+ $"'{GroupId}/{EdgeNodeId}{(device is null ? "" : "/" + device)}'. A DATA message can only "
|
||||
+ "carry metrics a birth declared — that is the whole point of the alias table.");
|
||||
|
||||
payload.Metrics.Add(EncodeDataMetric(seed.WithValue(value)));
|
||||
}
|
||||
|
||||
var topic = device is null ? NodeTopic("NDATA") : DeviceTopic("DDATA", device);
|
||||
await PublishAsync(topic, payload, _options.DataQos, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_publishGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PublishAsync(string topic, Payload payload, int qos, CancellationToken cancellationToken)
|
||||
{
|
||||
var client = _client ?? throw new InvalidOperationException(
|
||||
$"Simulated edge node '{GroupId}/{EdgeNodeId}' is not connected; call ConnectAsync first.");
|
||||
|
||||
var message = new MqttApplicationMessageBuilder()
|
||||
.WithTopic(topic)
|
||||
.WithPayload(payload.ToByteArray())
|
||||
.WithQualityOfServiceLevel((MqttQualityOfServiceLevel)Math.Clamp(qos, 0, 2))
|
||||
.WithRetainFlag(false) // Sparkplug never retains birth or data messages.
|
||||
.Build();
|
||||
|
||||
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
deadline.CancelAfter(TimeSpan.FromSeconds(_options.TimeoutSeconds));
|
||||
await client.PublishAsync(message, deadline.Token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// ---- inbound NCMD ----
|
||||
|
||||
private async Task OnMessageAsync(MqttApplicationMessageReceivedEventArgs args)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!IsRebirthCommand(args.ApplicationMessage))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var honoured = Interlocked.Increment(ref _rebirthCount);
|
||||
Log($"rebirth NCMD #{honoured} received; republishing metadata");
|
||||
|
||||
await _publishGate.WaitAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await BirthCoreAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_publishGate.Release();
|
||||
}
|
||||
|
||||
// Swap the signal AFTER the birth is on the wire, so a waiter that wakes on it can
|
||||
// immediately assert against the republished state rather than racing it.
|
||||
var previous = Interlocked.Exchange(
|
||||
ref _rebirthSignal,
|
||||
new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously));
|
||||
previous.TrySetResult(honoured);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// This runs on MQTTnet's dispatcher thread; a throw here stalls delivery for the client.
|
||||
Log($"NCMD handling failed: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsRebirthCommand(MqttApplicationMessage message)
|
||||
{
|
||||
if (!string.Equals(message.Topic, NodeTopic("NCMD"), StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var body = message.Payload;
|
||||
var bytes = body.IsSingleSegment ? body.FirstSpan.ToArray() : body.ToArray();
|
||||
|
||||
Payload decoded;
|
||||
try
|
||||
{
|
||||
decoded = Payload.Parser.ParseFrom(bytes);
|
||||
}
|
||||
catch (InvalidProtocolBufferException)
|
||||
{
|
||||
Log("NCMD payload was not a Sparkplug payload; ignored");
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var metric in decoded.Metrics)
|
||||
{
|
||||
if (string.Equals(metric.Name, RebirthMetricName, StringComparison.Ordinal)
|
||||
&& metric.ValueCase == Payload.Types.Metric.ValueOneofCase.BooleanValue
|
||||
&& metric.BooleanValue)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---- encoding ----
|
||||
|
||||
/// <summary>
|
||||
/// A birth metric: name, alias, datatype and value — everything a host needs to build its alias
|
||||
/// table.
|
||||
/// </summary>
|
||||
private static Payload.Types.Metric EncodeBirthMetric(SparkplugMetricSeed seed)
|
||||
{
|
||||
var metric = new Payload.Types.Metric
|
||||
{
|
||||
Name = seed.Name,
|
||||
Alias = seed.Alias,
|
||||
Datatype = (uint)seed.DataType,
|
||||
Timestamp = NowMs(),
|
||||
};
|
||||
|
||||
ApplyValue(metric, seed.DataType, seed.Value);
|
||||
return metric;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A DATA metric: <b>alias only</b>. No name, no datatype — which is how a real edge node
|
||||
/// publishes after a birth, and the shape that makes bind-by-name load-bearing on the host.
|
||||
/// </summary>
|
||||
private static Payload.Types.Metric EncodeDataMetric(SparkplugMetricSeed seed)
|
||||
{
|
||||
var metric = new Payload.Types.Metric
|
||||
{
|
||||
Alias = seed.Alias,
|
||||
Timestamp = NowMs(),
|
||||
};
|
||||
|
||||
ApplyValue(metric, seed.DataType, seed.Value);
|
||||
return metric;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a value into the metric's <c>oneof</c>, in the field Sparkplug's datatype dictates.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Signed integers go on the wire as two's complement in the UNSIGNED field</b> — Int8/16/32
|
||||
/// in <c>int_value</c>, Int64 in <c>long_value</c>. This is the encoding a host has to undo, and
|
||||
/// writing a negative value any other way would quietly retire that code path from the gate.
|
||||
/// </remarks>
|
||||
private static void ApplyValue(Payload.Types.Metric metric, DataType dataType, object? value)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
// An explicit null, not an omitted value: the two mean different things, and only the
|
||||
// explicit form says "this metric exists and currently has no value".
|
||||
metric.IsNull = true;
|
||||
return;
|
||||
}
|
||||
|
||||
switch (dataType)
|
||||
{
|
||||
case DataType.Int8:
|
||||
metric.IntValue = unchecked((uint)(sbyte)Convert.ToSByte(value, CultureInfo.InvariantCulture));
|
||||
break;
|
||||
case DataType.Int16:
|
||||
metric.IntValue = unchecked((uint)Convert.ToInt16(value, CultureInfo.InvariantCulture));
|
||||
break;
|
||||
case DataType.Int32:
|
||||
metric.IntValue = unchecked((uint)Convert.ToInt32(value, CultureInfo.InvariantCulture));
|
||||
break;
|
||||
case DataType.Int64:
|
||||
metric.LongValue = unchecked((ulong)Convert.ToInt64(value, CultureInfo.InvariantCulture));
|
||||
break;
|
||||
case DataType.Uint8:
|
||||
case DataType.Uint16:
|
||||
case DataType.Uint32:
|
||||
metric.IntValue = Convert.ToUInt32(value, CultureInfo.InvariantCulture);
|
||||
break;
|
||||
case DataType.Uint64:
|
||||
metric.LongValue = Convert.ToUInt64(value, CultureInfo.InvariantCulture);
|
||||
break;
|
||||
case DataType.Float:
|
||||
metric.FloatValue = Convert.ToSingle(value, CultureInfo.InvariantCulture);
|
||||
break;
|
||||
case DataType.Double:
|
||||
metric.DoubleValue = Convert.ToDouble(value, CultureInfo.InvariantCulture);
|
||||
break;
|
||||
case DataType.Boolean:
|
||||
metric.BooleanValue = Convert.ToBoolean(value, CultureInfo.InvariantCulture);
|
||||
break;
|
||||
case DataType.DateTime:
|
||||
metric.LongValue = value is DateTime dt
|
||||
? (ulong)new DateTimeOffset(dt.ToUniversalTime()).ToUnixTimeMilliseconds()
|
||||
: Convert.ToUInt64(value, CultureInfo.InvariantCulture);
|
||||
break;
|
||||
case DataType.String:
|
||||
case DataType.Text:
|
||||
case DataType.Uuid:
|
||||
metric.StringValue = Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty;
|
||||
break;
|
||||
case DataType.Bytes:
|
||||
case DataType.File:
|
||||
metric.BytesValue = ByteString.CopyFrom((byte[])value);
|
||||
break;
|
||||
default:
|
||||
throw new NotSupportedException(
|
||||
$"The simulator does not encode Sparkplug datatype {dataType}; v1 of the driver does "
|
||||
+ "not consume it either.");
|
||||
}
|
||||
}
|
||||
|
||||
private Payload NewSequencedPayload()
|
||||
{
|
||||
var payload = new Payload { Timestamp = NowMs(), Seq = NextSeq() };
|
||||
return payload;
|
||||
}
|
||||
|
||||
/// <summary>Advances the wrapping 0–255 sequence and returns the value to stamp.</summary>
|
||||
private ulong NextSeq()
|
||||
{
|
||||
if (!_seqStarted)
|
||||
{
|
||||
// Nothing has birthed yet: start the stream at 0 rather than at 1, so a data-before-birth
|
||||
// test still produces a legal-looking stream.
|
||||
_seqStarted = true;
|
||||
Volatile.Write(ref _seq, 0);
|
||||
return 0UL;
|
||||
}
|
||||
|
||||
var next = unchecked((byte)(Volatile.Read(ref _seq) + 1));
|
||||
Volatile.Write(ref _seq, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
private Payload BuildDeathPayload(ulong bdSeq)
|
||||
{
|
||||
// NO seq: an NDEATH is the broker-published Last Will and is not a member of the node's
|
||||
// sequence. Adding one here would be the single most plausible-looking way to make every
|
||||
// death look like a gap on the host.
|
||||
var payload = new Payload { Timestamp = NowMs() };
|
||||
payload.Metrics.Add(new Payload.Types.Metric
|
||||
{
|
||||
Name = BdSeqMetricName,
|
||||
Datatype = (uint)DataType.Int64,
|
||||
Timestamp = NowMs(),
|
||||
LongValue = bdSeq,
|
||||
});
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private ulong CurrentBdSeq() => Volatile.Read(ref _bdSeq);
|
||||
|
||||
private string NodeTopic(string type) => $"{Namespace}/{_options.GroupId}/{type}/{_options.EdgeNodeId}";
|
||||
|
||||
private string DeviceTopic(string type, string device) =>
|
||||
$"{Namespace}/{_options.GroupId}/{type}/{_options.EdgeNodeId}/{device}";
|
||||
|
||||
private static ulong NowMs() => (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
/// <summary>Nudges a seed's value so the standalone loop publishes something that visibly moves.</summary>
|
||||
private static object? Advance(SparkplugMetricSeed seed, int tick) => seed.DataType switch
|
||||
{
|
||||
DataType.Float => 20f + (tick % 50) / 10f,
|
||||
DataType.Double => 100d + (tick % 100) / 10d,
|
||||
DataType.Int32 or DataType.Int16 or DataType.Int8 => -tick,
|
||||
DataType.Int64 => (long)tick,
|
||||
DataType.Uint8 or DataType.Uint16 or DataType.Uint32 or DataType.Uint64 => (uint)tick,
|
||||
DataType.Boolean => tick % 2 == 0,
|
||||
DataType.String or DataType.Text or DataType.Uuid => $"tick-{tick}",
|
||||
_ => seed.Value,
|
||||
};
|
||||
|
||||
private void ConfigureTls(MqttClientTlsOptionsBuilder tls)
|
||||
{
|
||||
if (!_options.UseTls)
|
||||
{
|
||||
tls.UseTls(false);
|
||||
return;
|
||||
}
|
||||
|
||||
tls.UseTls(true).WithTargetHost(_options.Host);
|
||||
|
||||
if (_options.AllowUntrustedServerCertificate)
|
||||
{
|
||||
tls.WithAllowUntrustedCertificates(true)
|
||||
.WithIgnoreCertificateChainErrors(true)
|
||||
.WithCertificateValidationHandler(static _ => true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_options.CaCertificatePath))
|
||||
{
|
||||
return; // OS trust store.
|
||||
}
|
||||
|
||||
var path = _options.CaCertificatePath;
|
||||
var roots = new Lazy<X509Certificate2Collection?>(
|
||||
() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var collection = new X509Certificate2Collection();
|
||||
collection.ImportFromPemFile(path);
|
||||
return collection.Count == 0 ? null : collection;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
},
|
||||
LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
|
||||
tls.WithCertificateValidationHandler(args =>
|
||||
{
|
||||
if (roots.Value is not { Count: > 0 } trusted || args.Certificate is null)
|
||||
{
|
||||
return false; // Fail closed, exactly as the driver's own pin does.
|
||||
}
|
||||
|
||||
using var chain = new X509Chain();
|
||||
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
|
||||
chain.ChainPolicy.CustomTrustStore.AddRange(trusted);
|
||||
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
|
||||
|
||||
var presented = args.Certificate as X509Certificate2
|
||||
?? X509CertificateLoader.LoadCertificate(args.Certificate.Export(X509ContentType.Cert));
|
||||
|
||||
return chain.Build(presented);
|
||||
});
|
||||
}
|
||||
|
||||
private async Task DropClientAsync(bool graceful)
|
||||
{
|
||||
var client = _client;
|
||||
_client = null;
|
||||
if (client is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
client.ApplicationMessageReceivedAsync -= OnMessageAsync;
|
||||
|
||||
if (graceful)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
await client.DisconnectAsync(new MqttClientDisconnectOptions(), cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// The broker may already be gone; teardown is best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
client.Dispose();
|
||||
}
|
||||
|
||||
private void Log(string message) => _log?.Invoke($"[{GroupId}/{EdgeNodeId}] {message}");
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.SparkplugSimulator;
|
||||
|
||||
/// <summary>
|
||||
/// Connection + identity settings for one simulated Sparkplug B edge node.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately its own shape rather than a reuse of the driver's <c>MqttDriverOptions</c>: an edge
|
||||
/// node is the driver's <i>counterparty</i>, and reusing the driver's options record would drag the
|
||||
/// driver assembly into this project and blur which side of the wire a setting belongs to. The TLS
|
||||
/// knobs mirror the driver's by name so a rig configured for one is configured for the other.
|
||||
/// </remarks>
|
||||
public sealed record SparkplugEdgeNodeOptions
|
||||
{
|
||||
/// <summary>Broker hostname or IP.</summary>
|
||||
public string Host { get; init; } = "localhost";
|
||||
|
||||
/// <summary>Broker TCP port.</summary>
|
||||
public int Port { get; init; } = 8883;
|
||||
|
||||
/// <summary>Connect over TLS.</summary>
|
||||
public bool UseTls { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// PEM CA file pinning the broker chain. <see langword="null"/>/empty falls back to the OS
|
||||
/// trust store (or to <see cref="AllowUntrustedServerCertificate"/> when that is set).
|
||||
/// </summary>
|
||||
public string? CaCertificatePath { get; init; }
|
||||
|
||||
/// <summary>Accept any broker certificate. Fixture escape hatch; never a deployment posture.</summary>
|
||||
public bool AllowUntrustedServerCertificate { get; init; }
|
||||
|
||||
/// <summary>Broker username. The fixture broker runs <c>allow_anonymous false</c>.</summary>
|
||||
public string? Username { get; init; }
|
||||
|
||||
/// <summary>Broker password.</summary>
|
||||
public string? Password { get; init; }
|
||||
|
||||
/// <summary>The Sparkplug group id this node publishes under.</summary>
|
||||
public string GroupId { get; init; } = "Plant1";
|
||||
|
||||
/// <summary>The Sparkplug edge-node id.</summary>
|
||||
public string EdgeNodeId { get; init; } = "EdgeA";
|
||||
|
||||
/// <summary>
|
||||
/// MQTT client id. Left unset a unique one is generated — two simulated nodes sharing a client
|
||||
/// id would knock each other off the broker.
|
||||
/// </summary>
|
||||
public string? ClientId { get; init; }
|
||||
|
||||
/// <summary>Bounded connect/publish deadline, in seconds.</summary>
|
||||
public int TimeoutSeconds { get; init; } = 15;
|
||||
|
||||
/// <summary>
|
||||
/// QoS for NBIRTH/DBIRTH/NDATA/DDATA/DDEATH. Sparkplug B publishes all of these at QoS 0
|
||||
/// (only NDEATH — the broker-issued Will — and STATE are QoS 1), which is the default here.
|
||||
/// </summary>
|
||||
public int DataQos { get; init; }
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
using Org.Eclipse.Tahu.Protobuf;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.SparkplugSimulator;
|
||||
|
||||
/// <summary>
|
||||
/// One metric in a simulated edge node's (or device's) catalog: the name a birth certificate
|
||||
/// declares, the alias it binds that name to for the life of the birth, the Sparkplug datatype, and
|
||||
/// the value the birth publishes.
|
||||
/// </summary>
|
||||
/// <param name="Name">The stable metric name — what an authored tag binds to.</param>
|
||||
/// <param name="Alias">
|
||||
/// The per-birth alias. <b>Mutable across rebirths by design</b>: reassigning an alias to a
|
||||
/// different metric on a rebirth is legal Sparkplug and is §3.6 matrix case (i), the one that
|
||||
/// silently mis-routes data in a driver that binds by alias.
|
||||
/// </param>
|
||||
/// <param name="DataType">The Sparkplug datatype the birth declares for this metric.</param>
|
||||
/// <param name="Value">
|
||||
/// The value the birth publishes. <see langword="null"/> is encoded as an explicit
|
||||
/// <c>is_null</c> metric rather than as an omitted value — those mean different things on the wire.
|
||||
/// </param>
|
||||
public sealed record SparkplugMetricSeed(string Name, ulong Alias, DataType DataType, object? Value)
|
||||
{
|
||||
/// <summary>Returns this seed with a different value, leaving name/alias/type alone.</summary>
|
||||
/// <param name="value">The new value.</param>
|
||||
/// <returns>The updated seed.</returns>
|
||||
public SparkplugMetricSeed WithValue(object? value) => this with { Value = value };
|
||||
|
||||
/// <summary>Returns this seed with a different alias — the alias-reuse case.</summary>
|
||||
/// <param name="alias">The new alias.</param>
|
||||
/// <returns>The updated seed.</returns>
|
||||
public SparkplugMetricSeed WithAlias(ulong alias) => this with { Alias = alias };
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
The Sparkplug B edge-node simulator: one engine, two consumers.
|
||||
|
||||
1. `SparkplugLiveTests` (the sibling IntegrationTests project) references this project and
|
||||
drives `SparkplugEdgeNode` IN PROCESS, because the §3.6 matrix needs an edge node that can
|
||||
be TOLD to reuse an alias, skip a seq, or die — a container cannot be commanded mid-test.
|
||||
2. `Docker/docker-compose.yml`'s `sparkplug-sim` service runs the very same assembly as a
|
||||
standalone always-on edge node (see `Program`), so an operator can drive the AdminUI
|
||||
Sparkplug picker against live birth/data traffic.
|
||||
|
||||
It is therefore an Exe (a container entrypoint) that is also referenced as a library. That is
|
||||
the reason it is a nested project rather than a folder of files in the test project, and the
|
||||
reason the test project's csproj removes this directory from its default globs.
|
||||
|
||||
DEPENDENCIES ARE DELIBERATELY NARROW: `.Contracts` (for the generated Tahu `Payload` types —
|
||||
the ENCODE side of the encode/decode symmetry this fixture exists to prove) plus MQTTnet. It
|
||||
does NOT reference the runtime `.Driver` project, on purpose: a simulator that formatted its
|
||||
topics with the driver's own `SparkplugTopic.Format` could not detect a bug in it, because
|
||||
both sides of the comparison would be wrong together. The schema is shared (it IS the spec);
|
||||
the framing logic is written independently here.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Mqtt.SparkplugSimulator</RootNamespace>
|
||||
<AssemblyName>ZB.MOM.WW.OtOpcUa.Driver.Mqtt.SparkplugSimulator</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MQTTnet"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+20
@@ -33,9 +33,29 @@
|
||||
<PackageReference Include="MQTTnet"/>
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
SparkplugSimulator/ is its OWN project (an Exe: it is also the `sparkplug-sim` fixture
|
||||
container's entrypoint — see its csproj banner), nested here because the live tests are its
|
||||
other consumer. The SDK's default globs would otherwise compile its sources into this
|
||||
assembly as well, so they are removed and the project is referenced instead.
|
||||
-->
|
||||
<ItemGroup>
|
||||
<Compile Remove="SparkplugSimulator\**"/>
|
||||
<None Remove="SparkplugSimulator\**"/>
|
||||
<EmbeddedResource Remove="SparkplugSimulator\**"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Mqtt\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.csproj"/>
|
||||
<!--
|
||||
The address-picker browser, for the Sparkplug browse legs: the passive-window guarantee
|
||||
("opening a picker against a live plant publishes nothing") is only worth asserting
|
||||
against a real broker, where the assertion can be made on the WIRE rather than on the
|
||||
session's own publish counter.
|
||||
-->
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser.csproj"/>
|
||||
<ProjectReference Include="SparkplugSimulator\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.SparkplugSimulator.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
|
||||
Reference in New Issue
Block a user