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:
@@ -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)
|
||||
|
||||
@@ -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() },
|
||||
};
|
||||
}
|
||||
@@ -123,11 +123,7 @@ public sealed class MqttDriver
|
||||
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";
|
||||
public string DriverType => DriverTypeNames.Mqtt;
|
||||
|
||||
/// <summary>
|
||||
/// The ingest path this driver composes. Internal: the P2 Sparkplug handler feeds the same
|
||||
@@ -513,10 +509,11 @@ public sealed class MqttDriver
|
||||
|
||||
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);
|
||||
// MqttJson.Options — the ONE shared instance (factory / probe / this / browser),
|
||||
// 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 string enum field faults the driver.
|
||||
return JsonSerializer.Deserialize<MqttDriverOptions>(driverConfigJson, MqttJson.Options);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
|
||||
/// <summary>
|
||||
/// Registers the MQTT / Sparkplug B driver with the <see cref="DriverFactoryRegistry"/>. The
|
||||
/// Host's <c>DriverFactoryBootstrap</c> calls <see cref="Register"/> once at startup; the
|
||||
/// driver-instance bootstrapper then materialises <c>DriverInstance</c> rows of type
|
||||
/// <see cref="DriverTypeNames.Mqtt"/> into live <see cref="MqttDriver"/> instances.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Direct deserialization, no intermediate DTO.</b> The <c>DriverConfig</c> blob binds
|
||||
/// straight onto <see cref="MqttDriverOptions"/> — the same shape
|
||||
/// <see cref="MqttDriverProbe"/>, <c>MqttDriverBrowser</c> and
|
||||
/// <see cref="MqttDriver.ReinitializeAsync"/> already bind, and the shape the options record
|
||||
/// was designed for (every knob carries its own default, and <c>rawTags</c> is a plain
|
||||
/// <see cref="RawTagEntry"/> list needing no translation). Mirrors
|
||||
/// <c>OpcUaClientDriverFactoryExtensions</c>. <c>ModbusDriverFactoryExtensions</c>' separate
|
||||
/// DTO exists to service a legacy nullable-everything blob plus string→enum parsing this
|
||||
/// driver does with a converter instead; copying it here would add a <i>fourth</i> parse
|
||||
/// shape for one config, which is precisely the divergence
|
||||
/// <see cref="MqttJson.Options"/> exists to prevent.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Connection-free.</b> <see cref="CreateInstance"/> parses and constructs only — the
|
||||
/// <see cref="MqttDriver"/> constructor touches no network, and the registry contract
|
||||
/// forbids the factory from calling <c>InitializeAsync</c> itself (the driver host owns the
|
||||
/// retry semantics).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class MqttDriverFactoryExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Driver type name — matches <c>DriverInstance.DriverType</c> values. Sourced from
|
||||
/// <see cref="DriverTypeNames.Mqtt"/> so the registration key can never drift from the
|
||||
/// constant the dispatch maps, the probe and the browser reference.
|
||||
/// </summary>
|
||||
public const string DriverTypeName = DriverTypeNames.Mqtt;
|
||||
|
||||
/// <summary>
|
||||
/// Register the MQTT factory with the driver registry. The optional
|
||||
/// <paramref name="loggerFactory"/> is captured at registration time and used to construct an
|
||||
/// <see cref="ILogger{MqttDriver}"/> per driver instance — without it the driver runs with no
|
||||
/// logger (standalone/test callers stay unchanged).
|
||||
/// </summary>
|
||||
/// <param name="registry">The driver factory registry to register with.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory used to create per-instance loggers.</param>
|
||||
public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory));
|
||||
}
|
||||
|
||||
/// <summary>Public for the Server-side bootstrapper + test consumers.</summary>
|
||||
/// <param name="driverInstanceId">Stable logical id of the driver instance.</param>
|
||||
/// <param name="driverConfigJson">The <c>DriverConfig</c> JSON blob for the instance.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory for the per-instance logger.</param>
|
||||
/// <returns>A configured, not-yet-connected <see cref="MqttDriver"/>.</returns>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// <paramref name="driverInstanceId"/> or <paramref name="driverConfigJson"/> is blank.
|
||||
/// </exception>
|
||||
/// <exception cref="InvalidOperationException">The config blob deserialised to <c>null</c>.</exception>
|
||||
/// <exception cref="JsonException">The config blob is not valid JSON for the options shape.</exception>
|
||||
public static MqttDriver CreateInstance(
|
||||
string driverInstanceId,
|
||||
string driverConfigJson,
|
||||
ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||
|
||||
// MqttJson.Options — the one shared instance (see its remarks). A local copy here is the
|
||||
// repo's documented systemic enum bug in the making.
|
||||
var options = JsonSerializer.Deserialize<MqttDriverOptions>(driverConfigJson, MqttJson.Options)
|
||||
?? throw new InvalidOperationException(
|
||||
$"MQTT driver config for '{driverInstanceId}' deserialised to null");
|
||||
|
||||
return new MqttDriver(options, driverInstanceId, loggerFactory?.CreateLogger<MqttDriver>());
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ using System.Diagnostics;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Authentication;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using MQTTnet;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
@@ -55,26 +54,8 @@ public sealed class MqttDriverProbe : IDriverProbe
|
||||
/// </summary>
|
||||
internal const string ProbeClientIdPrefix = "-probe-";
|
||||
|
||||
/// <summary>
|
||||
/// Shared JSON options for every MQTT config-parsing seam in this driver: enums
|
||||
/// (<see cref="MqttMode"/>, <see cref="MqttPayloadFormat"/>, <c>protocolVersion</c>)
|
||||
/// round-trip by <b>name</b>, never ordinal — the repo's documented enum-serialization
|
||||
/// trap (AdminUI-authored configs with numeric enum fields fault the driver). Task 9's
|
||||
/// <c>MqttDriverFactoryExtensions</c> is expected to converge on this same instance
|
||||
/// rather than defining its own.
|
||||
/// </summary>
|
||||
internal static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
/// <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 />
|
||||
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
|
||||
@@ -82,7 +63,10 @@ public sealed class MqttDriverProbe : IDriverProbe
|
||||
MqttDriverOptions? options;
|
||||
try
|
||||
{
|
||||
options = JsonSerializer.Deserialize<MqttDriverOptions>(configJson, JsonOpts);
|
||||
// MqttJson.Options — the one shared instance across factory / probe / driver / browser
|
||||
// (see its remarks): enums must round-trip by NAME or an AdminUI-authored config that
|
||||
// Test-connect accepts would fault the deployed driver.
|
||||
options = JsonSerializer.Deserialize<MqttDriverOptions>(configJson, MqttJson.Options);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user