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}"); }