feat(mqtt): MqttDriver shell — lifecycle + authored-only discovery (Once)

Composes MqttConnection + MqttSubscriptionManager + LastValueCache into the
IDriver / ITagDiscovery / ISubscribable / IReadable / IHostConnectivityProbe /
IRediscoverable capability set.

- Discovery replays ONLY the authored raw tags (RediscoverPolicy = Once,
  SupportsOnlineDiscovery = false) — a chatty broker can never auto-provision.
- Composition order is register -> AttachTo -> ConnectAsync; the manager's
  Reconnected handler is passed through unwrapped so its throw-on-total-failure
  still tears a deaf session down.
- ReinitializeAsync applies a tag-only delta in place and never faults on a bad
  one; a session-changing delta rebuilds.
- ReadAsync serves the last-value cache and degrades per reference
  (BadWaitingForInitialData), never throwing for the batch.
- FlushOptionalCachesAsync is a no-op: the last-value cache IS IReadable.
- Adds MqttDriverOptions.RawTags (the authored-tag delivery mechanism every
  other driver's options DTO already has) and promotes MaxPayloadBytes from a
  manager ctor knob to an operator-facing key.
- Converges on MqttDriverProbe.JsonOpts rather than a second, divergent
  JsonSerializerOptions.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-24 17:06:39 -04:00
parent 211c6ea6d6
commit 420692b6e5
3 changed files with 1073 additions and 0 deletions
@@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations;
using System.Text;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
@@ -82,9 +83,40 @@ public sealed record MqttDriverOptions
[Range(1, int.MaxValue)]
public int ReconnectMaxBackoffSeconds { get; init; } = 30;
/// <summary>
/// Ceiling on an inbound message body, in bytes. A larger message is refused before any
/// decode or parse and degrades its own tags to <c>BadDecodingError</c>. Default 1 MiB.
/// </summary>
/// <remarks>
/// Decode and parse both run synchronously on MQTTnet's shared dispatcher thread, so their
/// cost is paid by <i>every</i> subscription, not just the offending topic — without a bound,
/// one publisher shipping a multi-megabyte body stalls the whole driver's delivery. "Unbounded"
/// is deliberately not offered; a non-positive value falls back to the driver's own 1 MiB
/// default. The literal is duplicated from <c>MqttSubscriptionManager.DefaultMaxPayloadBytes</c>
/// because this <c>.Contracts</c> assembly is <i>referenced by</i> the driver assembly and
/// cannot reference it back; the manager's constructor is the single enforcement point.
/// </remarks>
[Range(1, int.MaxValue)]
public int MaxPayloadBytes { get; init; } = 1024 * 1024;
/// <summary>Selects the ingest shape — Plain topics or Sparkplug B.</summary>
public MqttMode Mode { get; init; } = MqttMode.Plain;
/// <summary>
/// The cluster's authored raw MQTT tags, as delivered by the deploy artifact: each carries the
/// tag's <b>RawPath</b> (its v3 identity and driver wire reference) plus its <c>TagConfig</c>
/// blob. <see cref="MqttDriver"/> maps each through <see cref="MqttTagDefinitionFactory"/> at
/// initialize and re-registers the set <b>wholesale</b> on every reinitialize, so a redeploy
/// that drops a tag stops feeding it.
/// </summary>
/// <remarks>
/// This is also the <b>only</b> source of the driver's discoverable node set — plain MQTT has
/// no browsable address space, and a tag that is not authored here is never materialized no
/// matter how much traffic its topic carries. Mirrors <c>ModbusDriverOptions.RawTags</c> /
/// <c>FocasDriverOptions.RawTags</c>.
/// </remarks>
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = [];
/// <summary>
/// Sparkplug-only settings. Populated when <see cref="Mode"/> is
/// <see cref="MqttMode.SparkplugB"/>; <c>null</c> in Plain mode.
@@ -127,9 +159,13 @@ public sealed record MqttDriverOptions
builder.Append(", ConnectTimeoutSeconds = ").Append(ConnectTimeoutSeconds);
builder.Append(", ReconnectMinBackoffSeconds = ").Append(ReconnectMinBackoffSeconds);
builder.Append(", ReconnectMaxBackoffSeconds = ").Append(ReconnectMaxBackoffSeconds);
builder.Append(", MaxPayloadBytes = ").Append(MaxPayloadBytes);
builder.Append(", Mode = ").Append(Mode);
builder.Append(", Sparkplug = ").Append(Sparkplug);
builder.Append(", Plain = ").Append(Plain);
// Count only: a deployment routinely authors thousands of raw tags and each carries a full
// TagConfig blob, so printing the list would turn any log of this DTO into a config dump.
builder.Append(", RawTags = ").Append(RawTags.Count).Append(" tag(s)");
return true;
}
}
@@ -0,0 +1,732 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
/// <summary>
/// The MQTT / Sparkplug B driver shell: composes <see cref="MqttConnection"/> (broker session +
/// reconnect supervisor), <see cref="MqttSubscriptionManager"/> (authored table, topic routing,
/// value extraction) and the manager's <see cref="LastValueCache"/> (the push→poll bridge) into
/// the driver capability set the Core consumes.
/// </summary>
/// <remarks>
/// <para>
/// <b>Authored-only discovery.</b> Plain MQTT has no browsable address space — a broker will
/// happily carry thousands of topics this deployment has nothing to do with.
/// <see cref="DiscoverAsync"/> therefore replays exactly the raw tags the deploy artifact
/// authored (<see cref="MqttDriverOptions.RawTags"/>) and nothing else, and
/// <see cref="ITagDiscovery.SupportsOnlineDiscovery"/> stays <see langword="false"/> so the
/// universal browser never treats broker traffic as a tag catalogue. The policy is
/// <see cref="DiscoveryRediscoverPolicy.Once"/>: the authored set is fully known
/// synchronously, so re-running discovery on a timer can only produce the same tree.
/// </para>
/// <para>
/// <b>P1 scope — no Sparkplug.</b> <see cref="IRediscoverable"/> is implemented but
/// <see cref="OnRediscoveryNeeded"/> never fires in <see cref="MqttMode.Plain"/>: the
/// authored set only changes by redeploy. Sparkplug B flips the policy to
/// <see cref="DiscoveryRediscoverPolicy.UntilStable"/> and raises the event on DBIRTH — that
/// is a later task, and the seam here (<see cref="RaiseRediscoveryNeeded"/>) exists for it.
/// </para>
/// <para>
/// <b>Composition order is load-bearing.</b> <see cref="MqttSubscriptionManager.AttachTo"/>
/// must run <i>before</i> any subscribe and before the connect completes — it is what wires
/// message delivery and, critically, the reconnect re-subscribe. The connection's
/// <c>Reconnected</c> handler is passed through unwrapped: the manager throws when it can
/// re-establish nothing, and that throw is precisely what tears the session down and retries.
/// Swallowing it would leave a driver that reports Connected and receives nothing.
/// </para>
/// <para>
/// <b>Constructor is connection-free.</b> It maps the authored TagConfig blobs (pure, no I/O)
/// so discovery and reads work off a configured-but-not-yet-connected driver; every network
/// operation happens in <see cref="InitializeAsync"/>.
/// </para>
/// </remarks>
public sealed class MqttDriver
: IDriver, ITagDiscovery, ISubscribable, IReadable, IHostConnectivityProbe, IRediscoverable, IAsyncDisposable, IDisposable
{
/// <summary>
/// Rough per-tag driver-attributable cost: the parsed <see cref="MqttTagDefinition"/> (five
/// strings — RawPath, topic, JSONPath and the record header) plus its cached
/// <see cref="DataValueSnapshot"/> and the two dictionary entries that index it. An estimate
/// by construction — <c>GetMemoryFootprint</c> exists to drive a cache-budget decision, not
/// to be exact.
/// </summary>
private const long ApproxBytesPerAuthoredTag = 512;
/// <summary>How often the host-connectivity poll samples <see cref="MqttConnection.State"/>.</summary>
private static readonly TimeSpan HostProbeInterval = TimeSpan.FromSeconds(1);
private readonly string _driverInstanceId;
private readonly ILogger? _logger;
/// <summary>
/// Owned by the driver, not by the manager, so a manager rebuilt for changed ingest settings
/// inherits the observed values instead of silently regressing every node to
/// <c>BadWaitingForInitialData</c>.
/// </summary>
private readonly LastValueCache _values = new();
private readonly object _hostLock = new();
/// <summary>Guards <see cref="InitializeAsync"/> / <see cref="ReinitializeAsync"/> / <see cref="ShutdownAsync"/> against each other.</summary>
private readonly SemaphoreSlim _lifecycleGate = new(1, 1);
private MqttDriverOptions _options;
private MqttSubscriptionManager _subscriptions;
/// <summary>
/// RawPaths of the currently registered authored tags, in authoring order — the discovery
/// enumeration set. The manager resolves a RawPath to its definition but does not enumerate,
/// and enumerating from here is what makes "authored only" structural rather than incidental.
/// </summary>
private IReadOnlyList<string> _authoredRawPaths = [];
private MqttConnection? _connection;
private CancellationTokenSource? _hostProbeCts;
private DriverHealth _health = new(DriverState.Unknown, null, null);
private HostState _hostState = HostState.Unknown;
private DateTime _hostStateChangedUtc = DateTime.UtcNow;
private int _disposed;
/// <summary>Initializes a new driver. Stores + maps configuration only — no network, no client.</summary>
/// <param name="options">
/// Driver configuration, including the authored <see cref="MqttDriverOptions.RawTags"/>.
/// Task 9's factory deserializes it from the <c>DriverConfig</c> JSON; a later
/// <see cref="ReinitializeAsync"/> may replace it from a fresh config blob.
/// </param>
/// <param name="driverInstanceId">Stable logical id of this driver instance.</param>
/// <param name="logger">Optional logger; never receives credentials or payload bodies.</param>
public MqttDriver(MqttDriverOptions options, string driverInstanceId, ILogger<MqttDriver>? logger = null)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
_options = options;
_driverInstanceId = driverInstanceId;
_logger = logger;
_subscriptions = CreateSubscriptionManager(options);
RegisterAuthoredTags(options);
}
/// <inheritdoc />
public event EventHandler<DataChangeEventArgs>? OnDataChange;
/// <inheritdoc />
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
/// <inheritdoc />
public event EventHandler<RediscoveryEventArgs>? OnRediscoveryNeeded;
/// <inheritdoc />
public string DriverInstanceId => _driverInstanceId;
/// <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" and match MqttDriverProbe.DriverType: a
// DriverType that drifts from the persisted one silently breaks dispatch (the ModbusTcp/Modbus
// incident).
public string DriverType => "Mqtt";
/// <summary>
/// The ingest path this driver composes. Internal: the P2 Sparkplug handler feeds the same
/// <c>OnDataChange</c> + <see cref="LastValueCache"/> sinks through it, and the shell tests
/// drive <see cref="MqttSubscriptionManager.HandleMessage"/> to simulate broker traffic
/// without a broker.
/// </summary>
internal MqttSubscriptionManager Subscriptions => _subscriptions;
// ---- IDriver: lifecycle ----
/// <summary>
/// Adopts <paramref name="driverConfigJson"/> (falling back to the constructor's options when
/// it is blank or unusable), registers the authored tags, and opens the broker session.
/// </summary>
/// <remarks>
/// Ordering is the contract: register → attach → connect. Attaching before the connect
/// completes means the reconnect re-subscribe is wired for the very first drop, and the
/// manager's SUBSCRIBE transport is live before the OPC UA server's first subscribe arrives.
/// </remarks>
/// <param name="driverConfigJson">The driver configuration as JSON.</param>
/// <param name="cancellationToken">Cancellation for the connect.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
await _lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await InitializeCoreAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
}
finally
{
_lifecycleGate.Release();
}
}
/// <summary>
/// Applies a config delta in place. A tag-only delta re-registers the authored table without
/// touching the broker session; a delta that changes how the driver connects or ingests
/// rebuilds the session.
/// </summary>
/// <remarks>
/// <b>A bad delta never faults the driver.</b> Unparseable or unusable config is logged and
/// discarded, leaving the running configuration exactly as it was — a redeploy carrying one
/// malformed driver blob must not take a healthy driver's whole address space Bad. A genuine
/// connect failure against a <i>changed</i> endpoint is a different thing and does fault,
/// exactly as <see cref="InitializeAsync"/> would.
/// </remarks>
/// <param name="driverConfigJson">The driver configuration as JSON.</param>
/// <param name="cancellationToken">Cancellation for any reconnect the delta forces.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
MqttDriverOptions? next;
try
{
next = ParseOptions(driverConfigJson);
}
catch (Exception ex)
{
// Defence in depth: ParseOptions already swallows the JSON failure modes.
_logger?.LogWarning(
ex,
"MQTT driver '{DriverId}': reinitialize config could not be read; keeping the running configuration.",
_driverInstanceId);
return;
}
if (next is null)
{
_logger?.LogWarning(
"MQTT driver '{DriverId}': reinitialize config was blank or unusable; keeping the running configuration.",
_driverInstanceId);
return;
}
await _lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (!SameSession(next, _options))
{
// Broker endpoint / credentials / ingest shape changed — the live session cannot be
// reused. Full rebuild; a failure here is a real fault, not a bad delta.
_logger?.LogInformation(
"MQTT driver '{DriverId}': reinitialize changes the broker session; rebuilding.",
_driverInstanceId);
await TeardownAsync().ConfigureAwait(false);
await InitializeCoreAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
return;
}
_options = next;
RegisterAuthoredTags(next);
_logger?.LogInformation(
"MQTT driver '{DriverId}': reinitialize applied in place; {TagCount} authored tag(s).",
_driverInstanceId,
_authoredRawPaths.Count);
}
finally
{
_lifecycleGate.Release();
}
}
/// <inheritdoc />
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
var lastMessage = ReadHealth().LastSuccessfulRead;
if (await _lifecycleGate.WaitAsync(TimeSpan.FromSeconds(_options.ConnectTimeoutSeconds), cancellationToken)
.ConfigureAwait(false))
{
try
{
await TeardownAsync().ConfigureAwait(false);
}
finally
{
_lifecycleGate.Release();
}
}
else
{
// A wedged initialize must not turn shutdown into a hang; tear the session down anyway.
_logger?.LogWarning(
"MQTT driver '{DriverId}': lifecycle gate still held at shutdown; tearing the session down anyway.",
_driverInstanceId);
await TeardownAsync().ConfigureAwait(false);
}
WriteHealth(new DriverHealth(DriverState.Unknown, lastMessage, null));
TransitionHost(HostState.Unknown);
}
/// <summary>
/// Current health. Derived live from the connection's own state where one exists, so a
/// background reconnect is visible without the driver having to mirror every transition.
/// <c>LastSuccessfulRead</c> carries the last inbound message instant — MQTT never polls, so
/// "last message age" is the only evidence the session is actually delivering.
/// </summary>
/// <returns>The driver's current health snapshot.</returns>
public DriverHealth GetHealth()
{
var stored = ReadHealth();
var connection = _connection;
if (connection is null)
{
return stored;
}
var lastMessage = connection.LastMessageUtc ?? stored.LastSuccessfulRead;
return connection.State switch
{
MqttConnectionState.Connected => new DriverHealth(DriverState.Healthy, lastMessage, stored.LastError),
MqttConnectionState.Reconnecting => new DriverHealth(DriverState.Reconnecting, lastMessage, stored.LastError),
MqttConnectionState.Faulted => new DriverHealth(DriverState.Faulted, lastMessage, stored.LastError),
// Disconnected before the first successful connect, or Disposed after teardown: the
// stored snapshot (Initializing / Faulted / Unknown) is the more informative answer.
_ => stored with { LastSuccessfulRead = lastMessage },
};
}
/// <summary>
/// Approximate driver-attributable footprint: the authored definition table and the
/// last-value cache that indexes it. Both are sized by the authored tag count, which is the
/// only thing a deployment can grow.
/// </summary>
/// <returns>The approximate memory footprint in bytes.</returns>
public long GetMemoryFootprint() => _authoredRawPaths.Count * ApproxBytesPerAuthoredTag;
/// <summary>
/// No-op, deliberately. This driver holds no optional cache: the authored table is the
/// address space itself, and the last-value cache <b>is</b> <see cref="IReadable"/> — MQTT is
/// subscribe-first, so a dropped value is not re-fetchable, and flushing it would turn every
/// node Bad until its topic next published, which for a slow-changing tag can be never.
/// Birth/alias state (Sparkplug, P2) is correctness state for the same reason.
/// </summary>
/// <param name="cancellationToken">Unused; present for the <see cref="IDriver"/> shape.</param>
/// <returns>A completed task.</returns>
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
// ---- ITagDiscovery ----
/// <inheritdoc />
/// <remarks>
/// Plain mode discovers synchronously from the authored set, so one pass is all there is.
/// Sparkplug B (P2) fills its metric set in asynchronously from birth certificates and
/// switches this to <see cref="DiscoveryRediscoverPolicy.UntilStable"/>.
/// </remarks>
public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once;
/// <inheritdoc />
/// <remarks>
/// Always <see langword="false"/>: <see cref="DiscoverAsync"/> replays authored tags, it does
/// not enumerate a backend. A broker's live topic set is not a tag catalogue — the
/// <c>Driver.Mqtt.Browser</c> project is the surface for observing traffic during authoring.
/// </remarks>
public bool SupportsOnlineDiscovery => false;
/// <summary>
/// Streams the authored tag set — and only that — into <paramref name="builder"/>.
/// </summary>
/// <remarks>
/// The enumeration source is the driver's own list of registered RawPaths, never anything
/// observed on the wire, so no amount of broker traffic can add a node. A RawPath whose
/// TagConfig failed to map is absent from the manager and is skipped here too — it surfaces
/// as <c>BadNodeIdUnknown</c> on read rather than as a wrongly-typed variable.
/// </remarks>
/// <param name="builder">The address space builder to stream discovered nodes into.</param>
/// <param name="cancellationToken">Cancellation for the discovery operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(builder);
cancellationToken.ThrowIfCancellationRequested();
var folder = builder.Folder("Mqtt", "Mqtt");
foreach (var rawPath in _authoredRawPaths)
{
if (!_subscriptions.TryResolve(rawPath, out var def))
{
continue;
}
folder.Variable(def.Name, def.Name, new DriverAttributeInfo(
FullName: def.Name,
DriverDataType: def.DataType,
IsArray: false,
ArrayDim: null,
// MQTT ingest is one-way in P1 — this driver implements no IWritable leg, so every
// node is ViewOnly rather than an Operate node whose writes would silently vanish.
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: false));
}
return Task.CompletedTask;
}
// ---- ISubscribable (straight through to the manager) ----
/// <inheritdoc />
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences,
TimeSpan publishingInterval,
CancellationToken cancellationToken)
=> _subscriptions.SubscribeAsync(fullReferences, publishingInterval, cancellationToken);
/// <inheritdoc />
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
=> _subscriptions.UnsubscribeAsync(handle, cancellationToken);
// ---- IReadable ----
/// <summary>
/// Serves the batch from the last-value cache. MQTT is push-based: there is nothing to poll,
/// so a read is always "what was most recently published".
/// </summary>
/// <remarks>
/// <b>Never throws.</b> A reference that has not been observed — including one that is not an
/// authored tag at all — returns <c>BadWaitingForInitialData</c> in its own slot. Failing the
/// whole call would let one stale reference blank every other node in the same OPC UA read.
/// </remarks>
/// <param name="fullReferences">The RawPath references to read.</param>
/// <param name="cancellationToken">Unused; the read touches no network.</param>
/// <returns>One snapshot per requested reference, in request order.</returns>
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(fullReferences);
var results = new DataValueSnapshot[fullReferences.Count];
for (var i = 0; i < fullReferences.Count; i++)
{
results[i] = _subscriptions.Values.Read(fullReferences[i]);
}
return Task.FromResult<IReadOnlyList<DataValueSnapshot>>(results);
}
// ---- IHostConnectivityProbe ----
/// <summary>The single broker this driver instance talks to, as <c>host:port</c>.</summary>
public string HostName => $"{_options.Host}:{_options.Port}";
/// <inheritdoc />
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses()
{
lock (_hostLock)
{
return [new HostConnectivityStatus(HostName, _hostState, _hostStateChangedUtc)];
}
}
// ---- IRediscoverable ----
/// <summary>
/// Raises <see cref="OnRediscoveryNeeded"/>. Nothing in <see cref="MqttMode.Plain"/> calls it
/// — the authored set only changes by redeploy. It exists for the Sparkplug B path, where a
/// DBIRTH can introduce metrics the previous birth certificate did not carry.
/// </summary>
/// <param name="reason">Driver-supplied reason for the diagnostic log.</param>
/// <param name="scopeHint">Optional subtree hint; <see langword="null"/> means the whole tree.</param>
internal void RaiseRediscoveryNeeded(string reason, string? scopeHint = null)
=> OnRediscoveryNeeded?.Invoke(this, new RediscoveryEventArgs(reason, scopeHint));
// ---- disposal ----
/// <inheritdoc />
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
/// <summary>
/// Performs the same teardown as <see cref="ShutdownAsync"/> so a caller that uses
/// <c>await using</c> without an explicit shutdown does not leak a live broker session.
/// </summary>
/// <returns>A task that represents the asynchronous dispose.</returns>
public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
// The lifecycle gate is deliberately NOT disposed — same call as MqttConnection's semaphores.
// A caller that disposes before its last ShutdownAsync would otherwise get an
// ObjectDisposedException naming SemaphoreSlim, which says nothing useful; the gate holds no
// timer and no surviving registration, so leaving it undisposed costs nothing.
await TeardownAsync().ConfigureAwait(false);
}
// ---- internals ----
/// <summary>The gate-held body of <see cref="InitializeAsync"/>.</summary>
private async Task InitializeCoreAsync(string driverConfigJson, CancellationToken cancellationToken)
{
WriteHealth(new DriverHealth(DriverState.Initializing, ReadHealth().LastSuccessfulRead, null));
try
{
// A blank / unusable blob keeps the constructor's options rather than degrading the
// driver to defaults pointing at localhost.
var parsed = ParseOptions(driverConfigJson);
if (parsed is not null)
{
AdoptOptions(parsed);
}
RegisterAuthoredTags(_options);
var connection = new MqttConnection(_options, _driverInstanceId, _logger);
// MUST precede the connect: this wires message delivery AND the reconnect re-subscribe.
// The manager's Reconnected handler is passed through unwrapped on purpose — its
// throw-on-total-failure is what tears a deaf session down and retries it.
_subscriptions.AttachTo(connection);
await connection.ConnectAsync(cancellationToken).ConfigureAwait(false);
_connection = connection;
WriteHealth(new DriverHealth(DriverState.Healthy, connection.LastMessageUtc, null));
TransitionHost(HostState.Running);
StartHostProbe();
}
catch (Exception ex)
{
WriteHealth(new DriverHealth(DriverState.Faulted, ReadHealth().LastSuccessfulRead, ex.Message));
TransitionHost(HostState.Faulted);
throw;
}
}
/// <summary>
/// Deserializes a driver-config blob. Returns <see langword="null"/> — never throws — when the
/// blob is blank or unusable, so both the initialize fallback and the reinitialize
/// keep-running-config rule read off one answer.
/// </summary>
private MqttDriverOptions? ParseOptions(string driverConfigJson)
{
if (string.IsNullOrWhiteSpace(driverConfigJson))
{
return null;
}
try
{
// The probe's shared instance, deliberately: enums must round-trip by NAME. A second,
// divergent JsonSerializerOptions is this repo's documented systemic enum bug, where an
// AdminUI-authored config with a numeric enum field faults the driver.
return JsonSerializer.Deserialize<MqttDriverOptions>(driverConfigJson, MqttDriverProbe.JsonOpts);
}
catch (JsonException ex)
{
_logger?.LogWarning(
ex,
"MQTT driver '{DriverId}': driver config JSON is invalid.",
_driverInstanceId);
return null;
}
catch (NotSupportedException ex)
{
_logger?.LogWarning(
ex,
"MQTT driver '{DriverId}': driver config JSON could not be bound to the options shape.",
_driverInstanceId);
return null;
}
}
/// <summary>
/// Adopts new options, rebuilding the subscription manager only when a setting it captures at
/// construction actually changed. The rebuilt manager inherits the driver-owned
/// <see cref="LastValueCache"/>, so adopting new options never blanks observed values.
/// </summary>
private void AdoptOptions(MqttDriverOptions next)
{
if (!SameIngest(next, _options))
{
_subscriptions = CreateSubscriptionManager(next);
}
_options = next;
}
/// <summary>Builds a manager over the driver-owned cache and bridges its notifications to <see cref="OnDataChange"/>.</summary>
private MqttSubscriptionManager CreateSubscriptionManager(MqttDriverOptions options)
{
var manager = new MqttSubscriptionManager(
options,
_driverInstanceId,
transport: null,
logger: _logger,
cache: _values,
maxPayloadBytes: options.MaxPayloadBytes);
// The published reference is the RawPath the manager already stamped on the args; the driver
// only re-raises with itself as sender.
manager.OnDataChange += (_, args) => OnDataChange?.Invoke(this, args);
return manager;
}
/// <summary>Registers the authored raw tags wholesale and refreshes the discovery enumeration set.</summary>
private void RegisterAuthoredTags(MqttDriverOptions options)
{
var mapped = _subscriptions.Register(options.RawTags);
// Enumerate in authoring order; the manager owns which of these actually mapped, and
// DiscoverAsync skips the rest.
_authoredRawPaths = [.. options.RawTags.Select(t => t.RawPath)];
if (mapped != options.RawTags.Count)
{
_logger?.LogWarning(
"MQTT driver '{DriverId}': {Mapped} of {Authored} authored tag(s) mapped to a definition; "
+ "the rest will report BadNodeIdUnknown.",
_driverInstanceId,
mapped,
options.RawTags.Count);
}
}
/// <summary>
/// Whether two option sets describe the same broker session. Note the explicit
/// <see cref="object.Equals(object?)"/>: the identity projections are <c>object</c>-typed
/// anonymous instances, so <c>!=</c> would compare <b>references</b> and report "changed"
/// every single time — which would tear down and rebuild a perfectly good session on every
/// redeploy, and (via <see cref="SameIngest"/>) swap in a subscription manager that is not
/// attached to the live connection.
/// </summary>
private static bool SameSession(MqttDriverOptions a, MqttDriverOptions b)
=> SessionIdentity(a).Equals(SessionIdentity(b));
/// <summary>Whether two option sets produce an identical <see cref="MqttSubscriptionManager"/>.</summary>
private static bool SameIngest(MqttDriverOptions a, MqttDriverOptions b)
=> IngestIdentity(a).Equals(IngestIdentity(b));
/// <summary>
/// The settings whose change invalidates the live broker session. Projected onto an anonymous
/// type (structural <c>Equals</c>) rather than comparing the whole options record, whose
/// <c>RawTags</c> list would make every comparison unequal by reference.
/// </summary>
private static object SessionIdentity(MqttDriverOptions o) => new
{
o.Host,
o.Port,
o.ClientId,
o.UseTls,
o.AllowUntrustedServerCertificate,
o.CaCertificatePath,
o.Username,
o.Password,
o.ProtocolVersion,
o.CleanSession,
o.KeepAliveSeconds,
o.ConnectTimeoutSeconds,
o.ReconnectMinBackoffSeconds,
o.ReconnectMaxBackoffSeconds,
Ingest = IngestIdentity(o),
};
/// <summary>The settings <see cref="MqttSubscriptionManager"/> captures at construction.</summary>
private static object IngestIdentity(MqttDriverOptions o) => new
{
o.Mode,
DefaultQos = o.Plain?.DefaultQos,
o.MaxPayloadBytes,
};
/// <summary>Shared teardown for <see cref="ShutdownAsync"/>, <see cref="DisposeAsync"/> and the session rebuild.</summary>
private async Task TeardownAsync()
{
var probe = Interlocked.Exchange(ref _hostProbeCts, null);
if (probe is not null)
{
await probe.CancelAsync().ConfigureAwait(false);
probe.Dispose();
}
var connection = Interlocked.Exchange(ref _connection, null);
if (connection is not null)
{
await connection.DisposeAsync().ConfigureAwait(false);
}
}
/// <summary>
/// Starts the host-connectivity poll. <see cref="MqttConnection"/> exposes state but no
/// state-changed event, and an <see cref="IHostConnectivityProbe"/> whose event never fires is
/// a capability that looks wired and is inert — so the transition signal is sampled here.
/// </summary>
private void StartHostProbe()
{
var cts = new CancellationTokenSource();
var previous = Interlocked.Exchange(ref _hostProbeCts, cts);
if (previous is not null)
{
// Cancel before disposing: disposing a live source leaves its loop running against a
// token that will never be cancelled, and a second probe would fight the first.
previous.Cancel();
previous.Dispose();
}
_ = Task.Run(() => HostProbeLoopAsync(cts.Token), cts.Token);
}
private async Task HostProbeLoopAsync(CancellationToken ct)
{
try
{
while (!ct.IsCancellationRequested)
{
await Task.Delay(HostProbeInterval, ct).ConfigureAwait(false);
var connection = _connection;
if (connection is null)
{
continue;
}
TransitionHost(connection.State switch
{
MqttConnectionState.Connected => HostState.Running,
MqttConnectionState.Reconnecting or MqttConnectionState.Disconnected => HostState.Stopped,
MqttConnectionState.Faulted => HostState.Faulted,
_ => HostState.Unknown,
});
}
}
catch (OperationCanceledException)
{
// Shutdown.
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "MQTT driver '{DriverId}': host connectivity probe stopped.", _driverInstanceId);
}
}
/// <summary>Publishes a host state transition once; repeats of the current state are ignored.</summary>
private void TransitionHost(HostState next)
{
HostState previous;
lock (_hostLock)
{
if (_hostState == next)
{
return;
}
previous = _hostState;
_hostState = next;
_hostStateChangedUtc = DateTime.UtcNow;
}
OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(HostName, previous, next));
}
/// <summary>Barrier-protected read of the multi-threaded health field.</summary>
private DriverHealth ReadHealth() => Volatile.Read(ref _health);
/// <summary>Barrier-protected publish of a new health snapshot.</summary>
private void WriteHealth(DriverHealth value) => Volatile.Write(ref _health, value);
}
@@ -0,0 +1,305 @@
using System.Text;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
/// <summary>
/// Shell-level tests for <see cref="MqttDriver"/>: authored-only discovery, the lifecycle
/// surface (<c>Reinitialize</c> / <c>Shutdown</c> / health / footprint / cache flush), and the
/// <c>IReadable</c> batch contract. Nothing here dials a broker — every test exercises the
/// connection-free half of the driver, which is exactly the half a chatty broker must not be
/// able to influence.
/// </summary>
[Trait("Category", "Unit")]
public sealed class MqttDriverDiscoveryTests
{
/// <summary>
/// An authored TagConfig blob. <c>dataType</c> uses the real <see cref="DriverDataType"/>
/// member names — there is no <c>Double</c>; the 64-bit float is <c>Float64</c>.
/// </summary>
private static string TagJson(string topic, string dataType = "String", string payloadFormat = "Raw")
=> $$"""{"topic":"{{topic}}","payloadFormat":"{{payloadFormat}}","dataType":"{{dataType}}"}""";
private static RawTagEntry Tag(string rawPath, string topic, string dataType = "String")
=> new(rawPath, TagJson(topic, dataType), WriteIdempotent: false);
private static MqttDriver PlainDriver(params RawTagEntry[] tags)
=> new(new MqttDriverOptions { Mode = MqttMode.Plain, RawTags = tags }, "d", null);
/// <summary>
/// Plain mode replays ONLY the authored tag set: one variable per authored raw tag, a
/// single discovery pass (<see cref="DiscoveryRediscoverPolicy.Once"/>), and no online
/// discovery — the universal browser must never treat a broker's topic traffic as a
/// browsable address space.
/// </summary>
[Fact]
public async Task DiscoverAsync_Plain_StreamsAuthoredTagsOnly_PolicyOnce()
{
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
var b = new CapturingAddressSpaceBuilder();
await driver.DiscoverAsync(b, CancellationToken.None);
b.Variables.Count.ShouldBe(1);
b.Variables[0].Info.FullName.ShouldBe("Plant/Mqtt/dev1/Temp");
driver.RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.Once);
((ITagDiscovery)driver).SupportsOnlineDiscovery.ShouldBeFalse();
}
/// <summary>
/// The falsifiability pin for authored-only discovery: a message that arrives on a topic no
/// authored tag names must not add anything to the discovered set. A driver that
/// auto-provisioned from broker traffic would grow the address space on every deploy against
/// a chatty broker.
/// </summary>
[Fact]
public async Task DiscoverAsync_UnauthoredBrokerTraffic_DoesNotAppear()
{
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
// Traffic this driver never authored — a neighbouring publisher on the same broker.
driver.Subscriptions.HandleMessage("some/other/topic", Encoding.UTF8.GetBytes("42"), retained: false);
driver.Subscriptions.HandleMessage("f/t/deeper", Encoding.UTF8.GetBytes("42"), retained: false);
var b = new CapturingAddressSpaceBuilder();
await driver.DiscoverAsync(b, CancellationToken.None);
b.Variables.Select(v => v.Info.FullName).ShouldBe(["Plant/Mqtt/dev1/Temp"]);
}
/// <summary>A raw tag whose TagConfig does not map is skipped, never thrown, and never discovered.</summary>
[Fact]
public async Task DiscoverAsync_SkipsUnmappableTagConfig()
{
var driver = PlainDriver(
Tag("Plant/Mqtt/dev1/Good", "f/t"),
new RawTagEntry("Plant/Mqtt/dev1/Bad", "not-json", WriteIdempotent: false));
var b = new CapturingAddressSpaceBuilder();
await driver.DiscoverAsync(b, CancellationToken.None);
b.Variables.Select(v => v.Info.FullName).ShouldBe(["Plant/Mqtt/dev1/Good"]);
}
/// <summary>Discovered MQTT variables are read-only — this driver has no <c>IWritable</c> leg.</summary>
[Fact]
public async Task DiscoverAsync_MarksVariablesViewOnly()
{
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t", "Float64"));
var b = new CapturingAddressSpaceBuilder();
await driver.DiscoverAsync(b, CancellationToken.None);
b.Variables[0].Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
b.Variables[0].Info.DriverDataType.ShouldBe(DriverDataType.Float64);
}
/// <summary>
/// The falsifiability pin for the batch-read contract: a reference that is not an authored
/// tag degrades to <c>BadWaitingForInitialData</c> in its own slot rather than throwing and
/// failing every other reference in the same batch.
/// </summary>
[Fact]
public async Task ReadAsync_UnknownReference_DoesNotThrow_AndDegradesPerRef()
{
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
driver.Subscriptions.HandleMessage("f/t", Encoding.UTF8.GetBytes("hot"), retained: false);
var results = await driver.ReadAsync(
["Plant/Mqtt/dev1/Temp", "Plant/Mqtt/dev1/NeverAuthored"],
CancellationToken.None);
results.Count.ShouldBe(2);
results[0].StatusCode.ShouldBe(0u);
results[0].Value.ShouldBe("hot");
results[1].StatusCode.ShouldBe(0x80320000u); // BadWaitingForInitialData
}
/// <summary>An empty batch is legal and returns an empty result, not an exception.</summary>
[Fact]
public async Task ReadAsync_EmptyBatch_ReturnsEmpty()
{
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
var results = await driver.ReadAsync([], CancellationToken.None);
results.ShouldBeEmpty();
}
/// <summary>
/// The falsifiability pin for the cache-flush contract: the last-value cache backs
/// <c>IReadable</c>, so flushing it would silently turn every subscribed node Bad under
/// memory pressure. <c>FlushOptionalCachesAsync</c> must leave it untouched.
/// </summary>
[Fact]
public async Task FlushOptionalCachesAsync_LeavesLastValueCacheIntact()
{
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
driver.Subscriptions.HandleMessage("f/t", Encoding.UTF8.GetBytes("hot"), retained: false);
await driver.FlushOptionalCachesAsync(CancellationToken.None);
var results = await driver.ReadAsync(["Plant/Mqtt/dev1/Temp"], CancellationToken.None);
results[0].StatusCode.ShouldBe(0u);
results[0].Value.ShouldBe("hot");
}
/// <summary>A malformed reinitialize delta must never fault the driver, and must not lose the running config.</summary>
[Fact]
public async Task ReinitializeAsync_MalformedDelta_KeepsRunningConfig_AndDoesNotFault()
{
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
await Should.NotThrowAsync(() => driver.ReinitializeAsync("{ this is not json", CancellationToken.None));
driver.GetHealth().State.ShouldNotBe(DriverState.Faulted);
var b = new CapturingAddressSpaceBuilder();
await driver.DiscoverAsync(b, CancellationToken.None);
b.Variables.Select(v => v.Info.FullName).ShouldBe(["Plant/Mqtt/dev1/Temp"]);
}
/// <summary>
/// A delta that changes only the authored tag set is applied in place — no teardown, no
/// reconnect — and is applied WHOLESALE: a tag the redeploy dropped stops being discovered.
/// </summary>
[Fact]
public async Task ReinitializeAsync_TagOnlyDelta_AppliedInPlace_Wholesale()
{
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
var pressure = System.Text.Json.JsonSerializer.Serialize(TagJson("f/p"));
var flow = System.Text.Json.JsonSerializer.Serialize(TagJson("f/q"));
var delta = $$"""
{
"mode": "Plain",
"rawTags": [
{ "rawPath": "Plant/Mqtt/dev1/Pressure", "tagConfig": {{pressure}}, "writeIdempotent": false },
{ "rawPath": "Plant/Mqtt/dev1/Flow", "tagConfig": {{flow}}, "writeIdempotent": false }
]
}
""";
await driver.ReinitializeAsync(delta, CancellationToken.None);
var b = new CapturingAddressSpaceBuilder();
await driver.DiscoverAsync(b, CancellationToken.None);
b.Variables.Select(v => v.Info.FullName)
.OrderBy(x => x, StringComparer.Ordinal)
.ShouldBe(["Plant/Mqtt/dev1/Flow", "Plant/Mqtt/dev1/Pressure"]);
}
/// <summary>Identity is fixed at construction and must match the persisted <c>DriverInstance.DriverType</c>.</summary>
[Fact]
public void Identity_IsMqtt()
{
var driver = PlainDriver();
driver.DriverType.ShouldBe("Mqtt");
driver.DriverInstanceId.ShouldBe("d");
}
/// <summary>A driver that has never been initialized reports Unknown, not Healthy.</summary>
[Fact]
public void GetHealth_BeforeInitialize_IsUnknown()
{
PlainDriver().GetHealth().State.ShouldBe(DriverState.Unknown);
}
/// <summary>Plain mode never signals rediscovery — the authored set only changes by redeploy.</summary>
[Fact]
public async Task Plain_NeverRaisesRediscoveryNeeded()
{
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
var fired = 0;
((IRediscoverable)driver).OnRediscoveryNeeded += (_, _) => Interlocked.Increment(ref fired);
driver.Subscriptions.HandleMessage("f/t", Encoding.UTF8.GetBytes("hot"), retained: false);
await driver.DiscoverAsync(new CapturingAddressSpaceBuilder(), CancellationToken.None);
fired.ShouldBe(0);
}
/// <summary>The footprint tracks the authored table; an empty driver reports no driver-attributable bytes.</summary>
[Fact]
public void GetMemoryFootprint_TracksAuthoredTable()
{
PlainDriver().GetMemoryFootprint().ShouldBe(0);
PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t")).GetMemoryFootprint().ShouldBeGreaterThan(0);
}
/// <summary>Shutdown on a never-initialized driver is a no-op, not a null-reference.</summary>
[Fact]
public async Task ShutdownAsync_BeforeInitialize_DoesNotThrow()
{
var driver = PlainDriver(Tag("Plant/Mqtt/dev1/Temp", "f/t"));
await Should.NotThrowAsync(() => driver.ShutdownAsync(CancellationToken.None));
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
}
/// <summary>The single-broker connectivity probe names the configured endpoint.</summary>
[Fact]
public void GetHostStatuses_ReportsTheConfiguredBroker()
{
var driver = new MqttDriver(
new MqttDriverOptions { Mode = MqttMode.Plain, Host = "broker.example", Port = 1883 },
"d",
null);
var statuses = ((IHostConnectivityProbe)driver).GetHostStatuses();
statuses.Count.ShouldBe(1);
statuses[0].HostName.ShouldBe("broker.example:1883");
statuses[0].State.ShouldBe(HostState.Unknown);
}
// ---- test doubles ----
/// <summary>
/// Records the streamed discovery tree instead of materializing OPC UA nodes. Local to this
/// suite: the driver test project does not reference <c>Commons</c>, where the runtime's own
/// capturing builder lives. Mirrors the per-driver <c>RecordingBuilder</c> the AB CIP / FOCAS
/// suites use.
/// </summary>
private sealed class CapturingAddressSpaceBuilder : IAddressSpaceBuilder
{
/// <summary>Every folder streamed, in order, across the whole tree.</summary>
public List<(string BrowseName, string DisplayName)> Folders { get; } = [];
/// <summary>Every variable streamed, in order, across the whole tree.</summary>
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = [];
/// <inheritdoc />
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{
Folders.Add((browseName, displayName));
return this;
}
/// <inheritdoc />
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
Variables.Add((browseName, attributeInfo));
return new Handle(attributeInfo.FullName);
}
/// <inheritdoc />
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
private sealed class Handle(string fullRef) : IVariableHandle
{
public string FullReference => fullRef;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
private sealed class NullSink : IAlarmConditionSink
{
public void OnTransition(AlarmEventArgs args) { }
}
}
}