using System.Collections.Concurrent;
using System.Globalization;
using System.Text;
using Google.Protobuf;
using Microsoft.Extensions.Logging;
using MQTTnet;
using Org.Eclipse.Tahu.Protobuf;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
// See SparkplugTopicTests.cs for why this alias is redeclared locally in every consuming project
// rather than inherited: `SparkplugDataType` is a `global using` alias for the generated
// `Org.Eclipse.Tahu.Protobuf.DataType` (declared in Contracts/SparkplugDataType.cs), and `global using`
// scope does not cross a ProjectReference.
using SparkplugDataType = Org.Eclipse.Tahu.Protobuf.DataType;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser;
///
/// A passive MQTT observation window, served through the AdminUI's standard browse-session
/// interface.
///
/// MQTT has no discovery protocol — a broker cannot be asked "what topics exist". The only way
/// to learn the topic space is to subscribe to a wildcard and watch what arrives. So this
/// session accumulates every topic it observes into a segment tree (split on /) and
/// serves that tree one level at a time. It is therefore inherently incomplete: a topic
/// that never publishes during the window is invisible. That is a property of MQTT, not a
/// defect — the picker always keeps manual entry as the escape hatch.
///
///
/// The load-bearing safety property is that browsing publishes nothing. A picker opened
/// by an operator runs against a live production broker; if browse published anything,
/// opening an address picker could inject messages into a running plant. Every outbound
/// message must route through the single seam, which counts its
/// calls into so the guarantee is assertable.
/// — an explicit operator action, never a browse step — is
/// the one and only caller of that seam. ,
/// , , and
/// must never reach it, in either mode.
///
///
/// Sparkplug mode serves a different tree, from the same passive window. Instead of raw
/// spBv1.0/… topic segments — which look like a metric tree but are not one — the
/// session decodes observed NBIRTH/DBIRTH certificates into
/// Group → EdgeNode → [Device] → Metric, because a birth is the only Sparkplug message that
/// is self-describing (a DATA metric carries an alias and no name at all). The same node cap,
/// the same identifier bounds and the same "publishes nothing" guarantee apply.
///
///
/// Observations arrive on MQTTnet's dispatcher thread while the picker calls
/// from the UI, so the tree is built from concurrent collections and
/// every mutation is lock-free.
///
///
internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
{
/// Default cap on recorded nodes; a busy broker's # firehose is unbounded.
internal const int DefaultNodeCap = 50_000;
/// Maximum characters of a payload retained as the picker's confirm-it's-the-right-topic snippet.
internal const int PayloadSnippetMaxChars = 64;
///
/// Hard cap on the payload bytes fed to the UTF-8 decode. A # subscription receives
/// whatever the broker has — multi-MB retained blobs and JSON dumps included — and the node
/// cap does not help: a large payload on an already-known topic creates no node, it
/// just updates one, so an uncapped decode is unbounded work on MQTTnet's pump thread on
/// every single message, forever. 512 is × 4 (UTF-8's
/// worst-case bytes per char, so the snippet can always be filled) doubled, leaving headroom
/// for leading whitespace ahead of a scalar.
///
internal const int PayloadInspectMaxBytes = 512;
///
/// Cap on a whole topic name. MQTT permits ~65 KB, and the node cap bounds the node
/// count only — 50 000 nodes near that ceiling is a multi-GB tree. Topics past this
/// are rejected outright rather than truncated: a truncated topic is a different
/// topic, and the picker would commit it as a tag address.
///
internal const int MaxTopicChars = 1024;
/// Cap on one topic segment (one tree node's label + display name).
internal const int MaxSegmentChars = 256;
/// Sentinel node id of the "results truncated" marker, mirroring CapturedTreeBrowseSession.
internal const string TruncatedNodeId = "__truncated__";
/// Sentinel node id of the "oversized topics rejected" marker.
internal const string OversizedNodeId = "__oversized__";
///
/// Separates a metric name from its owning group/edge-node/device path in a Sparkplug metric's
/// node id. A group/edge-node/device id is a single MQTT topic segment and therefore cannot
/// contain /, but a metric name routinely does (Node Control/Rebirth,
/// Properties/Serial Number) — so slash-joining would make a node-level metric
/// Plant1/EdgeA/Temperature indistinguishable from a device folder of the same shape,
/// and the picker would commit the wrong address. Nothing re-parses this: it exists so the ids
/// the tree hands out are unambiguous, while every rebirth target is read from the tuple stored
/// on the node itself.
///
internal const string MetricSeparator = "::";
///
/// Ceiling on a Sparkplug birth body this session will decode. The plain path bounds its work
/// at , but a birth must be parsed whole to yield its
/// metric list, so the bound has to be on the message instead. Mirrors
/// MqttDriverOptions.MaxPayloadBytes' 1 MiB default: the protobuf parse runs on MQTTnet's
/// shared dispatcher thread, so an unbounded one is unbounded work per message, forever.
///
internal const int MaxBirthPayloadBytes = 1024 * 1024;
///
/// Cap on how many edge nodes one group-scoped will address.
/// A rebirth makes an edge node republish its entire metric set, so fanning one operator click
/// across a 200-node group is a self-inflicted broker storm. Past this the call is refused
/// whole — a partial fan-out would leave the operator unable to say which half of the
/// plant was asked to rebirth.
///
internal const int MaxGroupRebirthNodes = 32;
/// Bound on the recorded published-topic trail (a test/diagnostic aid, not a log).
private const int PublishTrailMax = 64;
///
/// Throw-on-invalid so genuinely binary payloads are reported as such rather than silently
/// rendered as a field of replacement characters. Cached: allocating an encoding per message
/// would be per-message garbage on the dispatcher thread.
///
private static readonly UTF8Encoding Utf8Strict = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
/// Bound on the courtesy DISCONNECT issued at dispose — teardown never hangs on a dead broker.
private static readonly TimeSpan DisconnectTimeout = TimeSpan.FromSeconds(5);
private readonly IMqttClient? _client;
private readonly ILogger? _logger;
private readonly int _nodeCap;
/// Flat path → node index, so is O(1) rather than a tree walk.
private readonly ConcurrentDictionary _byPath = new(StringComparer.Ordinal);
/// First-segment nodes, keyed by segment.
private readonly ConcurrentDictionary _roots = new(StringComparer.Ordinal);
///
/// The adapter that lets reach this session's client
/// through . A private nested type rather than an interface on
/// the session itself, so stays private and the counted chokepoint
/// cannot be bypassed by a future caller holding the session.
///
private readonly RebirthTransport _rebirthTransport;
/// Topics this session has published, newest last; bounded by .
private readonly ConcurrentQueue _publishedTopics = new();
private int _disposed;
private long _lastUsedTicksUtc = DateTime.UtcNow.Ticks;
private int _nodeCount;
private int _oversized;
private int _publishCount;
private int _truncated;
///
/// Test seam: an unconnected session whose tree is fed through
/// . The accumulating tree is the whole of the P1 logic, so it
/// is exercised without a live broker.
///
/// The ingest shape the session was opened for.
/// Cap on recorded nodes; defaults to .
internal MqttBrowseSession(MqttMode mode, int nodeCap = DefaultNodeCap)
: this(mode, client: null, logger: null, nodeCap) { }
/// Production constructor: takes ownership of an already-connected client.
/// The ingest shape the session was opened for.
/// The connected MQTT client; disconnected and disposed by .
/// Optional logger; never receives credentials or payload bodies.
/// Cap on recorded nodes; defaults to .
internal MqttBrowseSession(MqttMode mode, IMqttClient? client, ILogger? logger, int nodeCap = DefaultNodeCap)
{
Mode = mode;
_client = client;
_logger = logger;
_nodeCap = nodeCap;
_rebirthTransport = new RebirthTransport(this);
}
///
/// Number of messages this session has published — the assertion that browsing a live plant
/// broker is strictly read-only. Always 0 unless was
/// explicitly invoked: no browse path in either mode reaches the publish seam.
///
internal int PublishCountForTest => Volatile.Read(ref _publishCount);
/// The topics this session published, in order — the discriminating view of the above.
internal IReadOnlyList PublishedTopicsForTest => [.. _publishedTopics];
/// True once the node cap stopped recording; surfaced as a marker node from .
internal bool Truncated => Volatile.Read(ref _truncated) != 0;
///
/// True once a topic was rejected for breaching /
/// ; surfaced as its own marker node from
/// . Kept distinct from because the operator's
/// remedy differs — narrow the prefix, versus this broker publishes absurd topic names.
///
internal bool OversizedRejected => Volatile.Read(ref _oversized) != 0;
private bool Disposed => Volatile.Read(ref _disposed) != 0;
///
/// The ingest shape this session was opened for. P1 serves only;
/// the P2 Sparkplug tasks branch the tree shape (Group → EdgeNode → Device → Metric) on it.
///
internal MqttMode Mode { get; }
///
public Guid Token { get; } = Guid.NewGuid();
///
public DateTime LastUsedUtc => new(Interlocked.Read(ref _lastUsedTicksUtc), DateTimeKind.Utc);
///
public Task> RootAsync(CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(Disposed, this);
var nodes = Project(_roots);
if (Truncated)
{
nodes.Add(new BrowseNode(
TruncatedNodeId,
"⚠ Observation truncated — narrow the topic prefix or use manual entry",
BrowseNodeKind.Folder,
HasChildrenHint: false));
}
if (OversizedRejected)
{
nodes.Add(new BrowseNode(
OversizedNodeId,
Mode == MqttMode.SparkplugB
? "⚠ Some metrics were unusable (oversized or malformed names) — use manual entry for those"
: "⚠ Some topics were too long to show — use manual entry for those",
BrowseNodeKind.Folder,
HasChildrenHint: false));
}
Touch();
return Task.FromResult>(nodes);
}
///
///
/// is the full slash-joined topic path of the node
/// (e.g. factory/line3/oven) — the same value carried on the
/// the picker was handed. An unknown path returns empty rather than throwing: the tree grows
/// while the picker browses it, so a stale id is an ordinary race, not an error.
///
public Task> ExpandAsync(string nodeId, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(Disposed, this);
var children = _byPath.TryGetValue(nodeId ?? "", out var node)
? Project(node.Children)
: [];
Touch();
return Task.FromResult>(children);
}
///
///
///
/// Plain mode. A topic has no attribute model — MQTT carries an opaque payload. The
/// single synthetic attribute returned here exists to help the operator confirm they picked
/// the right topic: carries the type
/// inferred from the last observed payload (the authored tag's own dataType
/// remains the authority — inference is the brittle fallback, per design §4), and
/// carries that payload's snippet. The
/// SecurityClass slot is reused deliberately: it is the picker's free-text descriptor
/// line (rendered as {DriverDataType} · {SecurityClass}) and MQTT has no security
/// classes of its own. Returns empty for a synthesised path segment nothing was ever
/// published to.
///
///
/// Sparkplug mode is purely birth-derived — no payload is ever sniffed. A Sparkplug
/// metric declares its own type in the birth certificate, so there is nothing to
/// infer; and the plain path's UTF-8 inference / snippet machinery is not merely redundant
/// here but actively wrong, because a Sparkplug body is protobuf and would be reported as
/// (N bytes, binary) for every metric in the plant. This session therefore retains no
/// payload bytes at all in Sparkplug mode: the attribute carries the metric name, the
/// the birth declared (element type for an array variant, with
/// carrying the array bit), and a descriptor built from
/// the birth. A datatype this driver does not support in v1 is reported as such and
/// never coerced into a guessed .
///
///
public Task> AttributesAsync(string nodeId, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(Disposed, this);
IReadOnlyList attrs = [];
if (_byPath.TryGetValue(nodeId ?? "", out var node))
{
if (node.Metric is { } metric)
{
attrs = [DescribeMetric(metric)];
}
else if (node.Observed is { } observed)
{
attrs = [new AttributeInfo(node.Segment, observed.DataType.ToString(), IsArray: false, observed.Snippet)];
}
}
Touch();
return Task.FromResult(attrs);
}
///
/// Projects one observed Sparkplug birth metric into the picker's attribute row.
///
/// The metric as its birth certificate declared it.
/// The attribute row.
///
/// returns for
/// the five v1-unsupported datatypes (Unknown, DataSet, Template,
/// PropertySet, PropertySetList), and that null is passed through as the Sparkplug
/// type's own name rather than defaulted. None of those five names parses as a
/// , so the AdminUI's browse-commit mapper falls back to its
/// declared default instead of silently binding a metric this driver cannot serve — and the
/// descriptor line says so in words.
///
private static AttributeInfo DescribeMetric(SparkplugMetricInfo metric)
{
var mapped = metric.DataType?.ToDriverDataType();
var sparkplugName = metric.DataType?.ToString() ?? nameof(SparkplugDataType.Unknown);
var descriptor = new StringBuilder(metric.BirthType.ToString()).Append(" · ").Append(sparkplugName);
if (metric.Alias is { } alias) descriptor.Append(" · alias ").Append(alias);
if (mapped is null) descriptor.Append(" · unsupported in v1 — not bindable");
return new AttributeInfo(
metric.Name,
mapped?.ToString() ?? sparkplugName,
metric.DataType?.IsSparkplugArray() ?? false,
descriptor.ToString());
}
///
/// Best-effort teardown. Safe on a session that was never opened (test seam, or an
/// OpenAsync that failed between constructing the client and connecting it) and
/// idempotent under the reaper racing the picker's own close.
///
/// A task that represents the asynchronous teardown.
public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
if (_client is { } client)
{
try
{
using var cts = new CancellationTokenSource(DisconnectTimeout);
await client.DisconnectAsync(new MqttClientDisconnectOptions(), cts.Token).ConfigureAwait(false);
}
catch (Exception ex)
{
// The broker may already be gone, or never have answered the CONNECT at all.
_logger?.LogDebug(ex, "MQTT browse session disconnect failed; client abandoned.");
}
try { client.Dispose(); }
catch (Exception ex) { _logger?.LogDebug(ex, "MQTT browse client dispose failed."); }
}
_byPath.Clear();
_roots.Clear();
}
///
/// Records one observed message into the accumulating tree, in whichever shape
/// selects. Called from MQTTnet's dispatcher thread, so it does no I/O,
/// takes no lock, and never throws — a throwing handler would stall the client's pump. It also
/// never publishes: this is an observation seam, and the browser's read-only guarantee depends
/// on nothing here reaching .
///
/// The topic the message arrived on.
/// The message body.
internal void Observe(string topic, ReadOnlySpan payload)
{
if (Disposed || string.IsNullOrWhiteSpace(topic)) return;
if (Mode == MqttMode.SparkplugB)
{
ObserveSparkplugBirth(topic, payload);
return;
}
ObservePlainTopic(topic, payload);
}
///
/// Plain mode: records the topic as a path in the segment tree, decorated with the payload's
/// inferred type and display snippet.
///
/// The topic the message arrived on.
/// The message body; used only for type inference and the display snippet.
private void ObservePlainTopic(string topic, ReadOnlySpan payload)
{
if (topic.Length > MaxTopicChars)
{
Volatile.Write(ref _oversized, 1);
return;
}
var segments = topic.Split('/');
// Checked as a pre-pass so a rejected topic leaves no half-built path behind.
foreach (var segment in segments)
{
if (segment.Length > MaxSegmentChars)
{
Volatile.Write(ref _oversized, 1);
return;
}
}
var level = _roots;
TopicNode? node = null;
var path = string.Empty;
foreach (var segment in segments)
{
path = path.Length == 0 ? segment : path + "/" + segment;
node = GetOrCreate(level, path, segment, kind: null);
if (node is null) return; // node cap tripped; already-recorded topics keep serving
level = node.Children;
}
node?.Record(InferPayload(payload));
}
///
/// Sparkplug mode: decodes an observed birth certificate into the
/// Group → EdgeNode → [Device] → Metric tree.
///
/// The topic the message arrived on.
/// The Sparkplug-B protobuf body.
///
///
/// Births only. NBIRTH/DBIRTH are the only Sparkplug messages that name their
/// metrics; a DATA metric carries an alias and no name at all (that is the whole point of
/// the alias table), so a tree built from DATA would be a tree of unnamed nodes. NDEATH,
/// DDEATH, NCMD, DCMD and STATE carry no metric model worth browsing, and everything that
/// is not a Sparkplug topic at all is ignored outright rather than falling back to the
/// plain segment tree — a raw spBv1.0/… tree looks like a metric tree but is not one.
///
///
/// Node-level metrics hang directly under their edge node, not under a synthesised
/// "(node)" device folder. The authored address tuple is
/// (group, edgeNode, device?, metric) with device genuinely absent for these,
/// so an invented folder segment would be indistinguishable from a real device of that
/// name — and the picker would commit an address that does not exist. Devices render as
/// folders and metrics as leaves, so the two stay distinguishable side by side.
///
///
private void ObserveSparkplugBirth(string topic, ReadOnlySpan payload)
{
if (!SparkplugTopic.TryParse(topic, out var parsed)) return;
if (parsed.Type is not (SparkplugMessageType.NBIRTH or SparkplugMessageType.DBIRTH)) return;
// The bound has to be on the whole message: unlike a plain payload, a birth must be parsed
// entire to yield its metric list, and the parse runs on the shared dispatcher thread.
if (payload.Length > MaxBirthPayloadBytes)
{
Volatile.Write(ref _oversized, 1);
return;
}
// Never throws, for any input — see the SparkplugCodec type remarks.
if (!SparkplugCodec.TryDecode(payload, out var decoded)) return;
var groupId = parsed.GroupId!;
var edgeNodeId = parsed.EdgeNodeId!;
var deviceId = parsed.DeviceId;
if (!IsUsableLabel(groupId) || !IsUsableLabel(edgeNodeId) || (deviceId is not null && !IsUsableLabel(deviceId)))
{
Volatile.Write(ref _oversized, 1);
return;
}
var scope = new SparkplugScope(groupId, edgeNodeId);
var group = GetOrCreate(_roots, groupId, groupId, BrowseNodeKind.Folder, new SparkplugScope(groupId, null));
if (group is null) return;
var edgePath = groupId + "/" + edgeNodeId;
var edge = GetOrCreate(group.Children, edgePath, edgeNodeId, BrowseNodeKind.Folder, scope);
if (edge is null) return;
var owner = edge;
var ownerPath = edgePath;
if (deviceId is not null)
{
ownerPath = edgePath + "/" + deviceId;
owner = GetOrCreate(edge.Children, ownerPath, deviceId, BrowseNodeKind.Folder, scope);
if (owner is null) return;
}
foreach (var metric in decoded.Metrics)
{
// A birth is REQUIRED to name every metric it carries; one that does not is not something
// a picker can commit an address for.
var name = metric.Name;
if (name is null || !IsUsableLabel(name))
{
if (name is not null) Volatile.Write(ref _oversized, 1);
continue;
}
var metricPath = ownerPath + MetricSeparator + name;
if (metricPath.Length > MaxTopicChars)
{
Volatile.Write(ref _oversized, 1);
continue;
}
var node = GetOrCreate(owner.Children, metricPath, name, BrowseNodeKind.Leaf, scope);
if (node is null) return; // node cap tripped
node.RecordMetric(new SparkplugMetricInfo(name, metric.DataType, parsed.Type, metric.Alias));
}
}
///
/// Finds or creates one tree node under , honouring the node cap.
///
/// The child map to place the node in.
/// The node's full path — its browse node id, and its key.
/// The node's own label.
///
/// An explicit browse kind, or to let infer it from
/// whether the node has children. Sparkplug sets it explicitly: a device whose birth carried no
/// metrics is childless but is still a folder, and must not masquerade as a committable metric.
///
/// The Sparkplug scope to stamp on a newly created node, if any.
/// The node, or when the node cap stopped recording.
private TopicNode? GetOrCreate(
ConcurrentDictionary level,
string path,
string segment,
BrowseNodeKind? kind,
SparkplugScope? scope = null)
{
// Keyed by full path, not by label. In plain mode the two are equivalent (a path is its
// parent's path plus its label), but in Sparkplug mode a device and an edge-node-level metric
// are siblings that can share a label — `Plant1/EdgeA/Temp` and `Plant1/EdgeA::Temp` are
// different nodes, and keying by label would silently drop whichever arrived second.
if (level.TryGetValue(path, out var existing)) return existing;
if (Volatile.Read(ref _nodeCount) >= _nodeCap)
{
Volatile.Write(ref _truncated, 1);
return null;
}
var created = new TopicNode(path, segment, kind, scope);
var child = level.GetOrAdd(path, created);
if (ReferenceEquals(child, created))
{
Interlocked.Increment(ref _nodeCount);
_byPath[path] = child;
}
return child;
}
///
/// Whether an identifier off the wire is usable as a tree label. Length-bounded like a topic
/// segment, and control-character-free: a metric name is arbitrary UTF-8 chosen by the
/// publisher, and an embedded newline or ANSI escape would break the picker's single-line rows.
/// Unlike the payload snippet — which is scrubbed — a name is rejected rather than
/// cleaned, because a scrubbed name is a different name and the picker would commit it as the
/// tag's binding key.
///
/// The candidate label.
/// when the label may be recorded.
private static bool IsUsableLabel(string label)
{
if (label.Length == 0 || label.Length > MaxSegmentChars) return false;
foreach (var c in label)
{
if (char.IsControl(c)) return false;
}
return true;
}
/// Test seam over that takes the payload as text.
/// The observed topic.
/// The message body as text, or for an empty payload.
internal void ObserveTopicForTest(string topic, string? payload = null) =>
Observe(topic, payload is null ? ReadOnlySpan.Empty : Encoding.UTF8.GetBytes(payload));
/// Test seam over that takes raw wire bytes.
/// The observed topic.
/// The raw message body.
internal void ObserveRawForTest(string topic, byte[] payload) => Observe(topic, payload);
///
/// Test seam that feeds one metric's birth certificate through the real observe path —
/// it encodes an actual Sparkplug-B payload and hands it to , so the topic
/// parse, the protobuf decode and the tree build are all exercised rather than mocked around.
///
/// The Sparkplug group id.
/// The Sparkplug edge-node id.
/// The device id, or for an edge-node-level metric (NBIRTH).
/// The metric name the birth declares.
/// The datatype the birth declares.
/// The metric alias the birth declares, if any.
internal void ObserveBirthForTest(
string groupId,
string edgeNodeId,
string? device,
string metricName,
SparkplugDataType dataType,
ulong? alias = null)
{
var metric = new Payload.Types.Metric { Name = metricName, Datatype = (uint)dataType };
if (alias is { } a) metric.Alias = a;
ObserveBirth(groupId, edgeNodeId, device, metric);
}
/// Test seam feeding a birth certificate that declares no metrics at all.
/// The Sparkplug group id.
/// The Sparkplug edge-node id.
/// The device id, or for an NBIRTH.
internal void ObserveEmptyBirthForTest(string groupId, string edgeNodeId, string? device) =>
ObserveBirth(groupId, edgeNodeId, device, metric: null);
/// Encodes and observes one birth certificate.
/// The Sparkplug group id.
/// The Sparkplug edge-node id.
/// The device id, or for an NBIRTH.
/// The single metric the birth carries, or for none.
private void ObserveBirth(string groupId, string edgeNodeId, string? device, Payload.Types.Metric? metric)
{
var type = device is null ? SparkplugMessageType.NBIRTH : SparkplugMessageType.DBIRTH;
var topic = SparkplugTopic.Format(groupId, type, edgeNodeId, device);
var payload = new Payload { Seq = 0 };
if (metric is not null) payload.Metrics.Add(metric);
Observe(topic, payload.ToByteArray());
}
///
///
///
/// The one browse-session member that publishes, and it is never on a browse path.
/// Only an explicit operator click reaches it — -equivalent setup,
/// , , ,
/// and must never call it, and
/// is what makes that assertable rather than aspirational.
/// Authorization is the caller's job (BrowserSessionService enforces
/// DriverOperator): this type holds no user identity.
///
///
/// Scope resolution never re-parses an id string when it does not have to. A scope
/// that names a node in this session's own tree resolves through the
/// (group, edgeNode) tuple stamped on that node — so selecting a device or a metric
/// addresses its owning edge node, which is the only thing an NCMD can address
/// (Sparkplug has no device-scoped rebirth). A two-segment {group}/{edgeNode} scope
/// is honoured even when the window has never observed that node: a node that has not
/// birthed is the prime rebirth target, and refusing it would defeat the feature.
///
///
/// Group scope is bounded and all-or-nothing — see
/// .
///
///
public async Task RequestRebirthAsync(string scope, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(Disposed, this);
if (Mode != MqttMode.SparkplugB)
{
throw new NotSupportedException(
"A rebirth request is a Sparkplug B action; a plain-MQTT browse window publishes nothing, ever.");
}
ArgumentException.ThrowIfNullOrWhiteSpace(scope);
var targets = ResolveRebirthTargets(scope);
foreach (var (groupId, edgeNodeId) in targets)
{
// QoS 0, retain false, bounded deadline — all owned by RebirthRequester, which reaches the
// client only through this session's counted PublishAsync chokepoint.
await RebirthRequester
.RequestAsync(_rebirthTransport, groupId, edgeNodeId, cancellationToken)
.ConfigureAwait(false);
}
Touch();
return targets.Count;
}
///
/// Resolves a rebirth scope to the edge nodes an NCMD should be addressed to. Pure — it
/// publishes nothing, so an over-wide or unresolvable scope is refused before any message
/// leaves.
///
/// The scope, as documented on .
/// The distinct (group, edgeNode) targets, ordered for determinism.
/// The scope is not resolvable to any edge node.
///
/// A group scope named no observed edge node, or more than .
///
private IReadOnlyList<(string GroupId, string EdgeNodeId)> ResolveRebirthTargets(string scope)
{
// 1. A node from this session's own tree — the normal path. The target comes off the stored
// tuple, never off the id string.
if (_byPath.TryGetValue(scope, out var node) && node.Scope is { } nodeScope)
{
if (nodeScope.EdgeNodeId is { } edgeNodeId) return [(nodeScope.GroupId, edgeNodeId)];
return EnumerateGroup(nodeScope.GroupId, node);
}
var segments = scope.Split('/');
// 2. A group that is a root of this tree (identical to case 1 for an observed group, and
// reached only when the group node itself was evicted by the node cap).
if (segments.Length == 1 && _roots.TryGetValue(segments[0], out var group))
{
return EnumerateGroup(segments[0], group);
}
// 3. A bare {group}/{edgeNode} the window has never seen — the prime rebirth target.
if (segments.Length == 2 && IsUsableLabel(segments[0]) && IsUsableLabel(segments[1]))
{
return [(segments[0], segments[1])];
}
throw new ArgumentException(
$"'{scope}' does not name a Sparkplug group or edge node in this browse session; "
+ "select a node in the tree, or address one as '{group}/{edgeNode}'.",
nameof(scope));
}
/// Enumerates the observed edge nodes of one group, bounded by .
/// The group id.
/// The group's tree node.
/// The group's edge-node targets, ordered by edge-node id.
private static IReadOnlyList<(string GroupId, string EdgeNodeId)> EnumerateGroup(string groupId, TopicNode group)
{
var edgeNodes = group.Children.Values
.Select(n => n.Scope?.EdgeNodeId)
.Where(id => id is not null)
.Select(id => id!)
.Distinct(StringComparer.Ordinal)
.OrderBy(id => id, StringComparer.Ordinal)
.ToList();
if (edgeNodes.Count == 0)
{
throw new InvalidOperationException(
$"No edge node has been observed in Sparkplug group '{groupId}', so there is nothing to "
+ "address; select an edge node, or address one as '{group}/{edgeNode}'.");
}
if (edgeNodes.Count > MaxGroupRebirthNodes)
{
throw new InvalidOperationException(
$"Sparkplug group '{groupId}' has {edgeNodes.Count} observed edge nodes, more than the "
+ $"{MaxGroupRebirthNodes} one request will fan out to; select a single edge node instead. "
+ "Nothing was published.");
}
return [.. edgeNodes.Select(id => (groupId, id))];
}
///
/// The one and only outbound-message seam. Every browse path is strictly read-only, and
/// is the assertion of that;
/// — an explicit operator action — is the single sanctioned
/// caller. Anything that ever needs to publish must route through here rather than
/// touching directly, or the guarantee stops being checkable.
///
/// The message to publish.
/// Cancellation token for the publish.
/// A task that represents the asynchronous publish.
private async Task PublishAsync(MqttApplicationMessage message, CancellationToken cancellationToken)
{
Interlocked.Increment(ref _publishCount);
_publishedTopics.Enqueue(message.Topic);
while (_publishedTopics.Count > PublishTrailMax) _publishedTopics.TryDequeue(out _);
if (_client is { } client)
{
await client.PublishAsync(message, cancellationToken).ConfigureAwait(false);
}
}
///
/// Best-effort type inference from a payload body. Explicit authoring stays the authority
/// (design §4) — this only decorates the picker. Non-UTF-8 bodies are reported as opaque.
///
/// Runs on MQTTnet's dispatcher thread, so the work is bounded before any decode: at most
/// bytes are ever examined, however large the message.
///
///
/// The raw message body.
/// The inferred type and a bounded display snippet.
private static ObservedValue InferPayload(ReadOnlySpan payload)
{
if (payload.IsEmpty) return new ObservedValue(DriverDataType.String, "(empty)");
var totalBytes = payload.Length;
var clipped = totalBytes > PayloadInspectMaxBytes;
// A blind slice can cut a multi-byte sequence in half, which the strict decoder would report
// as "binary" — mislabelling a perfectly good UTF-8 payload. Back the window off to the last
// whole sequence instead.
var window = clipped ? TrimToUtf8Boundary(payload[..PayloadInspectMaxBytes]) : payload;
string text;
try
{
text = Utf8Strict.GetString(window);
}
catch (DecoderFallbackException)
{
return new ObservedValue(DriverDataType.String, $"({totalBytes} bytes, binary)");
}
var trimmed = text.Trim();
// Ordered narrowest-first. "1" infers Int64 rather than Boolean: an integer reading is the
// commoner meaning and, unlike the reverse, does not silently collapse a range to two states.
// A clipped payload is never a scalar, and inferring from a partial view could read a
// whitespace-padded prefix as a number it is not — so it is String by construction.
var dataType = clipped
? DriverDataType.String
: trimmed switch
{
_ when bool.TryParse(trimmed, out _) => DriverDataType.Boolean,
_ when long.TryParse(trimmed, NumberStyles.Integer, CultureInfo.InvariantCulture, out _)
=> DriverDataType.Int64,
_ when double.TryParse(trimmed, NumberStyles.Float, CultureInfo.InvariantCulture, out _)
=> DriverDataType.Float64,
_ => DriverDataType.String,
};
return new ObservedValue(dataType, Sanitize(trimmed, clipped));
}
///
/// Renders decoded payload text as a single-line, length-bounded snippet. Control characters
/// (newlines, tabs, ANSI escapes) become spaces — only strips them
/// at the ends, and an embedded one would break the picker's single-line attribute row.
///
/// The trimmed decoded text.
/// Whether bytes beyond the inspection window were discarded.
/// The display snippet.
private static string Sanitize(string text, bool clipped)
{
var overLength = text.Length > PayloadSnippetMaxChars;
var span = overLength ? text.AsSpan(0, PayloadSnippetMaxChars) : text.AsSpan();
// clipped, not just overLength: a huge payload whose first 512 bytes are mostly whitespace
// yields a short snippet that would otherwise claim to be the whole message.
return overLength || clipped ? Scrub(span) + "…" : Scrub(span);
}
/// Replaces control characters with spaces and trims the trailing edge.
/// The characters to scrub.
/// The scrubbed text.
private static string Scrub(ReadOnlySpan span)
{
Span buffer = span.Length <= 128 ? stackalloc char[span.Length] : new char[span.Length];
for (var i = 0; i < span.Length; i++) buffer[i] = char.IsControl(span[i]) ? ' ' : span[i];
return new string(buffer).TrimEnd();
}
///
/// Shortens a byte window so it ends on a whole UTF-8 sequence. Walks back over continuation
/// bytes (10xxxxxx) to the lead byte and drops it when the sequence it starts runs past
/// the window. A stray continuation byte is left in place — that is genuine corruption, and the
/// strict decoder should say so.
///
/// The blindly-sliced window.
/// The window, shortened to a sequence boundary.
private static ReadOnlySpan TrimToUtf8Boundary(ReadOnlySpan bytes)
{
var i = bytes.Length - 1;
var walked = 0;
while (i >= 0 && (bytes[i] & 0xC0) == 0x80 && walked < 3)
{
i--;
walked++;
}
if (i < 0) return ReadOnlySpan.Empty; // nothing but continuation bytes
var needed = bytes[i] switch
{
< 0x80 => 1, // ASCII
>= 0xF0 => 4,
>= 0xE0 => 3,
>= 0xC0 => 2,
_ => 1, // stray continuation byte — let the decoder reject it
};
return bytes.Length - i >= needed ? bytes : bytes[..i];
}
/// Projects one level of the tree into browse nodes, ordered so the picker is stable.
/// The child map to project.
/// The projected nodes, sorted by segment.
private static List Project(ConcurrentDictionary level) =>
level.Values
.OrderBy(n => n.Segment, StringComparer.Ordinal)
.Select(n =>
{
// Plain mode infers: a childless node is always the terminal segment of a topic that
// actually published, so it is the committable address, while a node that both
// published and has children below it renders as a folder — MQTT allows that shape and
// the picker must stay expandable. Sparkplug states the kind outright, because a
// device whose birth carried no metrics is childless and must still not be offered as
// a committable metric.
var hasChildren = !n.Children.IsEmpty;
var kind = n.Kind ?? (hasChildren ? BrowseNodeKind.Folder : BrowseNodeKind.Leaf);
return new BrowseNode(
n.Path,
n.Segment,
kind,
hasChildren || kind == BrowseNodeKind.Folder);
})
.ToList();
private void Touch() => Interlocked.Exchange(ref _lastUsedTicksUtc, DateTime.UtcNow.Ticks);
/// The most recent payload observed on a topic, published as one atomic reference.
/// The type inferred from that payload.
/// A bounded, display-safe rendering of that payload.
private sealed record ObservedValue(DriverDataType DataType, string Snippet);
///
/// The Sparkplug identity a tree node belongs to. Carried explicitly on the node so a rebirth
/// target is read, never parsed back out of a node-id string — the same discipline the
/// v3 address space applies to AddressSpaceRealm.
///
/// The Sparkplug group id.
///
/// The owning edge-node id, or on a group node (which addresses every
/// edge node beneath it).
///
private sealed record SparkplugScope(string GroupId, string? EdgeNodeId);
/// One metric, exactly as a birth certificate declared it. No payload bytes are retained.
/// The metric name — the tag's stable binding key across rebirths.
///
/// The declared Sparkplug datatype, or when the birth omitted it (which a
/// conformant birth never does; kept nullable because the decoder preserves absence rather than
/// defaulting it).
///
/// Which birth declared it — NBIRTH or DBIRTH.
/// The alias the birth assigned, if any. Display only: aliases are per-birth
/// and may be reused for a different metric after a rebirth, so nothing binds by them.
private sealed record SparkplugMetricInfo(
string Name,
SparkplugDataType? DataType,
SparkplugMessageType BirthType,
ulong? Alias);
/// One node of the observed tree — a topic segment in plain mode, a group / edge node /
/// device / metric in Sparkplug mode.
private sealed class TopicNode(string path, string segment, BrowseNodeKind? kind = null, SparkplugScope? scope = null)
{
/// Full path — the node id the picker round-trips.
public string Path { get; } = path;
/// This node's own label.
public string Segment { get; } = segment;
///
/// An explicit browse kind, or to infer it from the child count.
/// Set in Sparkplug mode only; see .
///
public BrowseNodeKind? Kind { get; } = kind;
/// The Sparkplug identity this node belongs to; in plain mode.
public SparkplugScope? Scope { get; } = scope;
/// Child nodes, keyed by their full path (see ).
public ConcurrentDictionary Children { get; } = new(StringComparer.Ordinal);
///
/// The last payload observed on exactly this topic, or when this is
/// a synthesised intermediate segment nothing published to. Written by the dispatcher
/// thread and read by the UI thread, so the whole record is swapped as one reference.
/// Plain mode only — Sparkplug retains no payload bytes.
///
public ObservedValue? Observed => Volatile.Read(ref _observed);
///
/// The birth-declared metric this node is, or when it is a folder
/// (or a plain-mode topic segment). Swapped as one reference for the same reason as
/// — a rebirth republishes the whole set, redeclaring types.
///
public SparkplugMetricInfo? Metric => Volatile.Read(ref _metric);
private ObservedValue? _observed;
private SparkplugMetricInfo? _metric;
/// Publishes the latest observation for this topic.
/// The inferred type + snippet to record.
public void Record(ObservedValue value) => Volatile.Write(ref _observed, value);
/// Publishes the latest birth declaration for this metric.
/// The metric as the most recent birth declared it.
public void RecordMetric(SparkplugMetricInfo value) => Volatile.Write(ref _metric, value);
}
///
/// Adapts 's publish seam onto this session's single counted
/// chokepoint. A private nested type on purpose: making the session
/// itself an would hand anyone holding it a general
/// publish-anything capability, and the read-only guarantee rests on there being exactly one
/// way out.
///
/// The session whose client the message is published on.
private sealed class RebirthTransport(MqttBrowseSession owner) : IMqttPublishTransport
{
///
public Task PublishAsync(string topic, byte[] payload, int qos, bool retain, CancellationToken cancellationToken)
{
var message = new MqttApplicationMessageBuilder()
.WithTopic(topic)
.WithPayload(payload)
.WithQualityOfServiceLevel((MQTTnet.Protocol.MqttQualityOfServiceLevel)Math.Clamp(qos, 0, 2))
.WithRetainFlag(retain)
.Build();
return owner.PublishAsync(message, cancellationToken);
}
}
}