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
@@ -0,0 +1,53 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
/// <summary>
/// The <b>single</b> <see cref="JsonSerializerOptions"/> instance every MQTT driver-config seam
/// parses <see cref="MqttDriverOptions"/> through — the runtime factory
/// (<c>MqttDriverFactoryExtensions</c>), the Test-connect probe (<c>MqttDriverProbe</c>), the
/// driver's own <c>ReinitializeAsync</c> re-parse, and the address-picker browser
/// (<c>MqttDriverBrowser</c>).
/// </summary>
/// <remarks>
/// <para>
/// <b>Why one instance, and why here.</b> Divergent per-seam options are this repo's
/// documented systemic enum bug: an AdminUI-authored config with an enum-valued field is
/// accepted by the seam that carries a <see cref="JsonStringEnumConverter"/> and faults the
/// one that does not, so "Test connect" goes green and the deployed driver dies. Two other
/// drivers already carry a copy per seam. Rather than repeat that, this driver keeps exactly
/// one instance.
/// </para>
/// <para>
/// It lives in <c>.Contracts</c> — the assembly that owns <see cref="MqttDriverOptions"/> and
/// the three enums the converter exists for — rather than in the factory or the probe,
/// because <c>.Contracts</c> is the <i>only</i> assembly all four consumers already reference.
/// In particular the browser lives in its own assembly and reaches the runtime <c>.Driver</c>
/// project only through a deliberate, documented layering exception that is scheduled to be
/// removed (see the <c>ProjectReference</c> comment in the browser's csproj); anchoring the
/// options in <c>.Driver</c> would resurrect the duplicate the day that reference goes away.
/// </para>
/// <para>
/// <b>Not a general-purpose JSON policy.</b> <c>UnmappedMemberHandling.Skip</c> means an
/// unknown key is ignored rather than rejected — deliberate, so a config blob authored
/// against a newer driver still binds — and <c>PropertyNameCaseInsensitive</c> accepts both
/// the camelCase the AdminUI emits and the PascalCase a hand-edited blob may carry. A
/// <see cref="JsonSerializerOptions"/> becomes read-only on first use, so this instance is
/// safe to share across threads and must never be mutated after startup.
/// </para>
/// </remarks>
public static class MqttJson
{
/// <summary>
/// The shared options. Enum-valued knobs (<see cref="MqttMode"/>,
/// <see cref="MqttProtocolVersion"/>, <see cref="MqttPayloadFormat"/>) round-trip by
/// <b>name</b>; numeric ordinals still bind, so an older blob is not broken by this.
/// </summary>
public static readonly JsonSerializerOptions Options = new()
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
Converters = { new JsonStringEnumConverter() },
};
}