feat(mqtt): bespoke passive #-observation browser (plain)
MQTT has no discovery protocol, so the AdminUI address picker learns the topic
space the only way there is: subscribe to the wildcard and accumulate what
arrives. MqttBrowseSession serves that observation as a segment tree (split on
'/'); MqttDriverBrowser opens it with a distinct "{clientId}-browse-{guid8}"
identity under a 5-30 s clamped budget.
Browsing publishes NOTHING - the load-bearing safety property, since the picker
runs against a live plant broker. Every outbound message must route through the
single PublishAsync seam that counts into PublishCountForTest; P2's operator-
triggered Sparkplug rebirth is the one intended exception. Sparkplug mode fails
fast rather than serving a raw spBv1.0 topic tree that looks like a metric tree.
Deviation from the plan's ref list: the project also references .Driver so the
browse CONNECT reuses MqttConnection.BuildClientOptions (TLS posture, CA-pin
chain validator, credentials). A second copy would be a security divergence.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -44,6 +44,7 @@
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/src/Drivers/Driver CLIs/">
|
||||
<Project Path="src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.csproj" />
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// A passive MQTT <b>observation window</b>, served through the AdminUI's standard browse-session
|
||||
/// interface.
|
||||
/// <para>
|
||||
/// 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 <c>/</c>) and
|
||||
/// serves that tree one level at a time. It is therefore <b>inherently incomplete</b>: 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The load-bearing safety property is that browsing publishes nothing.</b> A picker opened
|
||||
/// by an operator runs against a <i>live production broker</i>; if browse published anything,
|
||||
/// opening an address picker could inject messages into a running plant. Every outbound
|
||||
/// message must route through the single <see cref="PublishAsync"/> seam, which counts its
|
||||
/// calls into <see cref="PublishCountForTest"/> so the guarantee is assertable. In P1 nothing
|
||||
/// calls it; P2's operator-triggered Sparkplug rebirth (Task 23) is the one intended exception.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Observations arrive on MQTTnet's dispatcher thread while the picker calls
|
||||
/// <see cref="ExpandAsync"/> from the UI, so the tree is built from concurrent collections and
|
||||
/// every mutation is lock-free.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class MqttBrowseSession : IBrowseSession
|
||||
{
|
||||
/// <summary>Default cap on recorded nodes; a busy broker's <c>#</c> firehose is unbounded.</summary>
|
||||
internal const int DefaultNodeCap = 50_000;
|
||||
|
||||
/// <summary>Maximum characters of a payload retained as the picker's confirm-it's-the-right-topic snippet.</summary>
|
||||
internal const int PayloadSnippetMaxChars = 64;
|
||||
|
||||
/// <summary>Sentinel node id of the "results truncated" marker, mirroring <c>CapturedTreeBrowseSession</c>.</summary>
|
||||
internal const string TruncatedNodeId = "__truncated__";
|
||||
|
||||
/// <summary>Bound on the courtesy DISCONNECT issued at dispose — teardown never hangs on a dead broker.</summary>
|
||||
private static readonly TimeSpan DisconnectTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly IMqttClient? _client;
|
||||
private readonly ILogger? _logger;
|
||||
private readonly int _nodeCap;
|
||||
|
||||
/// <summary>Flat path → node index, so <see cref="ExpandAsync"/> is O(1) rather than a tree walk.</summary>
|
||||
private readonly ConcurrentDictionary<string, TopicNode> _byPath = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>First-segment nodes, keyed by segment.</summary>
|
||||
private readonly ConcurrentDictionary<string, TopicNode> _roots = new(StringComparer.Ordinal);
|
||||
|
||||
private int _disposed;
|
||||
private long _lastUsedTicksUtc = DateTime.UtcNow.Ticks;
|
||||
private int _nodeCount;
|
||||
private int _publishCount;
|
||||
private int _truncated;
|
||||
|
||||
/// <summary>
|
||||
/// Test seam: an unconnected session whose tree is fed through
|
||||
/// <see cref="ObserveTopicForTest"/>. The accumulating tree is the whole of the P1 logic, so it
|
||||
/// is exercised without a live broker.
|
||||
/// </summary>
|
||||
/// <param name="mode">The ingest shape the session was opened for.</param>
|
||||
/// <param name="nodeCap">Cap on recorded nodes; defaults to <see cref="DefaultNodeCap"/>.</param>
|
||||
internal MqttBrowseSession(MqttMode mode, int nodeCap = DefaultNodeCap)
|
||||
: this(mode, client: null, logger: null, nodeCap) { }
|
||||
|
||||
/// <summary>Production constructor: takes ownership of an already-connected client.</summary>
|
||||
/// <param name="mode">The ingest shape the session was opened for.</param>
|
||||
/// <param name="client">The connected MQTT client; disconnected and disposed by <see cref="DisposeAsync"/>.</param>
|
||||
/// <param name="logger">Optional logger; never receives credentials or payload bodies.</param>
|
||||
/// <param name="nodeCap">Cap on recorded nodes; defaults to <see cref="DefaultNodeCap"/>.</param>
|
||||
internal MqttBrowseSession(MqttMode mode, IMqttClient? client, ILogger? logger, int nodeCap = DefaultNodeCap)
|
||||
{
|
||||
Mode = mode;
|
||||
_client = client;
|
||||
_logger = logger;
|
||||
_nodeCap = nodeCap;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Number of messages this session has published. <b>Always 0 in P1</b> — the assertion that
|
||||
/// browsing a live plant broker is strictly read-only.
|
||||
/// </summary>
|
||||
internal int PublishCountForTest => Volatile.Read(ref _publishCount);
|
||||
|
||||
/// <summary>True once the node cap stopped recording; surfaced as a marker node from <see cref="RootAsync"/>.</summary>
|
||||
internal bool Truncated => Volatile.Read(ref _truncated) != 0;
|
||||
|
||||
private bool Disposed => Volatile.Read(ref _disposed) != 0;
|
||||
|
||||
/// <summary>
|
||||
/// The ingest shape this session was opened for. P1 serves <see cref="MqttMode.Plain"/> only;
|
||||
/// the P2 Sparkplug tasks branch the tree shape (Group → EdgeNode → Device → Metric) on it.
|
||||
/// </summary>
|
||||
internal MqttMode Mode { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Guid Token { get; } = Guid.NewGuid();
|
||||
|
||||
/// <inheritdoc />
|
||||
public DateTime LastUsedUtc => new(Interlocked.Read(ref _lastUsedTicksUtc), DateTimeKind.Utc);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<BrowseNode>> 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<IReadOnlyList<BrowseNode>>(nodes);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// <paramref name="nodeId"/> is the full slash-joined topic path of the node
|
||||
/// (e.g. <c>factory/line3/oven</c>) — the same value carried on the <see cref="BrowseNode"/>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public Task<IReadOnlyList<BrowseNode>> ExpandAsync(string nodeId, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(Disposed, this);
|
||||
var children = _byPath.TryGetValue(nodeId ?? "", out var node)
|
||||
? Project(node.Children)
|
||||
: [];
|
||||
Touch();
|
||||
return Task.FromResult<IReadOnlyList<BrowseNode>>(children);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// 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:
|
||||
/// <see cref="AttributeInfo.DriverDataType"/> carries the type <i>inferred</i> from the last
|
||||
/// observed payload (the authored tag's own <c>dataType</c> remains the authority — inference
|
||||
/// is the brittle fallback, per design §4), and <see cref="AttributeInfo.SecurityClass"/>
|
||||
/// carries that payload's snippet. The <c>SecurityClass</c> slot is reused deliberately: it is
|
||||
/// the picker's free-text descriptor line (rendered as
|
||||
/// <c>{DriverDataType} · {SecurityClass}</c>) and MQTT has no security classes of its own.
|
||||
/// Returns empty for a synthesised path segment nothing was ever published to.
|
||||
/// </remarks>
|
||||
public Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(Disposed, this);
|
||||
IReadOnlyList<AttributeInfo> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort teardown. Safe on a session that was never opened (test seam, or an
|
||||
/// <c>OpenAsync</c> that failed between constructing the client and connecting it) and
|
||||
/// idempotent under the reaper racing the picker's own close.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous teardown.</returns>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="topic">The topic the message arrived on.</param>
|
||||
/// <param name="payload">The message body; used only for type inference and the display snippet.</param>
|
||||
internal void Observe(string topic, ReadOnlySpan<byte> 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));
|
||||
}
|
||||
|
||||
/// <summary>Test seam over <see cref="Observe"/> that takes the payload as text.</summary>
|
||||
/// <param name="topic">The observed topic.</param>
|
||||
/// <param name="payload">The message body as text, or <see langword="null"/> for an empty payload.</param>
|
||||
internal void ObserveTopicForTest(string topic, string? payload = null) =>
|
||||
Observe(topic, payload is null ? ReadOnlySpan<byte>.Empty : Encoding.UTF8.GetBytes(payload));
|
||||
|
||||
/// <summary>
|
||||
/// <b>The one and only outbound-message seam.</b> Nothing in P1 calls it — browse against a
|
||||
/// live plant broker is strictly read-only, and <see cref="PublishCountForTest"/> is the
|
||||
/// assertion of that. P2's Sparkplug <c>RequestRebirthAsync</c> (Task 23) is the single
|
||||
/// explicitly-operator-triggered exception and <b>must route through here</b> rather than
|
||||
/// touching <see cref="_client"/> directly, or the guarantee stops being checkable.
|
||||
/// </summary>
|
||||
/// <param name="message">The message to publish.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the publish.</param>
|
||||
/// <returns>A task that represents the asynchronous publish.</returns>
|
||||
private async Task PublishAsync(MqttApplicationMessage message, CancellationToken cancellationToken)
|
||||
{
|
||||
Interlocked.Increment(ref _publishCount);
|
||||
if (_client is { } client)
|
||||
{
|
||||
await client.PublishAsync(message, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="payload">The raw message body.</param>
|
||||
/// <returns>The inferred type and a bounded display snippet.</returns>
|
||||
private static ObservedValue InferPayload(ReadOnlySpan<byte> 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);
|
||||
}
|
||||
|
||||
/// <summary>Projects one level of the tree into browse nodes, ordered so the picker is stable.</summary>
|
||||
/// <param name="level">The child map to project.</param>
|
||||
/// <returns>The projected nodes, sorted by segment.</returns>
|
||||
private static List<BrowseNode> Project(ConcurrentDictionary<string, TopicNode> 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);
|
||||
|
||||
/// <summary>The most recent payload observed on a topic, published as one atomic reference.</summary>
|
||||
/// <param name="DataType">The type inferred from that payload.</param>
|
||||
/// <param name="Snippet">A bounded, display-safe rendering of that payload.</param>
|
||||
private sealed record ObservedValue(DriverDataType DataType, string Snippet);
|
||||
|
||||
/// <summary>One segment of the observed topic space.</summary>
|
||||
private sealed class TopicNode(string path, string segment)
|
||||
{
|
||||
/// <summary>Full slash-joined topic path — the node id the picker round-trips.</summary>
|
||||
public string Path { get; } = path;
|
||||
|
||||
/// <summary>This node's own topic segment — the display label.</summary>
|
||||
public string Segment { get; } = segment;
|
||||
|
||||
/// <summary>Child segments, keyed by segment name.</summary>
|
||||
public ConcurrentDictionary<string, TopicNode> Children { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// The last payload observed on exactly this topic, or <see langword="null"/> 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.
|
||||
/// </summary>
|
||||
public ObservedValue? Observed => Volatile.Read(ref _observed);
|
||||
|
||||
private ObservedValue? _observed;
|
||||
|
||||
/// <summary>Publishes the latest observation for this topic.</summary>
|
||||
/// <param name="value">The inferred type + snippet to record.</param>
|
||||
public void Record(ObservedValue value) => Volatile.Write(ref _observed, value);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Bespoke MQTT address-picker browser: opens a short-lived, <b>strictly passive</b> observation
|
||||
/// window against the broker named by the form's JSON.
|
||||
/// <para>
|
||||
/// MQTT has no discovery protocol, so there is nothing to enumerate — the browser subscribes
|
||||
/// to the wildcard (<c>#</c>, or <c>{topicPrefix}/#</c>) and lets
|
||||
/// <see cref="MqttBrowseSession"/> 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class MqttDriverBrowser : IDriverBrowser
|
||||
{
|
||||
/// <summary>Floor on the open budget — a 1 s <c>ConnectTimeoutSeconds</c> would make browse unusable.</summary>
|
||||
internal const int MinOpenBudgetSeconds = 5;
|
||||
|
||||
/// <summary>Ceiling on the open budget — the picker must never hang on an unreachable broker.</summary>
|
||||
internal const int MaxOpenBudgetSeconds = 30;
|
||||
|
||||
/// <summary>Marks the transient browse identity in the broker's client-id/session logs.</summary>
|
||||
internal const string BrowseClientIdPrefix = "-browse-";
|
||||
|
||||
/// <summary>
|
||||
/// 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. <c>JsonStringEnumConverter</c> lets
|
||||
/// <c>mode</c> / <c>protocolVersion</c> be authored as their string names — the natural form
|
||||
/// for AdminUI-emitted JSON — while still accepting numeric ordinals.
|
||||
/// </summary>
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
private readonly ILogger<MqttDriverBrowser> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a browser. <b>Connection-free by contract</b> — the universal browser's
|
||||
/// <c>CanBrowse</c> constructs a throwaway instance purely to ask which driver type it
|
||||
/// handles, so the constructor must never touch the network.
|
||||
/// </summary>
|
||||
/// <param name="logger">Optional logger; defaults to <see cref="NullLogger{T}"/>. Never receives credentials.</param>
|
||||
public MqttDriverBrowser(ILogger<MqttDriverBrowser>? logger = null) =>
|
||||
_logger = logger ?? NullLogger<MqttDriverBrowser>.Instance;
|
||||
|
||||
/// <inheritdoc />
|
||||
// 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";
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// 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
|
||||
/// <see cref="OpenBudget"/>; nothing here publishes.
|
||||
/// </remarks>
|
||||
public async Task<IBrowseSession> OpenAsync(string configJson, CancellationToken cancellationToken)
|
||||
{
|
||||
var opts = JsonSerializer.Deserialize<MqttDriverOptions>(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<byte> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The wildcard the observation window subscribes to: the whole broker (<c>#</c>) unless the
|
||||
/// config scopes it with a Plain-mode topic prefix.
|
||||
/// </summary>
|
||||
/// <param name="options">The browse configuration.</param>
|
||||
/// <returns>The MQTT topic filter to subscribe.</returns>
|
||||
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 + "#";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A unique per-session client-id suffix. A broker disconnects an existing client when a new
|
||||
/// one CONNECTs with the <i>same</i> client id, so sharing the driver's id would knock the
|
||||
/// live driver offline every time an operator opened the picker.
|
||||
/// </summary>
|
||||
/// <returns>The suffix appended to the configured client id.</returns>
|
||||
internal static string BuildBrowseClientIdSuffix() =>
|
||||
BrowseClientIdPrefix + Guid.NewGuid().ToString("N")[..8];
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="options">The authored configuration.</param>
|
||||
/// <returns>The configuration the browse CONNECT is built from.</returns>
|
||||
internal static MqttDriverOptions ToBrowseOptions(MqttDriverOptions options) =>
|
||||
options with { CleanSession = true };
|
||||
|
||||
/// <summary>
|
||||
/// Whole-open bound (connect + subscribe), clamped to
|
||||
/// <see cref="MinOpenBudgetSeconds"/>–<see cref="MaxOpenBudgetSeconds"/> around the config's
|
||||
/// own connect deadline.
|
||||
/// </summary>
|
||||
/// <param name="options">The browse configuration.</param>
|
||||
/// <returns>The clamped open budget.</returns>
|
||||
internal static TimeSpan OpenBudget(MqttDriverOptions options) =>
|
||||
TimeSpan.FromSeconds(Math.Clamp(options.ConnectTimeoutSeconds, MinOpenBudgetSeconds, MaxOpenBudgetSeconds));
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser</RootNamespace>
|
||||
<AssemblyName>ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj"/>
|
||||
<!--
|
||||
Deviation from the plan's ref list (which named .Contracts only): the browse connect
|
||||
reuses MqttConnection.BuildClientOptions rather than reimplementing it. That method
|
||||
owns the TLS posture, the CA-pin chain validator and the credential handling; a second
|
||||
copy here would be a security divergence waiting to happen (browse silently not
|
||||
honouring a pinned CA while the runtime driver does). It is pure — no I/O, no network —
|
||||
so the dependency costs nothing at browse time.
|
||||
-->
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Mqtt\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MQTTnet"/>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the passive <c>#</c>-observation browse session and the browser that opens it. The
|
||||
/// session is exercised through its test seam (<c>ObserveTopicForTest</c>) 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.
|
||||
/// </summary>
|
||||
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<ObjectDisposedException>(
|
||||
async () => await s.RootAsync(TestContext.Current.CancellationToken));
|
||||
await Should.ThrowAsync<ObjectDisposedException>(
|
||||
async () => await s.ExpandAsync("a", TestContext.Current.CancellationToken));
|
||||
await Should.ThrowAsync<ObjectDisposedException>(
|
||||
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<NotSupportedException>(
|
||||
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));
|
||||
}
|
||||
}
|
||||
+1
@@ -22,6 +22,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Mqtt\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user