feat(mqtt): factory + DriverTypeNames.Mqtt + host factory/probe registration

Task 9. Adds MqttDriverFactoryExtensions (DriverTypeName = DriverTypeNames.Mqtt,
direct deserialization of MqttDriverOptions — no intermediate DTO, mirroring
OpcUaClientDriverFactoryExtensions), the DriverTypeNames.Mqtt constant, and both
host wiring sites: the factory in DriverFactoryBootstrap.Register and the probe in
AddOtOpcUaDriverProbes (the admin-node path Program.cs calls in its hasAdmin
block — a probe wired only on driver nodes makes Test-connect silently dead).

Carried-forward items:

- Converges every MQTT config-parsing seam onto ONE JsonSerializerOptions —
  MqttJson.Options, in .Contracts alongside MqttDriverOptions. It replaces the
  probe's internal JsonOpts and the browser's separate private copy; the factory,
  the probe, MqttDriver.ParseOptions and MqttDriverBrowser now all parse through
  it. .Contracts is the only assembly all four consumers reference, and the
  browser's reference to the runtime .Driver project is a documented layering
  exception scheduled for removal — anchoring the options there would resurrect
  the duplicate the day it goes away.
- Replaces the "Mqtt" literals in MqttDriverProbe, MqttDriverBrowser and
  MqttDriver with the constant (string value unchanged).
- Tightens MqttDriverProbeTests.ProbeAsync_EnumAsName: it asserted only
  Ok == false + non-empty message, which is also exactly what a JSON-parse
  failure produces — so it stayed green under the very regression it names.
  It now asserts the probe got past the parse and reached the network.

Falsifiability: deleting JsonStringEnumConverter from MqttJson.Options reddens 9
tests across 4 suites, including the tightened probe test (message becomes
"Config JSON is invalid: The JSON value could not be converted to
MqttProtocolVersion") — which the pre-fix assertions would have passed.

Also references the MQTT driver from Core.Abstractions.Tests so
DriverTypeNamesGuardTests' reflective bin scan discovers the new factory and the
constant/factory parity check stays honest.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-24 17:24:13 -04:00
parent 36abd871a1
commit f79d13e2d8
12 changed files with 344 additions and 60 deletions
@@ -1,11 +1,11 @@
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;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser;
@@ -39,19 +39,6 @@ public sealed class MqttDriverBrowser : IDriverBrowser
/// <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>
@@ -64,10 +51,7 @@ public sealed class MqttDriverBrowser : IDriverBrowser
_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";
public string DriverType => DriverTypeNames.Mqtt;
/// <inheritdoc />
/// <remarks>
@@ -77,7 +61,11 @@ public sealed class MqttDriverBrowser : IDriverBrowser
/// </remarks>
public async Task<IBrowseSession> OpenAsync(string configJson, CancellationToken cancellationToken)
{
var opts = JsonSerializer.Deserialize<MqttDriverOptions>(configJson, JsonOpts)
// 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.");
if (opts.Mode == MqttMode.SparkplugB)