diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs
index c5d4dad5..1621ecec 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs
@@ -39,6 +39,7 @@ public sealed class AbCipDriverProbe : IDriverProbe
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
+ Converters = { new JsonStringEnumConverter() },
};
///
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs
index ffe9dff6..1256540c 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs
@@ -39,6 +39,7 @@ public sealed class AbLegacyDriverProbe : IDriverProbe
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
+ Converters = { new JsonStringEnumConverter() },
};
///
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverProbe.cs
index 807046d0..f04ee0aa 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverProbe.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverProbe.cs
@@ -44,6 +44,7 @@ public sealed class FocasDriverProbe : IDriverProbe
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
+ Converters = { new JsonStringEnumConverter() },
};
///
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriverProbe.cs
index eb940508..2155f639 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriverProbe.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriverProbe.cs
@@ -35,6 +35,7 @@ public sealed class GalaxyDriverProbe : IDriverProbe
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
+ Converters = { new JsonStringEnumConverter() },
};
///
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianDriverProbe.cs
index bf3bdee0..edd37bc1 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianDriverProbe.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianDriverProbe.cs
@@ -24,6 +24,7 @@ public sealed class WonderwareHistorianDriverProbe : IDriverProbe
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
+ Converters = { new JsonStringEnumConverter() },
};
///
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverProbe.cs
index 8bb88235..38e7c5da 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverProbe.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverProbe.cs
@@ -20,6 +20,7 @@ public sealed class ModbusDriverProbe : IDriverProbe
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
+ Converters = { new JsonStringEnumConverter() },
};
// FC03 Read Holding Registers: function=0x03, addr-hi=0, addr-lo=0, qty-hi=0, qty-lo=1
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverProbe.cs
index 9868b78d..9d0b0380 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverProbe.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverProbe.cs
@@ -24,6 +24,7 @@ public sealed class S7DriverProbe : IDriverProbe
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
+ Converters = { new JsonStringEnumConverter() },
};
///
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverProbe.cs
index fedf29cf..8300a2b4 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverProbe.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverProbe.cs
@@ -68,6 +68,7 @@ public sealed class TwinCATDriverProbe : IDriverProbe
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
+ Converters = { new JsonStringEnumConverter() },
};
///
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbCipDriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbCipDriverPage.razor
index 056c3b3b..d0e3ddb5 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbCipDriverPage.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbCipDriverPage.razor
@@ -224,6 +224,7 @@ else
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip,
WriteIndented = false,
+ Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
};
private FormModel _form = new();
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbLegacyDriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbLegacyDriverPage.razor
index 5cea4448..61abdeae 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbLegacyDriverPage.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbLegacyDriverPage.razor
@@ -190,6 +190,7 @@ else
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip,
WriteIndented = false,
+ Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
};
private FormModel _form = new();
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/FocasDriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/FocasDriverPage.razor
index 7873f1fa..a56855cf 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/FocasDriverPage.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/FocasDriverPage.razor
@@ -267,6 +267,7 @@ else
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip,
WriteIndented = false,
+ Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
};
private FormModel _form = new();
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/GalaxyDriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/GalaxyDriverPage.razor
index 64107c2e..7a9c7f91 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/GalaxyDriverPage.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/GalaxyDriverPage.razor
@@ -218,6 +218,7 @@ else
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip,
WriteIndented = false,
+ Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
};
private FormModel _form = new();
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/HistorianWonderwareDriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/HistorianWonderwareDriverPage.razor
index 42c30d22..1b550668 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/HistorianWonderwareDriverPage.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/HistorianWonderwareDriverPage.razor
@@ -166,6 +166,7 @@ else
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip,
WriteIndented = false,
+ Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
};
private FormModel _form = new();
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/ModbusDriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/ModbusDriverPage.razor
index 59089643..c45a5768 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/ModbusDriverPage.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/ModbusDriverPage.razor
@@ -330,6 +330,7 @@ else
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip,
WriteIndented = false,
+ Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
};
private FormModel _form = new();
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/OpcUaClientDriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/OpcUaClientDriverPage.razor
index ccffb20e..54773d44 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/OpcUaClientDriverPage.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/OpcUaClientDriverPage.razor
@@ -279,6 +279,7 @@ else
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip,
WriteIndented = false,
+ Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
};
private FormModel _form = new();
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/S7DriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/S7DriverPage.razor
index f8332eb1..c017a90d 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/S7DriverPage.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/S7DriverPage.razor
@@ -196,6 +196,7 @@ else
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip,
WriteIndented = false,
+ Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
};
private FormModel _form = new();
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/TwinCATDriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/TwinCATDriverPage.razor
index 80772120..469b08bf 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/TwinCATDriverPage.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/TwinCATDriverPage.razor
@@ -206,6 +206,7 @@ else
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip,
WriteIndented = false,
+ Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
};
private FormModel _form = new();
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusDriverConfigEnumSerializationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusDriverConfigEnumSerializationTests.cs
new file mode 100644
index 00000000..2508b26d
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusDriverConfigEnumSerializationTests.cs
@@ -0,0 +1,49 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
+
+///
+/// Regression guard for the 2026-06-19 enum-serialization bug (the FB-9 Modbus-Int64 authoring
+/// case). The AdminUI Modbus page now serialises tag enums (,
+/// , byte-order) as STRINGS. This proves the factory parses that
+/// AdminUI-shaped Int64-tag blob, and documents that the pre-fix NUMERIC form threw because the
+/// ModbusTagDto enum fields are string?.
+///
+public sealed class ModbusDriverConfigEnumSerializationTests
+{
+ // Mirrors the (now fixed) AdminUI ModbusDriverPage._jsonOpts: camelCase + string enums.
+ private static readonly JsonSerializerOptions _adminPageOpts = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ Converters = { new JsonStringEnumConverter() },
+ };
+
+ /// Verifies the factory parses an AdminUI-authored Modbus config carrying an Int64
+ /// holding-register tag (string enums) without throwing.
+ [Fact]
+ public void Factory_parses_admin_authored_int64_string_enum_config()
+ {
+ var tag = new ModbusTagDefinition("Int64Tag", ModbusRegion.HoldingRegisters, (ushort)100, ModbusDataType.Int64);
+ var opts = new ModbusDriverOptions { Host = "10.0.0.5", Port = 502, UnitId = 1, Tags = new[] { tag } };
+ var blob = JsonSerializer.Serialize(opts, _adminPageOpts);
+ // The fixed AdminUI page must emit tag enums as strings, not numbers.
+ blob.ShouldContain("\"dataType\":\"Int64\"");
+
+ using var driver = ModbusDriverFactoryExtensions.CreateInstance("mb-test", blob);
+ driver.DriverType.ShouldBe("Modbus");
+ }
+
+ /// Documents the original bug: the pre-fix AdminUI page emitted numeric tag enums
+ /// ("dataType":5,"region":3) which the string-typed tag DTO cannot bind, so the factory throws.
+ [Fact]
+ public void Factory_throws_on_the_numeric_enum_form_the_pre_fix_page_emitted()
+ {
+ const string numericBlob =
+ "{\"host\":\"10.0.0.5\",\"port\":502,\"unitId\":1,\"tags\":[" +
+ "{\"name\":\"Int64Tag\",\"region\":3,\"address\":100,\"dataType\":5}]}";
+ Should.Throw(() => ModbusDriverFactoryExtensions.CreateInstance("mb-test", numericBlob));
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverConfigEnumSerializationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverConfigEnumSerializationTests.cs
new file mode 100644
index 00000000..feda2cf0
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverConfigEnumSerializationTests.cs
@@ -0,0 +1,48 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
+
+///
+/// Regression guard for the 2026-06-19 enum-serialization bug. The AdminUI S7 page now
+/// serialises as a STRING (its _jsonOpts gained a
+/// ). This proves the factory parses that AdminUI-shaped
+/// blob and round-trips the enum, and documents that the pre-fix NUMERIC form — which the old
+/// page emitted — threw because S7DriverConfigDto.CpuType is string?.
+///
+public sealed class S7DriverConfigEnumSerializationTests
+{
+ // Mirrors the (now fixed) AdminUI S7DriverPage._jsonOpts: camelCase + string enums.
+ private static readonly JsonSerializerOptions _adminPageOpts = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ Converters = { new JsonStringEnumConverter() },
+ };
+
+ /// Verifies the factory parses an AdminUI-authored (string-enum) S7 config and
+ /// preserves the CpuType.
+ [Fact]
+ public void Factory_parses_admin_authored_string_enum_config()
+ {
+ var opts = new S7DriverOptions { Host = "10.0.0.5", Port = 102, CpuType = S7CpuType.S71500, Rack = 0, Slot = 1 };
+ var blob = JsonSerializer.Serialize(opts, _adminPageOpts);
+ // The fixed AdminUI page must emit the enum as a string, not a number.
+ blob.ShouldContain("\"cpuType\":\"S71500\"");
+
+ var parsed = S7DriverFactoryExtensions.ParseOptions("s7-test", blob);
+ parsed.CpuType.ShouldBe(S7CpuType.S71500);
+ }
+
+ /// Documents the original bug: the pre-fix AdminUI page emitted a numeric enum
+ /// ("cpuType":40) which the string-typed config DTO cannot bind, so the factory throws.
+ [Fact]
+ public void Factory_throws_on_the_numeric_enum_form_the_pre_fix_page_emitted()
+ {
+ // 40 == (int)S7CpuType.S71500 — exactly what the pre-fix page (no converter) wrote for S71500.
+ // The throw comes from binding a JSON number to the DTO's string? CpuType, so it fires for any number.
+ const string numericBlob = "{\"host\":\"10.0.0.5\",\"port\":102,\"cpuType\":40,\"rack\":0,\"slot\":1}";
+ Should.Throw(() => S7DriverFactoryExtensions.ParseOptions("s7-test", numericBlob));
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/DriverPageJsonConverterTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/DriverPageJsonConverterTests.cs
new file mode 100644
index 00000000..8ede8c2a
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/DriverPageJsonConverterTests.cs
@@ -0,0 +1,69 @@
+using System.Reflection;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
+
+///
+/// Regression guard for the systemic driver-config enum-serialization bug found 2026-06-19.
+/// Every *DriverPage serialised enum config fields (e.g. S7 CpuType, Modbus
+/// DataType/Region, AbCip PlcFamily) as JSON numbers because its
+/// private static _jsonOpts had no . The driver
+/// factories, however, deserialise into string-typed DTOs (+ lenient ParseEnum) and
+/// throw when binding a JSON number to a string? — so an AdminUI-authored
+/// config that contained any enum field produced a blob the driver could not parse, faulting
+/// the driver on deploy. The fix makes every page serialise enums as strings (matching the
+/// factory + the long-correct OpcUaClient template). This test fails if any driver page loses
+/// its string-enum converter again.
+///
+public sealed class DriverPageJsonConverterTests
+{
+ /// Every concrete *DriverPage in the AdminUI assembly that declares a
+ /// _jsonOpts config serializer.
+ private static IReadOnlyList DriverPageTypes { get; } =
+ typeof(S7DriverPage).Assembly.GetTypes()
+ .Where(t => t.Name.EndsWith("DriverPage", StringComparison.Ordinal) && !t.IsAbstract)
+ .Where(t => t.GetField("_jsonOpts", BindingFlags.NonPublic | BindingFlags.Static) is not null)
+ .OrderBy(t => t.Name)
+ .ToList();
+
+ /// xUnit theory source over the driver-page types discovered by reflection.
+ public static TheoryData DriverPagesWithJsonOpts()
+ {
+ var data = new TheoryData();
+ foreach (var t in DriverPageTypes)
+ data.Add(t);
+ return data;
+ }
+
+ /// Verifies every driver page's config serializer registers a string-enum converter so
+ /// enum config fields round-trip as strings (and the driver factory can parse the result).
+ /// A driver page component type discovered by reflection.
+ [Theory]
+ [MemberData(nameof(DriverPagesWithJsonOpts))]
+ public void Driver_page_json_options_register_string_enum_converter(Type pageType)
+ {
+ var field = pageType.GetField("_jsonOpts", BindingFlags.NonPublic | BindingFlags.Static);
+ var opts = (JsonSerializerOptions)field!.GetValue(null)!;
+ opts.Converters.OfType().ShouldNotBeEmpty(
+ $"{pageType.Name}._jsonOpts must register a JsonStringEnumConverter; otherwise AdminUI-authored " +
+ "enum config fields serialise as numbers and the string-typed driver factory throws on parse.");
+ }
+
+ /// Enforces that EVERY concrete *DriverPage routes config serialization through a
+ /// _jsonOpts field — otherwise a new page that serialised config a different way would slip
+ /// past the converter guard above. Also a floor check so a rename can't silently shrink the set.
+ [Fact]
+ public void Every_driver_page_uses_a_guarded_jsonOpts_serializer()
+ {
+ var allDriverPages = typeof(S7DriverPage).Assembly.GetTypes()
+ .Where(t => t.Name.EndsWith("DriverPage", StringComparison.Ordinal) && !t.IsAbstract)
+ .ToList();
+ allDriverPages.Count.ShouldBeGreaterThanOrEqualTo(9, "reflection should discover the full driver-page fleet");
+ DriverPageTypes.Count.ShouldBe(allDriverPages.Count,
+ "every *DriverPage must declare a _jsonOpts config serializer so the string-enum converter guard covers it");
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeHandshakeE2eTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeHandshakeE2eTests.cs
index 6dbb1367..ceee9a94 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeHandshakeE2eTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeHandshakeE2eTests.cs
@@ -172,8 +172,16 @@ public sealed class DriverProbeHandshakeE2eTests
public async Task AbCip_Green_AgainstSim()
{
SkipUnless(DockerHost, AbCipPort);
+ // Probe an explicit tag that the ab_server ControlLogix sim actually defines
+ // (`TestDINT:DINT[1]`). The no-tags fallback (`@raw_cpu_type`) is NOT exercised here:
+ // ab_server answers an unknown/unsupported tag with libplctag ErrorBadParam (a REAL
+ // ControlLogix instead returns ErrorNotFound, which the probe classifies as
+ // reachable). Whether the `@raw_cpu_type` system-tag fallback is valid on a real
+ // ControlLogix is a hardware-gated follow-up (AbCipDriverOptions.cs flags it deferred).
var result = await new AbCipDriverProbe().ProbeAsync(
- $"{{\"Devices\":[{{\"HostAddress\":\"ab://{DockerHost}:{AbCipPort}/1,0\"}}]}}", Timeout, Ct);
+ $"{{\"Devices\":[{{\"HostAddress\":\"ab://{DockerHost}:{AbCipPort}/1,0\"}}]," +
+ $"\"Tags\":[{{\"DeviceHostAddress\":\"ab://{DockerHost}:{AbCipPort}/1,0\",\"TagPath\":\"TestDINT\"}}]}}",
+ Timeout, Ct);
result.Ok.ShouldBeTrue($"Probe message: {result.Message}");
result.Message!.ShouldContain("CIP session OK");
}