Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttDriverBrowser.cs
T
Joseph Doherty 7f53bd96ec fix(mqtt): close #509, #510, #512, #513, #514
Five MQTT tracker items — four AdminUI/authoring defects plus the .Browser
layering exception. No runtime driver behaviour changes.

#513 payloadFormat in a Sparkplug blob. MqttTagConfigModel.ToJson wrote the key
unconditionally. Sharper than the issue states: PayloadFormat is the one Plain
field with a NON-NULLABLE typed backing, so the write did not preserve a retyped
tag's leftover — it INVENTED "payloadFormat":"Json" in a Sparkplug tag that never
had one. Now Plain-only; preserve-unknown-keys is pinned unchanged.

#509 slashed Sparkplug metric names. The mapper already carried metricName
verbatim; only the tag Name (a RawPath segment) tripped RawPaths.ValidateSegment,
which refused the batch whole — including Node Control/Rebirth, mandatory on
every Sparkplug edge node. ToRawSegment coerces the Name and leaves the address
untouched. Coercion is not injective, so DescribeNameCollisions refuses a
colliding set BY NAME: silently merging two metrics into one tag would be worse
than the refusal it replaces. Applies to every driver, not just MQTT — the
argument is about what a Name is, not about who produced it. The sanitiser is
pinned against RawPaths.ValidateSegment rather than a copied charset.

#510 stale rebirth affordance. _canRebirth was probed once at browse-open and
sessions are reaped for idleness, so a modal left open offered an action whose
token no longer resolved. Re-probed on arm AND on confirm (the reap can land
between the two, and confirm is the one path that publishes to a live broker);
the affordance retires with an explanation instead of vanishing or erroring.

#514 rebirth on a quiet plant. The tree is birth-driven and births are never
retained, so a plant that birthed before the window opened showed an empty tree —
and the scope came only from a tree selection, so the one action that would fill
the tree could not be armed. The session already accepted a bare {group}/{edgeNode}
("the prime rebirth target"); this adds the affordance. Edge-node scope only, not
a group broadcast. The rule is extracted to SparkplugRebirthScope so it is
testable — this repo has no bUnit — and is advisory: the session re-resolves and
refuses before publishing.

#512 .Browser -> .Driver layering. TWO extractions were needed, not the one the
issue anticipated: it costed this as "the client-options builder", but the browse
session decodes births itself, so moving only that would have left the reference
exactly where it was.
  - MqttClientOptionsFactory (TLS, PEM CA-pin validator, credentials, protocol
    mapping) -> new leaf .Transport project. NOT .Contracts: that is deliberately
    transport-free and MqttClientOptions is an MQTTnet type, so .Contracts would
    hand MQTTnet to ControlPlane. This is the fix the .Browser csproj comment had
    already prescribed, against the issue's suggestion.
  - The Sparkplug protocol layer (SparkplugTopic/MessageType, SparkplugCodec,
    RebirthRequester) -> .Contracts, beside the Tahu wire schema it encodes.
Still exactly one copy of the TLS/CA-pin logic; MqttBrowserLayeringTests guards
the graph both ways.

Measured, not assumed: Driver.Mqtt.dll, Polly.Core and Serilog are now absent
from the AdminUI's output. Core.dll remains, because Driver.Sql.Browser has the
SAME violation, undocumented — the old comment blamed this browser for Core and
was wrong. Filed separately rather than fixed in passing.

Full suite green apart from the 7 known pre-existing failures (4 AbLegacy
integration, compose profiles not up; 3 Host.IntegrationTests).

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-07-31 06:17:18 -04:00

193 lines
10 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Buffers;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using MQTTnet;
using MQTTnet.Protocol;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
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 a wildcard (<c>#</c> / <c>{topicPrefix}/#</c> in plain mode, <c>spBv1.0/{groupId}/#</c>
/// in Sparkplug mode) and lets <see cref="MqttBrowseSession"/> accumulate whatever arrives:
/// a topic segment tree in plain mode, a Group → EdgeNode → Device → Metric tree decoded from
/// observed birth certificates in Sparkplug mode. Nothing on any browse path publishes — in
/// either mode: an operator opening a picker must not be able to inject a message into a
/// running plant. The session's operator-triggered <c>RequestRebirthAsync</c> is the sole
/// sanctioned publish, and it is never reached by <c>OpenAsync</c> or by any browse call.
/// </para>
/// <para>
/// <b>Layering note.</b> This project depends on the shared leaves only — <c>.Contracts</c> for
/// the Sparkplug wire schema and codec, <c>.Transport</c> for
/// <see cref="MqttClientOptionsFactory"/> — and NOT on the runtime <c>.Driver</c> project, which
/// it used to reference under a documented exception (Gitea #512). Reuse of the TLS / CA-pin path
/// is what that exception bought, and it is preserved: there is still exactly one copy, now in
/// <c>.Transport</c>. Do not fork it. Pinned by <c>MqttBrowserLayeringTests</c>.
/// </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-";
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 />
public string DriverType => DriverTypeNames.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)
{
// MqttJson.Options — the ONE shared instance across factory / probe / driver / browser
// (see its remarks). Parsing the same DriverConfig blob through a second, divergent
// JsonSerializerOptions is this repo's documented systemic enum bug: the picker would accept
// a `mode` / `protocolVersion` spelling the deployed driver rejects, or vice versa.
var opts = JsonSerializer.Deserialize<MqttDriverOptions>(configJson, MqttJson.Options)
?? throw new InvalidOperationException("Mqtt options deserialized to null.");
var filter = BuildRootFilter(opts);
var suffix = BuildBrowseClientIdSuffix();
var clientOptions = MqttClientOptionsFactory.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} in {Mode} mode observing "
+ "'{Filter}' (read-only).",
opts.Host, opts.Port, opts.Mode, filter);
return session;
}
catch
{
await session.DisposeAsync().ConfigureAwait(false); // owns the client — disconnects + disposes
throw;
}
}
/// <summary>
/// The wildcard the observation window subscribes to.
/// <para>
/// <b>Plain mode:</b> the whole broker (<c>#</c>) unless the config scopes it with a
/// topic prefix.
/// </para>
/// <para>
/// <b>Sparkplug mode:</b> the Sparkplug namespace only —
/// <c>spBv1.0/{groupId}/#</c>, matching the group the deployed driver itself subscribes
/// (design §3.1), or <c>spBv1.0/#</c> when no group has been authored yet, so the picker
/// can discover which groups exist. The Plain-mode topic prefix is deliberately ignored:
/// it is a different mode's key, and honouring it would silently narrow the window to a
/// prefix that cannot match a Sparkplug topic at all.
/// </para>
/// </summary>
/// <param name="options">The browse configuration.</param>
/// <returns>The MQTT topic filter to subscribe.</returns>
internal static string BuildRootFilter(MqttDriverOptions options)
{
if (options.Mode == MqttMode.SparkplugB)
{
var groupId = (options.Sparkplug?.GroupId ?? string.Empty).Trim();
return groupId.Length == 0 ? "spBv1.0/#" : $"spBv1.0/{groupId}/#";
}
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.
/// <para>
/// ⚠ <b>THE landmine — a Last Will would break the read-only guarantee from outside it.</b>
/// <c>MqttDriverOptions</c> (and <c>MqttSparkplugOptions</c>) carry no will, <b>re-verified
/// when Task 23 unsealed Sparkplug browse</b>, so there is nothing to strip and the
/// projection stays a single <c>CleanSession</c> override. If a will is ever added — NDEATH
/// <i>is</i> a will message — it MUST be cleared here: a will is published by the
/// <i>broker</i>, not by us, so it is invisible to
/// <see cref="MqttBrowseSession.PublishCountForTest"/>, and any ungraceful end to a browse
/// session (crash, dropped link, the disconnect deadline expiring) would then fire an
/// NDEATH under the plant's own edge-node identity — killing a live Sparkplug node because
/// an operator opened an address picker. <c>MqttBrowseSessionTests</c>'
/// <c>MqttDriverOptions_CarryNothingWillShaped_…</c> is the reflection guard that turns that
/// "there is nothing to strip" into a checked fact rather than a claim.
/// </para>
/// </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));
}