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