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:
Joseph Doherty
2026-07-24 15:45:32 -04:00
parent a6370a26f8
commit 7980b34692
6 changed files with 896 additions and 0 deletions
@@ -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));
}
@@ -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>