diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index e07dc176..02a8700d 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -44,6 +44,7 @@ + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs new file mode 100644 index 00000000..eca93d61 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs @@ -0,0 +1,365 @@ +using System.Collections.Concurrent; +using System.Globalization; +using System.Text; +using Microsoft.Extensions.Logging; +using MQTTnet; +using ZB.MOM.WW.OtOpcUa.Commons.Browsing; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +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. In P1 nothing +/// calls it; P2's operator-triggered Sparkplug rebirth (Task 23) is the one intended exception. +/// +/// +/// 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 : IBrowseSession +{ + /// 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; + + /// Sentinel node id of the "results truncated" marker, mirroring CapturedTreeBrowseSession. + internal const string TruncatedNodeId = "__truncated__"; + + /// 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); + + private int _disposed; + private long _lastUsedTicksUtc = DateTime.UtcNow.Ticks; + private int _nodeCount; + 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; + } + + /// + /// Number of messages this session has published. Always 0 in P1 — the assertion that + /// browsing a live plant broker is strictly read-only. + /// + internal int PublishCountForTest => Volatile.Read(ref _publishCount); + + /// True once the node cap stopped recording; surfaced as a marker node from . + internal bool Truncated => Volatile.Read(ref _truncated) != 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)); + } + + 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); + } + + /// + /// + /// 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. + /// + public Task> AttributesAsync(string nodeId, CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(Disposed, this); + IReadOnlyList attrs = + _byPath.TryGetValue(nodeId ?? "", out var node) && node.Observed is { } observed + ? [new AttributeInfo(node.Segment, observed.DataType.ToString(), IsArray: false, observed.Snippet)] + : []; + Touch(); + return Task.FromResult(attrs); + } + + /// + /// 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 topic (and its payload) into the accumulating tree. 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. + /// + /// The topic the message arrived on. + /// The message body; used only for type inference and the display snippet. + internal void Observe(string topic, ReadOnlySpan payload) + { + if (Disposed || string.IsNullOrWhiteSpace(topic)) return; + + var segments = topic.Split('/'); + var level = _roots; + TopicNode? node = null; + var path = string.Empty; + + foreach (var segment in segments) + { + path = path.Length == 0 ? segment : path + "/" + segment; + + if (!level.TryGetValue(segment, out var child)) + { + if (Volatile.Read(ref _nodeCount) >= _nodeCap) + { + Volatile.Write(ref _truncated, 1); + return; // stop recording; already-recorded topics keep serving + } + + var created = new TopicNode(path, segment); + child = level.GetOrAdd(segment, created); + if (ReferenceEquals(child, created)) + { + Interlocked.Increment(ref _nodeCount); + _byPath[path] = child; + } + } + + node = child; + level = child.Children; + } + + node?.Record(InferPayload(payload)); + } + + /// 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)); + + /// + /// The one and only outbound-message seam. Nothing in P1 calls it — browse against a + /// live plant broker is strictly read-only, and is the + /// assertion of that. P2's Sparkplug RequestRebirthAsync (Task 23) is the single + /// explicitly-operator-triggered exception and 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); + 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. + /// + /// 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)"); + + string text; + try + { + // Throw-on-invalid so genuinely binary payloads are reported as such rather than + // silently rendered as a field of replacement characters. + text = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true) + .GetString(payload); + } + catch (DecoderFallbackException) + { + return new ObservedValue(DriverDataType.String, $"({payload.Length} bytes, binary)"); + } + + var trimmed = text.Trim(); + var snippet = trimmed.Length > PayloadSnippetMaxChars + ? string.Concat(trimmed.AsSpan(0, PayloadSnippetMaxChars), "…") + : trimmed; + + // 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. + var dataType = 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, snippet); + } + + /// 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 => + { + // A childless node is always the terminal segment of a topic that actually published, + // so it is the committable address. A node that both published and has children below + // it renders as a folder — MQTT allows that shape and the picker must stay expandable. + var hasChildren = !n.Children.IsEmpty; + return new BrowseNode( + n.Path, + n.Segment, + hasChildren ? BrowseNodeKind.Folder : BrowseNodeKind.Leaf, + hasChildren); + }) + .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); + + /// One segment of the observed topic space. + private sealed class TopicNode(string path, string segment) + { + /// Full slash-joined topic path — the node id the picker round-trips. + public string Path { get; } = path; + + /// This node's own topic segment — the display label. + public string Segment { get; } = segment; + + /// Child segments, keyed by segment name. + 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. + /// + public ObservedValue? Observed => Volatile.Read(ref _observed); + + private ObservedValue? _observed; + + /// Publishes the latest observation for this topic. + /// The inferred type + snippet to record. + public void Record(ObservedValue value) => Volatile.Write(ref _observed, value); + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttDriverBrowser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttDriverBrowser.cs new file mode 100644 index 00000000..76194cab --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttDriverBrowser.cs @@ -0,0 +1,172 @@ +using System.Buffers; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using MQTTnet; +using MQTTnet.Protocol; +using ZB.MOM.WW.OtOpcUa.Commons.Browsing; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser; + +/// +/// Bespoke MQTT address-picker browser: opens a short-lived, strictly passive observation +/// window against the broker named by the form's JSON. +/// +/// MQTT has no discovery protocol, so there is nothing to enumerate — the browser subscribes +/// to the wildcard (#, or {topicPrefix}/#) and lets +/// accumulate whatever arrives. Nothing on any browse path +/// publishes: an operator opening a picker must not be able to inject a message into a +/// running plant. +/// +/// +public sealed class MqttDriverBrowser : IDriverBrowser +{ + /// Floor on the open budget — a 1 s ConnectTimeoutSeconds would make browse unusable. + internal const int MinOpenBudgetSeconds = 5; + + /// Ceiling on the open budget — the picker must never hang on an unreachable broker. + internal const int MaxOpenBudgetSeconds = 30; + + /// Marks the transient browse identity in the broker's client-id/session logs. + internal const string BrowseClientIdPrefix = "-browse-"; + + /// + /// Matches the runtime driver factory's parsing so a given DriverConfig JSON means the same + /// thing to the browser as it does to the deployed driver. JsonStringEnumConverter lets + /// mode / protocolVersion be authored as their string names — the natural form + /// for AdminUI-emitted JSON — while still accepting numeric ordinals. + /// + private static readonly JsonSerializerOptions JsonOpts = new() + { + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + PropertyNameCaseInsensitive = true, + Converters = { new JsonStringEnumConverter() }, + }; + + private readonly ILogger _logger; + + /// + /// Creates a browser. Connection-free by contract — the universal browser's + /// CanBrowse constructs a throwaway instance purely to ask which driver type it + /// handles, so the constructor must never touch the network. + /// + /// Optional logger; defaults to . Never receives credentials. + public MqttDriverBrowser(ILogger? logger = null) => + _logger = logger ?? NullLogger.Instance; + + /// + // Literal rather than a constant: DriverTypeNames.Mqtt does not exist yet — Task 9 adds it, and + // owns that file. The string must stay EXACTLY "Mqtt": a DriverType string that drifts from the + // persisted one silently breaks driver dispatch (the repo's ModbusTcp/Modbus incident). + public string DriverType => "Mqtt"; + + /// + /// + /// Connects with a browse-only client id, subscribes the wildcard at QoS 0, and hands back a + /// session that serves whatever the subscription observes. The whole open is bounded by + /// ; nothing here publishes. + /// + public async Task OpenAsync(string configJson, CancellationToken cancellationToken) + { + var opts = JsonSerializer.Deserialize(configJson, JsonOpts) + ?? throw new InvalidOperationException("Mqtt options deserialized to null."); + + if (opts.Mode == MqttMode.SparkplugB) + { + // P2 seam (Task 23): Sparkplug browse is a Group → EdgeNode → Device → Metric tree built + // from observed NBIRTH/DBIRTH payloads, with an operator-triggered rebirth request as the + // one sanctioned publish. Failing fast beats serving a raw spBv1.0 topic tree that looks + // like a metric tree but is not one. + throw new NotSupportedException( + "Sparkplug B browse (Group → EdgeNode → Device → Metric) is not available yet; " + + "the MQTT browser currently observes plain topics only. Author Sparkplug tags manually."); + } + + var filter = BuildRootFilter(opts); + var suffix = BuildBrowseClientIdSuffix(); + var clientOptions = MqttConnection.BuildClientOptions(ToBrowseOptions(opts), suffix, _logger); + + using var openCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + openCts.CancelAfter(OpenBudget(opts)); + + var client = new MqttClientFactory().CreateMqttClient(); + var session = new MqttBrowseSession(opts.Mode, client, _logger); + try + { + // Runs on MQTTnet's dispatcher: record and return, nothing else. + client.ApplicationMessageReceivedAsync += args => + { + var payload = args.ApplicationMessage.Payload; + ReadOnlySpan body = payload.IsSingleSegment ? payload.FirstSpan : payload.ToArray(); + session.Observe(args.ApplicationMessage.Topic, body); + return Task.CompletedTask; + }; + + await client.ConnectAsync(clientOptions, openCts.Token).ConfigureAwait(false); + + // QoS 0: a transient observation window has no delivery guarantee to offer and should + // cost the broker as little as possible. + var subscribeOptions = new MqttClientSubscribeOptionsBuilder() + .WithTopicFilter(f => f + .WithTopic(filter) + .WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtMostOnce)) + .Build(); + await client.SubscribeAsync(subscribeOptions, openCts.Token).ConfigureAwait(false); + + _logger.LogInformation( + "AdminUI MQTT browse session opened against {Host}:{Port} observing '{Filter}' (read-only).", + opts.Host, opts.Port, filter); + + return session; + } + catch + { + await session.DisposeAsync().ConfigureAwait(false); // owns the client — disconnects + disposes + throw; + } + } + + /// + /// The wildcard the observation window subscribes to: the whole broker (#) unless the + /// config scopes it with a Plain-mode topic prefix. + /// + /// The browse configuration. + /// The MQTT topic filter to subscribe. + internal static string BuildRootFilter(MqttDriverOptions options) + { + var prefix = (options.Plain?.TopicPrefix ?? string.Empty).Trim(); + if (prefix.Length == 0) return "#"; + if (!prefix.EndsWith('/')) prefix += "/"; + return prefix + "#"; + } + + /// + /// A unique per-session client-id suffix. A broker disconnects an existing client when a new + /// one CONNECTs with the same client id, so sharing the driver's id would knock the + /// live driver offline every time an operator opened the picker. + /// + /// The suffix appended to the configured client id. + internal static string BuildBrowseClientIdSuffix() => + BrowseClientIdPrefix + Guid.NewGuid().ToString("N")[..8]; + + /// + /// Projects the authored options into the ones the browse CONNECT uses. Clean session is + /// forced: a transient browse identity must not leave queued-message state behind on the + /// broker after the picker closes. + /// + /// The authored configuration. + /// The configuration the browse CONNECT is built from. + internal static MqttDriverOptions ToBrowseOptions(MqttDriverOptions options) => + options with { CleanSession = true }; + + /// + /// Whole-open bound (connect + subscribe), clamped to + /// around the config's + /// own connect deadline. + /// + /// The browse configuration. + /// The clamped open budget. + internal static TimeSpan OpenBudget(MqttDriverOptions options) => + TimeSpan.FromSeconds(Math.Clamp(options.ConnectTimeoutSeconds, MinOpenBudgetSeconds, MaxOpenBudgetSeconds)); +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser.csproj new file mode 100644 index 00000000..bdc8e8c6 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser.csproj @@ -0,0 +1,36 @@ + + + + net10.0 + enable + enable + latest + true + ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser + ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser + + + + + + + + + + + + + + + + + + + diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowseSessionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowseSessionTests.cs new file mode 100644 index 00000000..b2a309c9 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowseSessionTests.cs @@ -0,0 +1,321 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.Browsing; +using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests; + +/// +/// Covers the passive #-observation browse session and the browser that opens it. The +/// session is exercised through its test seam (ObserveTopicForTest) rather than a live +/// broker: the accumulating tree is the whole of the P1 logic, and the MQTTnet plumbing that +/// feeds it is a one-line handler. +/// +public sealed class MqttBrowseSessionTests +{ + // ---------------------------------------------------------------- tree shape + + [Fact] + public async Task ExpandAsync_BuildsTopicSegmentTree_FromObservedTopics() + { + await using var s = new MqttBrowseSession(MqttMode.Plain); + s.ObserveTopicForTest("factory/line3/oven/temp"); + + var root = await s.RootAsync(TestContext.Current.CancellationToken); + root.ShouldContain(n => n.DisplayName == "factory"); + + var lvl2 = await s.ExpandAsync("factory", TestContext.Current.CancellationToken); + lvl2.ShouldContain(n => n.DisplayName == "line3"); + + var lvl3 = await s.ExpandAsync("factory/line3", TestContext.Current.CancellationToken); + lvl3.ShouldContain(n => n.DisplayName == "oven"); + + var lvl4 = await s.ExpandAsync("factory/line3/oven", TestContext.Current.CancellationToken); + var leaf = lvl4.ShouldHaveSingleItem(); + leaf.DisplayName.ShouldBe("temp"); + leaf.NodeId.ShouldBe("factory/line3/oven/temp"); + leaf.Kind.ShouldBe(BrowseNodeKind.Leaf); + leaf.HasChildrenHint.ShouldBeFalse(); + } + + [Fact] + public async Task ObservedTopics_AccumulateAcrossMessages_AndDoNotDuplicate() + { + await using var s = new MqttBrowseSession(MqttMode.Plain); + s.ObserveTopicForTest("factory/line3/oven/temp"); + s.ObserveTopicForTest("factory/line3/oven/temp"); // repeat — must not duplicate + s.ObserveTopicForTest("factory/line3/oven/pressure"); + s.ObserveTopicForTest("factory/line4/mixer/rpm"); + s.ObserveTopicForTest("plant/power"); + + var root = await s.RootAsync(TestContext.Current.CancellationToken); + root.Select(n => n.DisplayName).ShouldBe(["factory", "plant"]); // sorted, deduped + + var lines = await s.ExpandAsync("factory", TestContext.Current.CancellationToken); + lines.Select(n => n.DisplayName).ShouldBe(["line3", "line4"]); + + var oven = await s.ExpandAsync("factory/line3/oven", TestContext.Current.CancellationToken); + oven.Select(n => n.DisplayName).ShouldBe(["pressure", "temp"]); + } + + [Fact] + public async Task IntermediateNode_IsFolder_WithChildrenHint() + { + await using var s = new MqttBrowseSession(MqttMode.Plain); + s.ObserveTopicForTest("factory/line3/oven/temp"); + + var root = await s.RootAsync(TestContext.Current.CancellationToken); + var factory = root.ShouldHaveSingleItem(); + factory.Kind.ShouldBe(BrowseNodeKind.Folder); + factory.HasChildrenHint.ShouldBeTrue(); + } + + [Fact] + public async Task ExpandAsync_UnknownNode_ReturnsEmpty() + { + await using var s = new MqttBrowseSession(MqttMode.Plain); + s.ObserveTopicForTest("factory/line3"); + + var children = await s.ExpandAsync("nope/not/here", TestContext.Current.CancellationToken); + children.ShouldBeEmpty(); + } + + [Fact] + public async Task RootAsync_BeforeAnyTraffic_IsEmpty() + { + // MQTT has no discovery protocol: an observation window that has seen nothing shows nothing. + await using var s = new MqttBrowseSession(MqttMode.Plain); + (await s.RootAsync(TestContext.Current.CancellationToken)).ShouldBeEmpty(); + } + + // ---------------------------------------------------------------- read-only guarantee + + [Fact] + public async Task BrowseCalls_PublishNothing() // browse is read-only + { + await using var s = new MqttBrowseSession(MqttMode.Plain); + s.ObserveTopicForTest("factory/line3/oven/temp", "23.5"); + + await s.RootAsync(TestContext.Current.CancellationToken); + await s.ExpandAsync("factory", TestContext.Current.CancellationToken); + await s.AttributesAsync("factory/line3/oven/temp", TestContext.Current.CancellationToken); + + s.PublishCountForTest.ShouldBe(0); + } + + // ---------------------------------------------------------------- attributes + + [Theory] + [InlineData("23.5", nameof(Core.Abstractions.DriverDataType.Float64))] + [InlineData("42", nameof(Core.Abstractions.DriverDataType.Int64))] + [InlineData("true", nameof(Core.Abstractions.DriverDataType.Boolean))] + [InlineData("{\"v\":1}", nameof(Core.Abstractions.DriverDataType.String))] + public async Task AttributesAsync_InfersTypeFromLastPayload(string payload, string expectedType) + { + await using var s = new MqttBrowseSession(MqttMode.Plain); + s.ObserveTopicForTest("factory/oven/temp", payload); + + var attrs = await s.AttributesAsync("factory/oven/temp", TestContext.Current.CancellationToken); + var a = attrs.ShouldHaveSingleItem(); + a.Name.ShouldBe("temp"); + a.DriverDataType.ShouldBe(expectedType); + a.IsArray.ShouldBeFalse(); + a.IsAlarm.ShouldBeFalse(); + a.SecurityClass.ShouldContain(payload); // the last-payload snippet + } + + [Fact] + public async Task AttributesAsync_LongPayload_IsTruncatedToASnippet() + { + await using var s = new MqttBrowseSession(MqttMode.Plain); + s.ObserveTopicForTest("t/long", new string('x', 500)); + + var a = (await s.AttributesAsync("t/long", TestContext.Current.CancellationToken)).ShouldHaveSingleItem(); + a.SecurityClass.Length.ShouldBeLessThanOrEqualTo(MqttBrowseSession.PayloadSnippetMaxChars + 1); + } + + [Fact] + public async Task AttributesAsync_UnobservedFolder_ReturnsEmpty() + { + await using var s = new MqttBrowseSession(MqttMode.Plain); + s.ObserveTopicForTest("factory/oven/temp", "1"); + + // "factory" is a synthesised path segment — nothing was ever published to it. + (await s.AttributesAsync("factory", TestContext.Current.CancellationToken)).ShouldBeEmpty(); + (await s.AttributesAsync("no/such/node", TestContext.Current.CancellationToken)).ShouldBeEmpty(); + } + + // ---------------------------------------------------------------- concurrency + + [Fact] + public async Task ObserveDuringBrowse_IsThreadSafe() + { + // Messages arrive on MQTTnet's dispatcher thread while the picker calls ExpandAsync. + await using var s = new MqttBrowseSession(MqttMode.Plain); + using var stop = new CancellationTokenSource(); + + var observed = 0; + var writer = Task.Run(() => + { + var i = 0; + while (!stop.Token.IsCancellationRequested) + { + s.ObserveTopicForTest($"factory/line{i % 8}/dev{i % 32}/m{i}", i.ToString()); + Interlocked.Increment(ref observed); + i++; + } + }, CancellationToken.None); + + // The read loop is pure in-memory work and would otherwise finish before the writer thread + // is even scheduled, leaving the two never actually overlapping. + while (Volatile.Read(ref observed) == 0) await Task.Yield(); + + for (var i = 0; i < 400; i++) + { + await s.RootAsync(TestContext.Current.CancellationToken); + await s.ExpandAsync("factory", TestContext.Current.CancellationToken); + await s.ExpandAsync("factory/line3", TestContext.Current.CancellationToken); + } + + await stop.CancelAsync(); + await writer; // rethrows anything the observer thread hit + + (await s.RootAsync(TestContext.Current.CancellationToken)).ShouldNotBeEmpty(); + } + + // ---------------------------------------------------------------- lifetime + + [Fact] + public async Task LastUsedUtc_AdvancesOnEveryCall() + { + var s = new MqttBrowseSession(MqttMode.Plain); + var before = s.LastUsedUtc; + await Task.Delay(15, TestContext.Current.CancellationToken); + + await s.RootAsync(TestContext.Current.CancellationToken); + s.LastUsedUtc.ShouldBeGreaterThan(before); + + var afterRoot = s.LastUsedUtc; + await Task.Delay(15, TestContext.Current.CancellationToken); + await s.ExpandAsync("x", TestContext.Current.CancellationToken); + s.LastUsedUtc.ShouldBeGreaterThan(afterRoot); + + await s.DisposeAsync(); + } + + [Fact] + public async Task DisposeAsync_OnNeverOpenedSession_IsIdempotentAndDoesNotThrow() + { + var s = new MqttBrowseSession(MqttMode.Plain); + await Should.NotThrowAsync(async () => await s.DisposeAsync()); + await Should.NotThrowAsync(async () => await s.DisposeAsync()); + } + + [Fact] + public async Task AfterDispose_BrowseCallsThrowObjectDisposed() + { + var s = new MqttBrowseSession(MqttMode.Plain); + s.ObserveTopicForTest("a/b"); + await s.DisposeAsync(); + + await Should.ThrowAsync( + async () => await s.RootAsync(TestContext.Current.CancellationToken)); + await Should.ThrowAsync( + async () => await s.ExpandAsync("a", TestContext.Current.CancellationToken)); + await Should.ThrowAsync( + async () => await s.AttributesAsync("a/b", TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task ObserveAfterDispose_IsIgnored() + { + // A message already in MQTTnet's dispatcher when the reaper disposes the session. + var s = new MqttBrowseSession(MqttMode.Plain); + await s.DisposeAsync(); + Should.NotThrow(() => s.ObserveTopicForTest("late/arrival", "1")); + } + + // ---------------------------------------------------------------- node cap + + [Fact] + public async Task NodeCap_StopsRecording_AndSurfacesATruncationMarker() + { + await using var s = new MqttBrowseSession(MqttMode.Plain, nodeCap: 4); + s.ObserveTopicForTest("a/b/c"); // 3 nodes + s.ObserveTopicForTest("d/e/f"); // would take it past 4 + + var root = await s.RootAsync(TestContext.Current.CancellationToken); + root.ShouldContain(n => n.NodeId == MqttBrowseSession.TruncatedNodeId); + } + + // ---------------------------------------------------------------- the browser + + [Fact] + public void Ctor_IsConnectionFree_AndReportsTheMqttDriverType() + { + // The universal-browser CanBrowse pattern constructs a throwaway instance purely to ask + // what driver type it handles — the ctor must never touch the network. + var browser = new MqttDriverBrowser(); + browser.DriverType.ShouldBe("Mqtt"); + } + + [Fact] + public async Task OpenAsync_SparkplugMode_FailsFastWithoutConnecting() + { + var browser = new MqttDriverBrowser(); + const string cfg = """{"host":"broker.invalid","port":8883,"mode":"SparkplugB"}"""; + + await Should.ThrowAsync( + async () => await browser.OpenAsync(cfg, TestContext.Current.CancellationToken)); + } + + [Theory] + [InlineData(null, "#")] + [InlineData("", "#")] + [InlineData(" ", "#")] + [InlineData("factory", "factory/#")] + [InlineData("factory/", "factory/#")] + [InlineData("factory/line3", "factory/line3/#")] + public void BuildRootFilter_ScopesTheWildcardToTheConfiguredPrefix(string? prefix, string expected) + { + var opts = new MqttDriverOptions + { + Mode = MqttMode.Plain, + Plain = prefix is null ? null : new MqttPlainOptions { TopicPrefix = prefix }, + }; + MqttDriverBrowser.BuildRootFilter(opts).ShouldBe(expected); + } + + [Fact] + public void BrowseClientIdSuffix_IsUnique_AndIsAppliedToTheConnectClientId() + { + // A broker disconnects an existing client when a new one CONNECTs with the same id — + // browsing must never knock the running driver offline. + var a = MqttDriverBrowser.BuildBrowseClientIdSuffix(); + var b = MqttDriverBrowser.BuildBrowseClientIdSuffix(); + a.ShouldNotBe(b); + a.ShouldStartWith("-browse-"); + a.Length.ShouldBe("-browse-".Length + 8); + + var opts = new MqttDriverOptions { ClientId = "otopcua-node1" }; + MqttConnection.BuildClientOptions(opts, a).ClientId.ShouldBe("otopcua-node1" + a); + } + + [Fact] + public void BrowseConnect_ForcesACleanSession() + { + // A transient browse identity must not leave queued-message state behind on the broker. + var opts = new MqttDriverOptions { CleanSession = false }; + MqttDriverBrowser.ToBrowseOptions(opts).CleanSession.ShouldBeTrue(); + } + + [Theory] + [InlineData(1, 5)] // clamped up + [InlineData(15, 15)] + [InlineData(600, 30)] // clamped down + public void OpenBudget_IsClampedTo5To30Seconds(int configured, int expected) + { + var opts = new MqttDriverOptions { ConnectTimeoutSeconds = configured }; + MqttDriverBrowser.OpenBudget(opts).ShouldBe(TimeSpan.FromSeconds(expected)); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests.csproj index 2ad93142..f1a88765 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests.csproj +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests.csproj @@ -22,6 +22,7 @@ +