767e7031b3
Adds a project-owned, controllable Sparkplug B edge node (SparkplugEdgeNode) that
encodes with the SAME generated Tahu schema the driver decodes with — so the live
gate checks encode/decode symmetry rather than the driver against itself — and
drives the §3.6 matrix end-to-end over the real TLS+auth Mosquitto fixture.
The simulator frames its topics independently of SparkplugTopic.Format: a
simulator borrowing the parser's formatter could not detect a bug in it, because
both sides of the comparison would be wrong together. It registers a real NDEATH
Last Will at CONNECT, restarts seq at 0 on NBIRTH, publishes DATA metrics as
alias-only, carries bdSeq per session, and encodes signed ints as two's
complement in the unsigned proto field.
SparkplugLiveTests (Category=LiveIntegration, env-gated, skip-clean) covers:
birth -> alias-only data -> OnDataChange under the RawPath; late-join rebirth
NCMD honoured by an independent decoder; alias reuse across a rebirth routing by
metric NAME; a stale-bdSeq NDEATH ignored while the current one stales; the real
broker-published Will staling and the next birth restoring; seq wrap 255->0
requesting no rebirth while a deliberate gap does; and the browser's passive
window asserted ON THE WIRE (a third client watching spBv1.0/{group}/NCMD/#),
plus RequestRebirthAsync node-vs-group enumeration.
Two things make the suite falsifiable rather than decorative: the seq-wrap test
builds its ingestor with rebirthDebounce: TimeSpan.Zero (at the shipped 10s
default a wrap-triggered NCMD would be swallowed and the test would pass for the
wrong reason) and pairs the silence with a deliberate gap that must produce one;
and the passivity assertion observes the broker rather than the session's own
publish counter, which can only see the seam it guards.
Docker/docker-compose.yml gains a profile-gated `sparkplug-sim` service running
the same engine standalone against the same broker (TLS, CA-pinned) — a manual
driving aid for the AdminUI picker, deliberately NOT a test dependency, since the
matrix needs an edge node it can command mid-test. Its app directory is publish
output (publish-simulator.sh), gitignored like secrets/.
Offline: 15 skipped, 0 failed with no env set. Live: 15/15 passed against
10.100.0.35:8883. Falsifiability spot-check: mutating AliasTable.RebuildFromBirth
to merge instead of replace, and neutering SparkplugCodec.ReinterpretSigned for
Int32, turned exactly the two corresponding tests red and nothing else; both
mutations reverted.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
918 lines
37 KiB
C#
918 lines
37 KiB
C#
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}");
|
||
}
|