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:
@@ -53,6 +53,9 @@ public static class DriverTypeNames
|
||||
/// <summary>Calculation pseudo-driver — tags computed by C# scripts over other tags' live values.</summary>
|
||||
public const string Calculation = "Calculation";
|
||||
|
||||
/// <summary>MQTT / Sparkplug B broker-subscription driver.</summary>
|
||||
public const string Mqtt = "Mqtt";
|
||||
|
||||
/// <summary>
|
||||
/// Every driver-type string declared above, for callers that need to enumerate
|
||||
/// the full set (e.g. validation of an authored <c>DriverType</c>).
|
||||
@@ -68,5 +71,6 @@ public static class DriverTypeNames
|
||||
OpcUaClient,
|
||||
Galaxy,
|
||||
Calculation,
|
||||
Mqtt,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -18,6 +18,7 @@ using FocasProbe = Driver.FOCAS.FocasDriverProbe;
|
||||
using OpcUaProbe = Driver.OpcUaClient.OpcUaClientDriverProbe;
|
||||
using GalaxyProbe = Driver.Galaxy.GalaxyDriverProbe;
|
||||
using CalculationProbe = Driver.Calculation.CalculationDriverProbe;
|
||||
using MqttProbe = Driver.Mqtt.MqttDriverProbe;
|
||||
|
||||
/// <summary>
|
||||
/// Wires every cross-platform driver assembly's <c>Register(registry, loggerFactory)</c>
|
||||
@@ -122,6 +123,7 @@ public static class DriverFactoryBootstrap
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, OpcUaProbe>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, GalaxyProbe>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, CalculationProbe>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, MqttProbe>());
|
||||
|
||||
return services;
|
||||
}
|
||||
@@ -144,6 +146,7 @@ public static class DriverFactoryBootstrap
|
||||
Driver.FOCAS.FocasDriverFactoryExtensions.Register(registry);
|
||||
Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry, secretResolver, loggerFactory);
|
||||
Driver.Modbus.ModbusDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||
Driver.Mqtt.MqttDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||
Driver.OpcUaClient.OpcUaClientDriverFactoryExtensions.Register(registry, loggerFactory, secretResolver);
|
||||
Driver.S7.S7DriverFactoryExtensions.Register(registry);
|
||||
Driver.TwinCAT.TwinCATDriverFactoryExtensions.Register(registry);
|
||||
|
||||
@@ -74,6 +74,7 @@
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway\ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Modbus\ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Mqtt\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.S7\ZB.MOM.WW.OtOpcUa.Driver.S7.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.csproj"/>
|
||||
|
||||
+2
-1
@@ -21,11 +21,12 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
<!-- Core hosts DriverFactoryRegistry; the eight driver assemblies each ship the
|
||||
<!-- Core hosts DriverFactoryRegistry; the driver assemblies below each ship the
|
||||
*DriverFactoryExtensions.Register entry point the guard test drives to build the
|
||||
authoritative registered-factory set (mirrors the Host's DriverFactoryBootstrap). -->
|
||||
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Modbus\ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Mqtt\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.S7\ZB.MOM.WW.OtOpcUa.Driver.S7.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbCip\ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj"/>
|
||||
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="MqttDriverFactoryExtensions"/> — the seam that turns a persisted
|
||||
/// <c>DriverInstance.DriverConfig</c> blob into a live <see cref="MqttDriver"/>. Mirrors
|
||||
/// <c>OpcUaClientDriverFactoryExtensions</c>' shape (direct deserialization into the options
|
||||
/// record, no intermediate DTO).
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class MqttDriverFactoryExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Register_ThenCreate_BuildsMqttDriver()
|
||||
{
|
||||
var registry = new DriverFactoryRegistry();
|
||||
MqttDriverFactoryExtensions.Register(registry);
|
||||
|
||||
var factory = registry.TryGet(MqttDriverFactoryExtensions.DriverTypeName);
|
||||
factory.ShouldNotBeNull();
|
||||
|
||||
var driver = factory("d1", """{"host":"h","port":1883,"mode":"Plain"}""");
|
||||
|
||||
driver.ShouldBeOfType<MqttDriver>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The factory's registration key and the canonical constant must be the same string. A
|
||||
/// drift here is invisible at compile time and silently breaks dispatch — the repo's
|
||||
/// <c>ModbusTcp</c>/<c>Modbus</c> incident.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DriverTypeName_MatchesConstant()
|
||||
=> MqttDriverFactoryExtensions.DriverTypeName.ShouldBe(DriverTypeNames.Mqtt);
|
||||
|
||||
/// <summary>
|
||||
/// Third independent pin on the literal itself: both the constant and the factory name could
|
||||
/// be renamed together and still agree with each other while disagreeing with every persisted
|
||||
/// <c>DriverInstance.DriverType</c> row.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DriverTypeName_IsExactlyMqtt()
|
||||
{
|
||||
MqttDriverFactoryExtensions.DriverTypeName.ShouldBe("Mqtt");
|
||||
DriverTypeNames.Mqtt.ShouldBe("Mqtt");
|
||||
DriverTypeNames.All.ShouldContain("Mqtt");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>rawTags</c> is the ONLY source of the driver's discoverable node set (plain MQTT has no
|
||||
/// browsable address space), so a factory that parsed everything else correctly but dropped
|
||||
/// this array would produce a driver that connects, subscribes to nothing and materializes
|
||||
/// nothing — green everywhere, dark in production.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CreateInstance_CarriesRawTagsThroughToDiscovery()
|
||||
{
|
||||
const string tagConfig = """{"topic":"f/t","payloadFormat":"Raw","dataType":"String"}""";
|
||||
var json = JsonSerializer.Serialize(new
|
||||
{
|
||||
host = "h",
|
||||
port = 1883,
|
||||
mode = "Plain",
|
||||
rawTags = new[]
|
||||
{
|
||||
new { rawPath = "Plant/Mqtt/dev1/Temp", tagConfig, writeIdempotent = false },
|
||||
},
|
||||
});
|
||||
|
||||
var driver = MqttDriverFactoryExtensions.CreateInstance("d1", json);
|
||||
|
||||
var builder = new RecordingBuilder();
|
||||
await driver.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.VariableFullNames.ShouldBe(["Plant/Mqtt/dev1/Temp"]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enum-valued knobs must bind from their string NAMES — the repo's documented systemic
|
||||
/// enum-serialization bug is an AdminUI-authored config whose enum field one seam can read
|
||||
/// and another cannot. Removing <see cref="JsonStringEnumConverter"/> from
|
||||
/// <see cref="MqttJson.Options"/> makes this throw.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateInstance_EnumsAuthoredAsNames_Bind()
|
||||
{
|
||||
const string json = """
|
||||
{"host":"h","port":8883,"mode":"SparkplugB","protocolVersion":"V311",
|
||||
"sparkplug":{"groupId":"Plant1","hostId":"h1"}}
|
||||
""";
|
||||
|
||||
var driver = MqttDriverFactoryExtensions.CreateInstance("d1", json);
|
||||
|
||||
driver.ShouldBeOfType<MqttDriver>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Direct pin on the single shared instance: the enum converter is what the test above
|
||||
/// exercises behaviourally, and this asserts it structurally so a reader can see the one
|
||||
/// instance every MQTT config seam parses through.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void SharedJsonOptions_CarryTheStringEnumConverter()
|
||||
=> MqttJson.Options.Converters.ShouldContain(c => c is JsonStringEnumConverter);
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_BlankConfig_Throws()
|
||||
=> Should.Throw<ArgumentException>(() => MqttDriverFactoryExtensions.CreateInstance("d1", " "));
|
||||
|
||||
[Fact]
|
||||
public void CreateInstance_JsonNullLiteral_ThrowsInvalidOperation()
|
||||
=> Should.Throw<InvalidOperationException>(() => MqttDriverFactoryExtensions.CreateInstance("d1", "null"));
|
||||
|
||||
/// <summary>
|
||||
/// Minimal discovery recorder — local to this suite because the driver test project does not
|
||||
/// reference <c>Commons</c>, where the runtime's capturing builder lives (same reason
|
||||
/// <c>MqttDriverDiscoveryTests</c> carries its own).
|
||||
/// </summary>
|
||||
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<string> VariableFullNames { get; } = [];
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||
{
|
||||
VariableFullNames.Add(attributeInfo.FullName);
|
||||
return new Handle(attributeInfo.FullName);
|
||||
}
|
||||
|
||||
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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
@@ -8,12 +7,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
||||
|
||||
public sealed class MqttDriverOptionsTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions J = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
/// <summary>
|
||||
/// The production instance, not a look-alike copy. A local clone would keep passing after
|
||||
/// <see cref="MqttJson.Options"/> lost its <c>JsonStringEnumConverter</c> — testing the copy
|
||||
/// instead of the thing every seam actually parses through.
|
||||
/// </summary>
|
||||
private static readonly JsonSerializerOptions J = MqttJson.Options;
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_SparkplugConfig_ReadsModeAndSubObject()
|
||||
|
||||
@@ -161,10 +161,20 @@ public sealed class MqttDriverProbeTests
|
||||
r.Message.ShouldNotContain(secret);
|
||||
}
|
||||
|
||||
/// <summary>Enum fields (protocolVersion) must round-trip by name, per the repo's documented
|
||||
/// enum-serialization trap — a numeric-only seam faults AdminUI-authored configs.</summary>
|
||||
/// <summary>
|
||||
/// Enum fields (protocolVersion) must round-trip by name, per the repo's documented
|
||||
/// enum-serialization trap — a numeric-only seam faults AdminUI-authored configs.
|
||||
/// <para>
|
||||
/// <b>The assertions are the whole point.</b> "Ok is false with a non-empty message" is
|
||||
/// ALSO what a JSON-parse failure produces, so asserting only that would stay green if
|
||||
/// <c>JsonStringEnumConverter</c> were ever dropped from <see cref="MqttJson.Options"/>
|
||||
/// — i.e. the exact regression this test exists to catch. So it asserts the probe got
|
||||
/// PAST the parse (message is not the <c>"Config JSON is invalid"</c> shape) and reached
|
||||
/// the network (the message names the target endpoint).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ProbeAsync_EnumAsName_DoesNotThrow()
|
||||
public async Task ProbeAsync_EnumAsName_ParsesAndReachesTheNetwork()
|
||||
{
|
||||
var listener = StartListener();
|
||||
var port = ListenerPort(listener);
|
||||
@@ -178,5 +188,14 @@ public sealed class MqttDriverProbeTests
|
||||
|
||||
r.Ok.ShouldBeFalse();
|
||||
r.Message.ShouldNotBeNullOrEmpty();
|
||||
r.Message.ShouldNotStartWith(
|
||||
"Config JSON is invalid",
|
||||
Case.Insensitive,
|
||||
"the probe never parsed the config — 'V500' was rejected, so the shared JSON options " +
|
||||
"lost their JsonStringEnumConverter");
|
||||
r.Message.ShouldNotStartWith("Config JSON deserialized to null");
|
||||
r.Message.ShouldNotStartWith("Config has no host/port");
|
||||
// A network-level marker: the probe got as far as dialling the (closed) loopback port.
|
||||
r.Message.ShouldContain($"127.0.0.1:{port}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user