diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs
index d4ba0417..6e65b231 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs
@@ -53,6 +53,9 @@ public static class DriverTypeNames
/// Calculation pseudo-driver — tags computed by C# scripts over other tags' live values.
public const string Calculation = "Calculation";
+ /// MQTT / Sparkplug B broker-subscription driver.
+ public const string Mqtt = "Mqtt";
+
///
/// Every driver-type string declared above, for callers that need to enumerate
/// the full set (e.g. validation of an authored DriverType).
@@ -68,5 +71,6 @@ public static class DriverTypeNames
OpcUaClient,
Galaxy,
Calculation,
+ Mqtt,
];
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttDriverBrowser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttDriverBrowser.cs
index ce2f3366..42c049d6 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttDriverBrowser.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttDriverBrowser.cs
@@ -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
/// Marks the transient browse identity in the broker's client-id/session logs.
internal const string BrowseClientIdPrefix = "-browse-";
- ///
- /// 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. JsonStringEnumConverter lets
- /// mode / protocolVersion be authored as their string names — the natural form
- /// for AdminUI-emitted JSON — while still accepting numeric ordinals.
- ///
- private static readonly JsonSerializerOptions JsonOpts = new()
- {
- UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
- PropertyNameCaseInsensitive = true,
- Converters = { new JsonStringEnumConverter() },
- };
-
private readonly ILogger _logger;
///
@@ -64,10 +51,7 @@ public sealed class MqttDriverBrowser : IDriverBrowser
_logger = logger ?? NullLogger.Instance;
///
- // 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;
///
///
@@ -77,7 +61,11 @@ public sealed class MqttDriverBrowser : IDriverBrowser
///
public async Task OpenAsync(string configJson, CancellationToken cancellationToken)
{
- var opts = JsonSerializer.Deserialize(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(configJson, MqttJson.Options)
?? throw new InvalidOperationException("Mqtt options deserialized to null.");
if (opts.Mode == MqttMode.SparkplugB)
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttJson.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttJson.cs
new file mode 100644
index 00000000..944f653d
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttJson.cs
@@ -0,0 +1,53 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
+
+///
+/// The single instance every MQTT driver-config seam
+/// parses through — the runtime factory
+/// (MqttDriverFactoryExtensions), the Test-connect probe (MqttDriverProbe), the
+/// driver's own ReinitializeAsync re-parse, and the address-picker browser
+/// (MqttDriverBrowser).
+///
+///
+///
+/// Why one instance, and why here. 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 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.
+///
+///
+/// It lives in .Contracts — the assembly that owns and
+/// the three enums the converter exists for — rather than in the factory or the probe,
+/// because .Contracts is the only assembly all four consumers already reference.
+/// In particular the browser lives in its own assembly and reaches the runtime .Driver
+/// project only through a deliberate, documented layering exception that is scheduled to be
+/// removed (see the ProjectReference comment in the browser's csproj); anchoring the
+/// options in .Driver would resurrect the duplicate the day that reference goes away.
+///
+///
+/// Not a general-purpose JSON policy. UnmappedMemberHandling.Skip means an
+/// unknown key is ignored rather than rejected — deliberate, so a config blob authored
+/// against a newer driver still binds — and PropertyNameCaseInsensitive accepts both
+/// the camelCase the AdminUI emits and the PascalCase a hand-edited blob may carry. A
+/// becomes read-only on first use, so this instance is
+/// safe to share across threads and must never be mutated after startup.
+///
+///
+public static class MqttJson
+{
+ ///
+ /// The shared options. Enum-valued knobs (,
+ /// , ) round-trip by
+ /// name; numeric ordinals still bind, so an older blob is not broken by this.
+ ///
+ public static readonly JsonSerializerOptions Options = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
+ Converters = { new JsonStringEnumConverter() },
+ };
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs
index 5481f1cd..b7db7439 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs
@@ -123,11 +123,7 @@ public sealed class MqttDriver
public string DriverInstanceId => _driverInstanceId;
///
- // 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;
///
/// 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(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(driverConfigJson, MqttJson.Options);
}
catch (JsonException ex)
{
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriverFactoryExtensions.cs
new file mode 100644
index 00000000..dc6d9bea
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriverFactoryExtensions.cs
@@ -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;
+
+///
+/// Registers the MQTT / Sparkplug B driver with the . The
+/// Host's DriverFactoryBootstrap calls once at startup; the
+/// driver-instance bootstrapper then materialises DriverInstance rows of type
+/// into live instances.
+///
+///
+///
+/// Direct deserialization, no intermediate DTO. The DriverConfig blob binds
+/// straight onto — the same shape
+/// , MqttDriverBrowser and
+/// already bind, and the shape the options record
+/// was designed for (every knob carries its own default, and rawTags is a plain
+/// list needing no translation). Mirrors
+/// OpcUaClientDriverFactoryExtensions. ModbusDriverFactoryExtensions' 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 fourth parse
+/// shape for one config, which is precisely the divergence
+/// exists to prevent.
+///
+///
+/// Connection-free. parses and constructs only — the
+/// constructor touches no network, and the registry contract
+/// forbids the factory from calling InitializeAsync itself (the driver host owns the
+/// retry semantics).
+///
+///
+public static class MqttDriverFactoryExtensions
+{
+ ///
+ /// Driver type name — matches DriverInstance.DriverType values. Sourced from
+ /// so the registration key can never drift from the
+ /// constant the dispatch maps, the probe and the browser reference.
+ ///
+ public const string DriverTypeName = DriverTypeNames.Mqtt;
+
+ ///
+ /// Register the MQTT factory with the driver registry. The optional
+ /// is captured at registration time and used to construct an
+ /// per driver instance — without it the driver runs with no
+ /// logger (standalone/test callers stay unchanged).
+ ///
+ /// The driver factory registry to register with.
+ /// Optional logger factory used to create per-instance loggers.
+ public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)
+ {
+ ArgumentNullException.ThrowIfNull(registry);
+ registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory));
+ }
+
+ /// Public for the Server-side bootstrapper + test consumers.
+ /// Stable logical id of the driver instance.
+ /// The DriverConfig JSON blob for the instance.
+ /// Optional logger factory for the per-instance logger.
+ /// A configured, not-yet-connected .
+ ///
+ /// or is blank.
+ ///
+ /// The config blob deserialised to null.
+ /// The config blob is not valid JSON for the options shape.
+ 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(driverConfigJson, MqttJson.Options)
+ ?? throw new InvalidOperationException(
+ $"MQTT driver config for '{driverInstanceId}' deserialised to null");
+
+ return new MqttDriver(options, driverInstanceId, loggerFactory?.CreateLogger());
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriverProbe.cs
index ce8a71cc..4a6e973a 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriverProbe.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriverProbe.cs
@@ -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
///
internal const string ProbeClientIdPrefix = "-probe-";
- ///
- /// Shared JSON options for every MQTT config-parsing seam in this driver: enums
- /// (, , protocolVersion)
- /// round-trip by name, never ordinal — the repo's documented enum-serialization
- /// trap (AdminUI-authored configs with numeric enum fields fault the driver). Task 9's
- /// MqttDriverFactoryExtensions is expected to converge on this same instance
- /// rather than defining its own.
- ///
- internal static readonly JsonSerializerOptions JsonOpts = new()
- {
- PropertyNameCaseInsensitive = true,
- UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
- Converters = { new JsonStringEnumConverter() },
- };
-
///
- // 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;
///
public async Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
@@ -82,7 +63,10 @@ public sealed class MqttDriverProbe : IDriverProbe
MqttDriverOptions? options;
try
{
- options = JsonSerializer.Deserialize(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(configJson, MqttJson.Options);
}
catch (Exception ex)
{
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs
index 71b5d0b7..fe19a128 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs
@@ -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;
///
/// Wires every cross-platform driver assembly's Register(registry, loggerFactory)
@@ -122,6 +123,7 @@ public static class DriverFactoryBootstrap
services.TryAddEnumerable(ServiceDescriptor.Singleton());
services.TryAddEnumerable(ServiceDescriptor.Singleton());
services.TryAddEnumerable(ServiceDescriptor.Singleton());
+ services.TryAddEnumerable(ServiceDescriptor.Singleton());
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);
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj
index dd7fbd9a..ebcea6c5 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj
@@ -74,6 +74,7 @@
+
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj
index b89bfe91..1e32d03c 100644
--- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj
@@ -21,11 +21,12 @@
-
+
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverFactoryExtensionsTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverFactoryExtensionsTests.cs
new file mode 100644
index 00000000..ad4e2191
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverFactoryExtensionsTests.cs
@@ -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;
+
+///
+/// Tests for — the seam that turns a persisted
+/// DriverInstance.DriverConfig blob into a live . Mirrors
+/// OpcUaClientDriverFactoryExtensions' shape (direct deserialization into the options
+/// record, no intermediate DTO).
+///
+[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();
+ }
+
+ ///
+ /// 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
+ /// ModbusTcp/Modbus incident.
+ ///
+ [Fact]
+ public void DriverTypeName_MatchesConstant()
+ => MqttDriverFactoryExtensions.DriverTypeName.ShouldBe(DriverTypeNames.Mqtt);
+
+ ///
+ /// 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
+ /// DriverInstance.DriverType row.
+ ///
+ [Fact]
+ public void DriverTypeName_IsExactlyMqtt()
+ {
+ MqttDriverFactoryExtensions.DriverTypeName.ShouldBe("Mqtt");
+ DriverTypeNames.Mqtt.ShouldBe("Mqtt");
+ DriverTypeNames.All.ShouldContain("Mqtt");
+ }
+
+ ///
+ /// rawTags 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.
+ ///
+ [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"]);
+ }
+
+ ///
+ /// 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 from
+ /// makes this throw.
+ ///
+ [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();
+ }
+
+ ///
+ /// 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.
+ ///
+ [Fact]
+ public void SharedJsonOptions_CarryTheStringEnumConverter()
+ => MqttJson.Options.Converters.ShouldContain(c => c is JsonStringEnumConverter);
+
+ [Fact]
+ public void CreateInstance_BlankConfig_Throws()
+ => Should.Throw(() => MqttDriverFactoryExtensions.CreateInstance("d1", " "));
+
+ [Fact]
+ public void CreateInstance_JsonNullLiteral_ThrowsInvalidOperation()
+ => Should.Throw(() => MqttDriverFactoryExtensions.CreateInstance("d1", "null"));
+
+ ///
+ /// Minimal discovery recorder — local to this suite because the driver test project does not
+ /// reference Commons, where the runtime's capturing builder lives (same reason
+ /// MqttDriverDiscoveryTests carries its own).
+ ///
+ private sealed class RecordingBuilder : IAddressSpaceBuilder
+ {
+ public List 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) { }
+ }
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverOptionsTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverOptionsTests.cs
index ce7e2c3c..d6fc1cb9 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverOptionsTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverOptionsTests.cs
@@ -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() },
- };
+ ///
+ /// The production instance, not a look-alike copy. A local clone would keep passing after
+ /// lost its JsonStringEnumConverter — testing the copy
+ /// instead of the thing every seam actually parses through.
+ ///
+ private static readonly JsonSerializerOptions J = MqttJson.Options;
[Fact]
public void Deserialize_SparkplugConfig_ReadsModeAndSubObject()
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverProbeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverProbeTests.cs
index 14c70eab..5f3e1b5e 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverProbeTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverProbeTests.cs
@@ -161,10 +161,20 @@ public sealed class MqttDriverProbeTests
r.Message.ShouldNotContain(secret);
}
- /// Enum fields (protocolVersion) must round-trip by name, per the repo's documented
- /// enum-serialization trap — a numeric-only seam faults AdminUI-authored configs.
+ ///
+ /// Enum fields (protocolVersion) must round-trip by name, per the repo's documented
+ /// enum-serialization trap — a numeric-only seam faults AdminUI-authored configs.
+ ///
+ /// The assertions are the whole point. "Ok is false with a non-empty message" is
+ /// ALSO what a JSON-parse failure produces, so asserting only that would stay green if
+ /// JsonStringEnumConverter were ever dropped from
+ /// — i.e. the exact regression this test exists to catch. So it asserts the probe got
+ /// PAST the parse (message is not the "Config JSON is invalid" shape) and reached
+ /// the network (the message names the target endpoint).
+ ///
+ ///
[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}");
}
}