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;
///
/// A controllable Sparkplug B edge node — the driver's counterparty on a real broker.
/// Registers an NDEATH Last Will at CONNECT, publishes NBIRTH/DBIRTH then N/DDATA, honours a
/// Node Control/Rebirth NCMD, and exposes the §3.6 pathologies (alias reuse across a
/// rebirth, a seq gap, a stale-bdSeq death) as explicit, on-demand operations.
///
///
///
/// Encodes with the generated Tahu types, frames topics independently. The payloads are
/// built from — the same generated schema the driver decodes with, which
/// is what makes this fixture a genuine encode/decode symmetry check. The topic strings
/// are composed here from literals rather than through the driver's own
/// SparkplugTopic.Format: 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.
///
///
/// What "the way a real edge node does it" means, concretely, and why each detail matters:
///
///
/// -
/// NDEATH is the broker-published Last Will, registered at CONNECT — not a message
/// this class publishes on the way out. It carries a bdSeq metric and no
/// seq, because it is not a member of the node's sequence.
/// makes the broker fire it.
///
/// -
/// bdSeq increments once per CONNECT 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.
///
/// -
/// NBIRTH restarts seq at 0; DBIRTH/NDATA/DDATA/DDEATH continue it, wrapping
/// 255 → 0.
///
/// -
/// DATA metrics carry an alias and nothing else — 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.
///
/// -
/// Signed integers ride as two's complement in the UNSIGNED proto field. A
/// DataType.Int32 metric holding −1234 goes on the wire as int_value =
/// 4294966062. Encoding it "helpfully" as a positive number would silently retire the
/// driver's ReinterpretSigned path from the live gate.
///
///
///
/// Not thread-safe for concurrent publishes: seq 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
/// .
///
///
public sealed class SparkplugEdgeNode : IAsyncDisposable
{
/// The well-known Sparkplug node-control metric an NCMD sets to trigger a rebirth.
public const string RebirthMetricName = "Node Control/Rebirth";
/// The Sparkplug session-token metric name, carried by NBIRTH and NDEATH.
public const string BdSeqMetricName = "bdSeq";
private const string Namespace = "spBv1.0";
private readonly SparkplugEdgeNodeOptions _options;
private readonly Action? _log;
private readonly SemaphoreSlim _publishGate = new(1, 1);
/// Device id → its metric catalog. Ordered so a birth's metric order is deterministic.
private readonly ConcurrentDictionary> _devices = new(StringComparer.Ordinal);
private IMqttClient? _client;
private IReadOnlyList _nodeMetrics = [];
private ulong _bdSeq;
private int _sessions;
private byte _seq;
private bool _seqStarted;
private int _rebirthCount;
private int _disposed;
private TaskCompletionSource _rebirthSignal = new(TaskCreationOptions.RunContinuationsAsynchronously);
/// Initializes a new simulated edge node. Does no I/O — call .
/// Broker connection + Sparkplug identity.
/// Optional line sink for the standalone runner's console output.
public SparkplugEdgeNode(SparkplugEdgeNodeOptions options, Action? log = null)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentException.ThrowIfNullOrWhiteSpace(options.GroupId);
ArgumentException.ThrowIfNullOrWhiteSpace(options.EdgeNodeId);
_options = options;
_log = log;
}
/// The Sparkplug group id.
public string GroupId => _options.GroupId;
/// The Sparkplug edge-node id.
public string EdgeNodeId => _options.EdgeNodeId;
/// The current session token — incremented on every .
public ulong BdSeq => Volatile.Read(ref _bdSeq);
/// The seq of the most recently published sequenced message.
public byte LastSeq => Volatile.Read(ref _seq);
/// How many rebirth NCMDs this node has honoured since construction.
public int RebirthCount => Volatile.Read(ref _rebirthCount);
/// Whether the underlying MQTT client currently holds a session.
public bool IsConnected => _client?.IsConnected ?? false;
/// The device ids this node currently publishes for.
public IReadOnlyCollection Devices => [.. _devices.Keys];
///
/// 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.
///
/// The catalog.
public void SetNodeMetrics(params SparkplugMetricSeed[] metrics) =>
_nodeMetrics = [.. metrics ?? []];
/// Replaces one device's (DBIRTH-level) metric catalog. Takes effect at the next birth.
/// The device id.
/// The catalog.
public void SetDeviceMetrics(string deviceId, params SparkplugMetricSeed[] metrics)
{
ArgumentException.ThrowIfNullOrWhiteSpace(deviceId);
_devices[deviceId] = [.. metrics ?? []];
}
///
/// 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.
///
///
/// The Will is registered here or not at all. 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.
///
/// Cancellation for the connect.
/// A task that completes when the session is established and NCMD is subscribed.
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")}");
}
///
/// Publishes the node's birth certificate — NBIRTH at seq = 0, then one DBIRTH per
/// device continuing the same sequence.
///
/// Cancellation for the publishes.
/// A task that completes when every birth message has been published.
public async Task BirthAsync(CancellationToken cancellationToken)
{
await _publishGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await BirthCoreAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
_publishGate.Release();
}
}
///
/// Publishes an NDATA carrying the named node-level metrics, encoded the way a real edge node
/// does: alias only, resolved from the current birth catalog.
///
/// Metric name → new value. A name not in the catalog throws.
/// Cancellation for the publish.
/// A task that completes when the message has been published.
public Task PublishNodeDataAsync(
IReadOnlyList<(string Metric, object? Value)> values,
CancellationToken cancellationToken) =>
PublishDataAsync(device: null, values, cancellationToken);
/// Publishes a DDATA for one device — alias only, same sequence as the node's own stream.
/// The device id.
/// Metric name → new value.
/// Cancellation for the publish.
/// A task that completes when the message has been published.
public Task PublishDeviceDataAsync(
string deviceId,
IReadOnlyList<(string Metric, object? Value)> values,
CancellationToken cancellationToken) =>
PublishDataAsync(deviceId, values, cancellationToken);
///
/// Publishes an NDATA whose metrics carry an alias the current birth does not declare —
/// the "unknown alias" arm of §3.6.
///
/// The undeclared alias.
/// A double value, encoded as double_value.
/// Cancellation for the publish.
/// A task that completes when the message has been published.
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();
}
}
///
/// Burns sequence numbers without publishing them — the next message
/// therefore arrives with a seq 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.
///
/// How many sequence numbers to skip; must be at least 1.
public void SkipSeq(int count = 1)
{
ArgumentOutOfRangeException.ThrowIfLessThan(count, 1);
for (var i = 0; i < count; i++)
{
NextSeq();
}
}
///
/// Publishes an NDEATH directly, carrying . 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.
///
/// The session token to stamp; uses the current one.
/// Cancellation for the publish.
/// A task that completes when the message has been published.
///
/// A real NDEATH is broker-issued (see ); this is the deterministic
/// equivalent for the ordering case a test cannot otherwise stage — a will that arrives
/// after the reconnect's NBIRTH.
///
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();
}
}
/// Publishes a DDEATH for one device — sequenced, unlike an NDEATH.
/// The device id.
/// Cancellation for the publish.
/// A task that completes when the message has been published.
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();
}
}
///
/// Ends the session in a way that makes the broker publish this node's registered
/// NDEATH Will — the realistic death path.
///
/// Cancellation for the disconnect.
/// A task that completes once the client has gone.
///
/// Uses the MQTT 5 DISCONNECT reason code 0x04 Disconnect with Will Message, 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 deterministically
/// 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.
///
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");
}
///
/// Awaits the -th honoured rebirth NCMD. Pre-checks what has already
/// happened, so a caller that registers after the command landed is served immediately.
///
/// The rebirth count to wait for.
/// Bound on the wait.
/// Caller cancellation.
/// The rebirth count once it has reached .
public async Task 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.
}
}
}
///
/// The standalone (container) loop: birth once, then publish node + device data every
/// until cancelled. Rebirth NCMDs are honoured throughout.
///
/// Publish cadence.
/// Stops the loop.
/// A task that completes when the loop is cancelled.
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}");
}
}
}
///
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(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 ----
///
/// A birth metric: name, alias, datatype and value — everything a host needs to build its alias
/// table.
///
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;
}
///
/// A DATA metric: alias only. 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.
///
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;
}
///
/// Writes a value into the metric's oneof, in the field Sparkplug's datatype dictates.
///
///
/// Signed integers go on the wire as two's complement in the UNSIGNED field — Int8/16/32
/// in int_value, Int64 in long_value. 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.
///
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;
}
/// Advances the wrapping 0–255 sequence and returns the value to stamp.
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();
/// Nudges a seed's value so the standalone loop publishes something that visibly moves.
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(
() =>
{
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}");
}