feat(adminui): Transport dropdown on AbCip/AbLegacy driver forms (live-verified on docker-dev)

- AbCip/AbLegacy driver forms gain a Transport InputSelect; FromOptions/ToOptions now carry
  Transport, so saving any other field no longer silently reverts Native to libplctag.
- Both drivers log the constructed transport once per init.
- AbCip factory now takes the host ILoggerFactory (it ran on NullLogger in production, hiding
  every AbCip log line); DriverFactoryBootstrap passes it.
- Gate results: AdminUI /run (T19) native, inverted (dead port) and libplctag legs.
This commit is contained in:
Joseph Doherty
2026-10-07 19:05:50 -04:00
parent c8b35663e5
commit b8ebafc642
11 changed files with 359 additions and 11 deletions
@@ -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,
/// <summary>The tag factory chosen at construction; exposed so tests can pin transport selection.</summary>
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;
/// <summary>Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide (05/STAB-8).</summary>
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))
@@ -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";
/// <summary>
/// Registers the AB CIP driver factory with the driver registry.
/// Registers the AB CIP driver factory with the driver registry. The optional
/// <paramref name="loggerFactory"/> is captured at registration time and used to construct an
/// <see cref="ILogger{AbCipDriver}"/> 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.
/// </summary>
/// <param name="registry">The driver factory registry to register with.</param>
public static void Register(DriverFactoryRegistry registry)
/// <param name="loggerFactory">Optional logger factory for driver instances.</param>
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));
}
/// <summary>
@@ -31,15 +37,24 @@ public static class AbCipDriverFactoryExtensions
/// <param name="driverConfigJson">The driver configuration as a JSON string.</param>
/// <returns>A configured <see cref="AbCipDriver"/> instance.</returns>
internal static AbCipDriver CreateInstance(string driverInstanceId, string driverConfigJson)
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null);
/// <summary>Creates an instance of the AB CIP driver with an optional logger.</summary>
/// <param name="driverInstanceId">The unique identifier for this driver instance.</param>
/// <param name="driverConfigJson">The driver configuration as a JSON string.</param>
/// <param name="loggerFactory">Optional logger factory for the driver instance.</param>
/// <returns>A configured <see cref="AbCipDriver"/> instance.</returns>
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<AbCipDriver>());
}
/// <summary>
/// Deserialise an AB CIP driver-config JSON document into <see cref="AbCipDriverOptions"/>.
/// Shared by <see cref="CreateInstance"/> (first construction) and
/// Shared by <see cref="CreateInstance(string, string, ILoggerFactory?)"/> (first construction) and
/// <see cref="AbCipDriver.InitializeAsync"/> / <see cref="AbCipDriver.ReinitializeAsync"/>
/// so a reinitialize with a changed config JSON (new device, new tag, changed timeout)
/// actually takes effect rather than being silently discarded.
@@ -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
/// <summary>The tag factory chosen at construction; exposed so tests can pin transport selection.</summary>
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;
/// <summary>Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide (05/STAB-8).</summary>
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
@@ -32,6 +32,14 @@
</div>
<div class="form-text">Only enable when UDT member declaration order matches controller compiled layout.</div>
</div>
<div class="col-md-3">
<label class="form-label" for="abcipTransport">Transport</label>
<InputSelect @bind-Value="_form.Transport" @bind-Value:after="EmitAsync" class="form-select form-select-sm" id="abcipTransport">
<option value="@AbCipTransport.Libplctag">libplctag (default)</option>
<option value="@AbCipTransport.Native">Native (managed)</option>
</InputSelect>
<div class="form-text">Which protocol stack talks to the controller. Takes effect on the next deploy.</div>
</div>
</div>
</div>
</section>
@@ -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).</summary>
public string GetConfigJson()
=> JsonSerializer.Serialize(_form.ToOptions(_preservedDevices), _jsonOpts);
=> Serialize(_form.ToOptions(_preservedDevices));
/// <summary>Serializes options with the form's JSON settings (camelCase, enums as strings).</summary>
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<AbCipDriverOptions>(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),
@@ -19,6 +19,14 @@
<InputNumber @bind-Value="_form.TimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default read/write timeout. Default 2 s.</div>
</div>
<div class="col-md-3">
<label class="form-label" for="ablegacyTransport">Transport</label>
<InputSelect @bind-Value="_form.Transport" @bind-Value:after="EmitAsync" class="form-select form-select-sm" id="ablegacyTransport">
<option value="@AbLegacyTransport.Libplctag">libplctag (default)</option>
<option value="@AbLegacyTransport.Native">Native (managed)</option>
</InputSelect>
<div class="form-text">Which protocol stack talks to the controller. Takes effect on the next deploy.</div>
</div>
</div>
</div>
</section>
@@ -101,7 +109,11 @@
/// <summary>Serializes the current channel/protocol config to camelCase JSON, preserving the driver's
/// existing Devices array (authored as separate v3 entities).</summary>
public string GetConfigJson()
=> JsonSerializer.Serialize(_form.ToOptions(_preservedDevices), _jsonOpts);
=> Serialize(_form.ToOptions(_preservedDevices));
/// <summary>Serializes options with the form's JSON settings (camelCase, enums as strings).</summary>
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<AbLegacyDriverOptions>(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,
};
}
@@ -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);