Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/SparkplugSimulator/SparkplugEdgeNode.cs
T
Joseph Doherty 767e7031b3 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
2026-07-24 23:18:08 -04:00

918 lines
37 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 0255 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}");
}