diff --git a/docs/plans/2026-10-07-native-ab-transport-gate-results.md b/docs/plans/2026-10-07-native-ab-transport-gate-results.md
index 06826e51..3d2570e2 100644
--- a/docs/plans/2026-10-07-native-ab-transport-gate-results.md
+++ b/docs/plans/2026-10-07-native-ab-transport-gate-results.md
@@ -187,7 +187,76 @@ see the same split on the press.
## AdminUI /run (T19)
-_(pending)_
+Run 2026-10-07 on the local docker-dev rig (OrbStack), against the ab_server `controllogix` fixture
+on `10.100.0.35:44818`. Every container ran the same freshly built image
+(`otopcua-host:dev` `sha256:91bffbb0…`, checked per container because `:9200` round-robins). The
+modal was driven with the Chrome tools against `http://localhost:9200/raw`.
+
+**Authoring (UI).**
+- Created the AbCip driver `t19-abcip` (`DRV-8ea0f0a8cfde`) in the MAIN cluster.
+- Configure → Transport = "Native (managed)" → Save. `DriverInstance.DriverConfig` then held
+ `"transport":"Native"`, a string.
+- Reopened Configure. The dropdown showed "Native (managed)". Changed only Timeout from 2 to 3 and
+ saved. The row then held `"transport":"Native"` and `"timeout":"00:00:03"`, so the save-reverts-transport
+ defect is fixed live, not only in unit tests.
+- Created device `Device1plc1` with host `ab://10.100.0.35:44818/1,0` (ControlLogix). Test Connect
+ returned `OK · 62 ms`.
+- Added the raw tag `TestDINT` (Int32, read-only, tag path `TestDINT`, DInt).
+- Seeded `TestDINT = 4242` on the simulator so a non-zero value proves the read path.
+- Raw namespace: `ns=2`, the shared `s=OtOpcUa` root plus RawPath ids. UNS uses `ns=3`.
+- RawPath: `t19-abcip/Device1plc1/TestDINT`.
+
+**Leg 1: Native.** The deploy sealed with `acks=6`. Both central nodes logged:
+```
+AbCip driver DRV-8ea0f0a8cfde initializing: transport=Native tagFactory=NativeAbCipTagFactory
+```
+CLI read on central-1 (`:4840`) and central-2 (`:4841`):
+```
+Node: ns=2;s=t19-abcip/Device1plc1/TestDINT
+Value: 4242
+Status: 0x00000000
+```
+The simulator value was then changed to 5150 through the libplctag CLI. The native-served node
+followed it (`Value: 5150`, `Status: 0x00000000`, source time `23:02:09.60Z`).
+
+**Leg 2: inverted gate (Native, dead port).**
+- Device host changed to `ab://10.100.0.35:44819/1,0`. Nothing listens on 44819
+ (`ss -ltn` count 0).
+- The node went `BadNoCommunication` (value retained). A CLI read failed with
+ `ServiceResultException: [80050000]` (BadCommunicationError). The CLI `read` throws on a Bad
+ status rather than printing it.
+- The central-1 log showed the native error text, raised from `NativeAbCipTagRuntime.ReadAsync`:
+ ```
+ ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.AbTransportException: TCP connect to 10.100.0.35:44819 failed: ConnectionRefused
+ ---> System.Net.Sockets.SocketException (111): Connection refused
+ ```
+- For contrast, the same dead port under libplctag logged different wording:
+ ```
+ ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.AbTransportException: ErrorTimeout
+ ---> libplctag.LibPlcTagException: ErrorTimeout
+ ```
+ So the switch changes behaviour and is not inert.
+
+**Leg 3: back to libplctag.**
+- Device restored to port 44818. Configure → Transport = "libplctag (default)" → Save. The row held
+ `"transport":"Libplctag"`.
+- The redeploy sealed with `acks=6`. Both centrals logged
+ `initializing: transport=Libplctag tagFactory=LibplctagTagFactory`.
+- CLI reads on `:4840` and `:4841`: `Value: 5150`, `Status: 0x00000000`.
+
+**Fix found by the gate.** The first deploy logged no transport line. `AbCipDriverFactoryExtensions.Register`
+took no `ILoggerFactory`, and `DriverFactoryBootstrap` registered AbCip without one, so every production
+AbCip driver ran on `NullLogger`. That hid the new line and every existing AbCip warning or error. AbCip
+now takes the host logger factory the same way AbLegacy and Modbus do. `AbCipTransportInitLogTests` is
+falsified by reverting that wiring: 2/2 tests fail.
+
+**Cleanup.**
+- The `t19-abcip` driver, device and tag rows were deleted and the change redeployed (sealed,
+ `acks=6`). Both centrals logged `stopped driver child DRV-8ea0f0a8cfde`.
+- The rig has no test driver pointing at the fixture.
+- The fixture was brought down (`docker compose --profile controllogix down`).
+- The simulator's `TestDINT` was left at 5150 until teardown. The container is gone, so the value
+ does not persist.
## Real press (T21)
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
index 99f400c2..39c3985b 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
@@ -152,6 +152,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
_driverInstanceId = driverInstanceId;
// Chosen once here and held across ReinitializeAsync (Shutdown+Initialize): a changed
// Transport in a reinit config is inert; a deploy recreates the instance instead.
+ _transport = options.Transport;
var defaults = AbCipTransportFactories.For(options.Transport);
_tagFactory = tagFactory ?? defaults.Tags;
_enumeratorFactory = enumeratorFactory ?? defaults.Enumerators;
@@ -178,6 +179,10 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// The tag factory chosen at construction; exposed so tests can pin transport selection.
internal IAbCipTagFactory TagFactory => _tagFactory;
+ // The transport chosen at construction (see the ctor); logged at every init so an operator can tell
+ // from the node log which stack serves this instance.
+ private readonly AbCipTransport _transport;
+
/// Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide (05/STAB-8).
private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30);
@@ -282,6 +287,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
_health = new DriverHealth(DriverState.Initializing, null, null);
+ _logger.LogInformation(
+ "AbCip driver {DriverInstanceId} initializing: transport={Transport} tagFactory={TagFactory}",
+ _driverInstanceId, _transport, _tagFactory.GetType().Name);
try
{
if (!string.IsNullOrWhiteSpace(driverConfigJson))
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs
index 4ce4ca72..7819ec72 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs
@@ -1,4 +1,5 @@
using System.Text.Json;
+using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
@@ -15,13 +16,18 @@ public static class AbCipDriverFactoryExtensions
public const string DriverTypeName = "AbCip";
///
- /// Registers the AB CIP driver factory with the driver registry.
+ /// Registers the AB CIP driver 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 the
+ /// null logger, so none of its diagnostics (including the per-init transport line) reach the
+ /// node log. Mirrors the AbLegacy / Modbus registration pattern.
///
/// The driver factory registry to register with.
- public static void Register(DriverFactoryRegistry registry)
+ /// Optional logger factory for driver instances.
+ public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)
{
ArgumentNullException.ThrowIfNull(registry);
- registry.Register(DriverTypeName, CreateInstance);
+ registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory));
}
///
@@ -31,15 +37,24 @@ public static class AbCipDriverFactoryExtensions
/// The driver configuration as a JSON string.
/// A configured instance.
internal static AbCipDriver CreateInstance(string driverInstanceId, string driverConfigJson)
+ => CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null);
+
+ /// Creates an instance of the AB CIP driver with an optional logger.
+ /// The unique identifier for this driver instance.
+ /// The driver configuration as a JSON string.
+ /// Optional logger factory for the driver instance.
+ /// A configured instance.
+ internal static AbCipDriver CreateInstance(
+ string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
var options = ParseOptions(driverInstanceId, driverConfigJson);
- return new AbCipDriver(options, driverInstanceId);
+ return new AbCipDriver(options, driverInstanceId, logger: loggerFactory?.CreateLogger());
}
///
/// Deserialise an AB CIP driver-config JSON document into .
- /// Shared by (first construction) and
+ /// Shared by (first construction) and
/// /
/// so a reinitialize with a changed config JSON (new device, new tag, changed timeout)
/// actually takes effect rather than being silently discarded.
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
index 07eae5d1..a6b1dcf8 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs
@@ -70,6 +70,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
_driverInstanceId = driverInstanceId;
// Chosen once here and held across ReinitializeAsync (Shutdown+Initialize): a changed
// Transport in a reinit config is inert; a deploy recreates the instance instead.
+ _transport = options.Transport;
_tagFactory = tagFactory ?? (options.Transport == AbLegacyTransport.Native
? new NativeAbLegacyTagFactory()
: new LibplctagLegacyTagFactory());
@@ -92,6 +93,10 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
/// The tag factory chosen at construction; exposed so tests can pin transport selection.
internal IAbLegacyTagFactory TagFactory => _tagFactory;
+ // The transport chosen at construction (see the ctor); logged at every init so an operator can tell
+ // from the node log which stack serves this instance.
+ private readonly AbLegacyTransport _transport;
+
/// Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide (05/STAB-8).
private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30);
@@ -129,6 +134,9 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
_health = new DriverHealth(DriverState.Initializing, null, null);
+ _logger.LogInformation(
+ "AbLegacy driver {DriverInstanceId} initializing: transport={Transport} tagFactory={TagFactory}",
+ _driverInstanceId, _transport, _tagFactory.GetType().Name);
try
{
// #516: re-parse the supplied DriverConfig so a config change delivered through the IDriver
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbCipDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbCipDriverForm.razor
index 2a422476..616ad7d4 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbCipDriverForm.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbCipDriverForm.razor
@@ -32,6 +32,14 @@
Only enable when UDT member declaration order matches controller compiled layout.
+
+
+
+
+
+
+
Which protocol stack talks to the controller. Takes effect on the next deploy.
+
@@ -136,7 +144,11 @@
/// Devices array forward (the device form authors the real endpoints; the merge reconstructs the array
/// from the device rows at deploy/probe time).
public string GetConfigJson()
- => JsonSerializer.Serialize(_form.ToOptions(_preservedDevices), _jsonOpts);
+ => Serialize(_form.ToOptions(_preservedDevices));
+
+ /// Serializes options with the form's JSON settings (camelCase, enums as strings).
+ internal static string Serialize(AbCipDriverOptions options)
+ => JsonSerializer.Serialize(options, _jsonOpts);
private async Task EmitAsync()
{
@@ -151,7 +163,7 @@
await ResilienceConfigChanged.InvokeAsync(r);
}
- private static AbCipDriverOptions? TryDeserialize(string json)
+ internal static AbCipDriverOptions? TryDeserialize(string json)
{
try { return JsonSerializer.Deserialize(json, _jsonOpts); }
catch { return null; }
@@ -163,6 +175,7 @@
{
// Operation
public int TimeoutSeconds { get; set; } = 2;
+ public AbCipTransport Transport { get; set; } = AbCipTransport.Libplctag;
public bool EnableControllerBrowse { get; set; } = false;
public bool EnableDeclarationOnlyUdtGrouping { get; set; } = false;
@@ -182,6 +195,7 @@
public static FormModel FromOptions(AbCipDriverOptions o) => new()
{
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
+ Transport = o.Transport,
EnableControllerBrowse = o.EnableControllerBrowse,
EnableDeclarationOnlyUdtGrouping = o.EnableDeclarationOnlyUdtGrouping,
EnableAlarmProjection = o.EnableAlarmProjection,
@@ -205,6 +219,7 @@
ProbeTagPath = string.IsNullOrWhiteSpace(ProbeTagPath) ? null : ProbeTagPath,
},
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
+ Transport = Transport,
EnableControllerBrowse = EnableControllerBrowse,
EnableAlarmProjection = EnableAlarmProjection,
AlarmPollInterval = TimeSpan.FromSeconds(AlarmPollIntervalSeconds),
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor
index 58ccebb3..b3bd135e 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor
@@ -19,6 +19,14 @@
Default read/write timeout. Default 2 s.
+
+
+
+
+
+
+
Which protocol stack talks to the controller. Takes effect on the next deploy.
+
@@ -101,7 +109,11 @@
/// Serializes the current channel/protocol config to camelCase JSON, preserving the driver's
/// existing Devices array (authored as separate v3 entities).
public string GetConfigJson()
- => JsonSerializer.Serialize(_form.ToOptions(_preservedDevices), _jsonOpts);
+ => Serialize(_form.ToOptions(_preservedDevices));
+
+ /// Serializes options with the form's JSON settings (camelCase, enums as strings).
+ internal static string Serialize(AbLegacyDriverOptions options)
+ => JsonSerializer.Serialize(options, _jsonOpts);
private async Task EmitAsync()
{
@@ -116,7 +128,7 @@
await ResilienceConfigChanged.InvokeAsync(r);
}
- private static AbLegacyDriverOptions? TryDeserialize(string json)
+ internal static AbLegacyDriverOptions? TryDeserialize(string json)
{
try { return JsonSerializer.Deserialize(json, _jsonOpts); }
catch { return null; }
@@ -128,6 +140,7 @@
{
// Operation
public int TimeoutSeconds { get; set; } = 2;
+ public AbLegacyTransport Transport { get; set; } = AbLegacyTransport.Libplctag;
// Probe
public bool ProbeEnabled { get; set; } = true;
@@ -145,6 +158,7 @@
public static FormModel FromOptions(AbLegacyDriverOptions o) => new()
{
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
+ Transport = o.Transport,
ProbeEnabled = o.Probe.Enabled,
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
@@ -164,6 +178,7 @@
ProbeAddress = string.IsNullOrWhiteSpace(ProbeAddress) ? null : ProbeAddress,
},
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
+ Transport = Transport,
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
};
}
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 4943dee6..94b8b31c 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs
@@ -168,7 +168,7 @@ public static class DriverFactoryBootstrap
IDriverPrimaryRoleSource primaryRoleSource,
ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger? scriptRoot = null)
{
- Driver.AbCip.AbCipDriverFactoryExtensions.Register(registry);
+ Driver.AbCip.AbCipDriverFactoryExtensions.Register(registry, loggerFactory);
Driver.AbLegacy.AbLegacyDriverFactoryExtensions.Register(registry, loggerFactory);
Driver.Calculation.CalculationDriverFactoryExtensions.Register(registry, loggerFactory, scriptRoot);
Driver.FOCAS.FocasDriverFactoryExtensions.Register(registry);
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTransportInitLogTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTransportInitLogTests.cs
new file mode 100644
index 00000000..3c54b962
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTransportInitLogTests.cs
@@ -0,0 +1,55 @@
+using Microsoft.Extensions.Logging;
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
+
+///
+/// The transport the instance runs on is logged at every init through the HOST-supplied logger factory,
+/// so an operator can tell from the node log which stack serves a driver (T19 live gate). The factory
+/// path is the one under test: a driver built without a logger writes to the null logger.
+///
+[Trait("Category", "Unit")]
+public sealed class AbCipTransportInitLogTests
+{
+ private static string Config(string transport) =>
+ $$"""{"transport":"{{transport}}","probe":{"enabled":false},"devices":[{"hostAddress":"ab://10.0.0.1:44818/1,0","plcFamily":"ControlLogix"}]}""";
+
+ [Theory]
+ [InlineData("Native", AbCipTransport.Native)]
+ [InlineData("Libplctag", AbCipTransport.Libplctag)]
+ public async Task Init_logs_the_transport_through_the_registered_logger_factory(string json, AbCipTransport expected)
+ {
+ var captured = new List();
+ using var factory = new CapturingLoggerFactory(captured);
+ var driver = AbCipDriverFactoryExtensions.CreateInstance("drv-t19", Config(json), factory);
+ try
+ {
+ await driver.InitializeAsync(Config(json), TestContext.Current.CancellationToken);
+ }
+ finally
+ {
+ await driver.ShutdownAsync(TestContext.Current.CancellationToken);
+ }
+
+ captured.ShouldContain(m => m.Contains("drv-t19") && m.Contains($"transport={expected}"));
+ }
+
+ private sealed class CapturingLoggerFactory(List sink) : ILoggerFactory
+ {
+ public ILogger CreateLogger(string categoryName) => new CapturingLogger(sink);
+ public void AddProvider(ILoggerProvider provider) { }
+ public void Dispose() { }
+ }
+
+ private sealed class CapturingLogger(List sink) : ILogger
+ {
+ public IDisposable? BeginScope(TState state) where TState : notnull => null;
+ public bool IsEnabled(LogLevel logLevel) => true;
+ public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
+ Func formatter)
+ {
+ lock (sink) sink.Add(formatter(state, exception));
+ }
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyTransportInitLogTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyTransportInitLogTests.cs
new file mode 100644
index 00000000..f16ffb3c
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyTransportInitLogTests.cs
@@ -0,0 +1,55 @@
+using Microsoft.Extensions.Logging;
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
+
+///
+/// The transport the instance runs on is logged at every init through the HOST-supplied logger factory,
+/// so an operator can tell from the node log which stack serves a driver (T19 live gate). The factory
+/// path is the one under test: a driver built without a logger writes to the null logger.
+///
+[Trait("Category", "Unit")]
+public sealed class AbLegacyTransportInitLogTests
+{
+ private static string Config(string transport) =>
+ $$"""{"transport":"{{transport}}","probe":{"enabled":false},"devices":[{"hostAddress":"ab://10.0.0.1:44818/1,0","plcFamily":"Slc500"}]}""";
+
+ [Theory]
+ [InlineData("Native", AbLegacyTransport.Native)]
+ [InlineData("Libplctag", AbLegacyTransport.Libplctag)]
+ public async Task Init_logs_the_transport_through_the_registered_logger_factory(string json, AbLegacyTransport expected)
+ {
+ var captured = new List();
+ using var factory = new CapturingLoggerFactory(captured);
+ var driver = AbLegacyDriverFactoryExtensions.CreateInstance("drv-t19", Config(json), factory);
+ try
+ {
+ await driver.InitializeAsync(Config(json), TestContext.Current.CancellationToken);
+ }
+ finally
+ {
+ await driver.ShutdownAsync(TestContext.Current.CancellationToken);
+ }
+
+ captured.ShouldContain(m => m.Contains("drv-t19") && m.Contains($"transport={expected}"));
+ }
+
+ private sealed class CapturingLoggerFactory(List sink) : ILoggerFactory
+ {
+ public ILogger CreateLogger(string categoryName) => new CapturingLogger(sink);
+ public void AddProvider(ILoggerProvider provider) { }
+ public void Dispose() { }
+ }
+
+ private sealed class CapturingLogger(List sink) : ILogger
+ {
+ public IDisposable? BeginScope(TState state) where TState : notnull => null;
+ public bool IsEnabled(LogLevel logLevel) => true;
+ public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
+ Func formatter)
+ {
+ lock (sink) sink.Add(formatter(state, exception));
+ }
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbCipDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbCipDriverPageFormSerializationTests.cs
index 3e171d59..e338494b 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbCipDriverPageFormSerializationTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbCipDriverPageFormSerializationTests.cs
@@ -100,4 +100,58 @@ public sealed class AbCipDriverPageFormSerializationTests
back.Devices[0].PlcFamily.ShouldBe(AbCipPlcFamily.ControlLogix);
back.Devices[1].PlcFamily.ShouldBe(AbCipPlcFamily.CompactLogix);
}
+
+ // ---- Task 19: Transport (native AB transport switch) ----
+ // These drive the form's OWN load/save path (TryDeserialize -> FromOptions -> ToOptions -> Serialize),
+ // which uses the production JsonSerializerOptions, so a numeric enum or a dropped field shows up here.
+
+ [Theory]
+ [InlineData(AbCipTransport.Libplctag)]
+ [InlineData(AbCipTransport.Native)]
+ public void Transport_survives_FromOptions_ToOptions(AbCipTransport transport)
+ {
+ var original = new AbCipDriverOptions { Transport = transport };
+ var back = AbCipDriverForm.FormModel.FromOptions(original).ToOptions([]);
+ back.Transport.ShouldBe(transport);
+ }
+
+ [Fact]
+ public void Native_transport_serializes_as_a_string()
+ {
+ var form = new AbCipDriverForm.FormModel { Transport = AbCipTransport.Native };
+ var json = AbCipDriverForm.Serialize(form.ToOptions([]));
+ json.ShouldContain("\"transport\":\"Native\"");
+ }
+
+ [Fact]
+ public void Editing_an_unrelated_field_preserves_a_saved_Native_transport()
+ {
+ // The regression the review found: FromOptions/ToOptions dropped Transport, so an operator who set
+ // Native was silently reverted to libplctag the next time they saved ANY other edit.
+ const string saved = """{"transport":"Native","timeout":"00:00:02","devices":[]}""";
+ var loaded = AbCipDriverForm.TryDeserialize(saved);
+ loaded.ShouldNotBeNull();
+ loaded.Transport.ShouldBe(AbCipTransport.Native);
+
+ var form = AbCipDriverForm.FormModel.FromOptions(loaded);
+ form.TimeoutSeconds = 7;
+ var json = AbCipDriverForm.Serialize(form.ToOptions(loaded.Devices));
+
+ json.ShouldContain("\"transport\":\"Native\"");
+ json.ShouldContain("\"timeout\":\"00:00:07\"");
+ }
+
+ [Fact]
+ public void A_config_without_transport_loads_as_Libplctag_and_re_emits_it_explicitly()
+ {
+ // Chosen behaviour: an absent key round-trips as an explicit "Libplctag" (the form always writes the
+ // field). The dropdown therefore shows "libplctag (default)".
+ var loaded = AbCipDriverForm.TryDeserialize("""{"probeTimeoutSeconds":5}""");
+ loaded.ShouldNotBeNull();
+ var form = AbCipDriverForm.FormModel.FromOptions(loaded);
+ form.Transport.ShouldBe(AbCipTransport.Libplctag);
+
+ var json = AbCipDriverForm.Serialize(form.ToOptions(loaded.Devices));
+ json.ShouldContain("\"transport\":\"Libplctag\"");
+ }
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs
index 5c22a9a1..e77da8ad 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs
@@ -96,4 +96,58 @@ public sealed class AbLegacyDriverPageFormSerializationTests
back.Devices[0].PlcFamily.ShouldBe(AbLegacyPlcFamily.Slc500);
back.Devices[1].PlcFamily.ShouldBe(AbLegacyPlcFamily.MicroLogix);
}
+
+ // ---- Task 19: Transport (native AB transport switch) ----
+ // These drive the form's OWN load/save path (TryDeserialize -> FromOptions -> ToOptions -> Serialize),
+ // which uses the production JsonSerializerOptions, so a numeric enum or a dropped field shows up here.
+
+ [Theory]
+ [InlineData(AbLegacyTransport.Libplctag)]
+ [InlineData(AbLegacyTransport.Native)]
+ public void Transport_survives_FromOptions_ToOptions(AbLegacyTransport transport)
+ {
+ var original = new AbLegacyDriverOptions { Transport = transport };
+ var back = AbLegacyDriverForm.FormModel.FromOptions(original).ToOptions([]);
+ back.Transport.ShouldBe(transport);
+ }
+
+ [Fact]
+ public void Native_transport_serializes_as_a_string()
+ {
+ var form = new AbLegacyDriverForm.FormModel { Transport = AbLegacyTransport.Native };
+ var json = AbLegacyDriverForm.Serialize(form.ToOptions([]));
+ json.ShouldContain("\"transport\":\"Native\"");
+ }
+
+ [Fact]
+ public void Editing_an_unrelated_field_preserves_a_saved_Native_transport()
+ {
+ // The regression the review found: FromOptions/ToOptions dropped Transport, so an operator who set
+ // Native was silently reverted to libplctag the next time they saved ANY other edit.
+ const string saved = """{"transport":"Native","timeout":"00:00:02","devices":[]}""";
+ var loaded = AbLegacyDriverForm.TryDeserialize(saved);
+ loaded.ShouldNotBeNull();
+ loaded.Transport.ShouldBe(AbLegacyTransport.Native);
+
+ var form = AbLegacyDriverForm.FormModel.FromOptions(loaded);
+ form.TimeoutSeconds = 7;
+ var json = AbLegacyDriverForm.Serialize(form.ToOptions(loaded.Devices));
+
+ json.ShouldContain("\"transport\":\"Native\"");
+ json.ShouldContain("\"timeout\":\"00:00:07\"");
+ }
+
+ [Fact]
+ public void A_config_without_transport_loads_as_Libplctag_and_re_emits_it_explicitly()
+ {
+ // Chosen behaviour: an absent key round-trips as an explicit "Libplctag" (the form always writes the
+ // field). The dropdown therefore shows "libplctag (default)".
+ var loaded = AbLegacyDriverForm.TryDeserialize("""{"probeTimeoutSeconds":5}""");
+ loaded.ShouldNotBeNull();
+ var form = AbLegacyDriverForm.FormModel.FromOptions(loaded);
+ form.Transport.ShouldBe(AbLegacyTransport.Libplctag);
+
+ var json = AbLegacyDriverForm.Serialize(form.ToOptions(loaded.Devices));
+ json.ShouldContain("\"transport\":\"Libplctag\"");
+ }
}