feat(ab): native managed transport is the default for AbCip and AbLegacy; libplctag remains as fallback

Task 22 (spec section 8 P5), with the controller rulings that exclude PLC-5 and LogixPccc.

- Transport is nullable on both option records; null (key absent) means "use the default" and is
  never serialized. Factory DTO parse: absent -> null; a bad value still fails via ParseEnum.
- One resolver per driver (AbCipTransportResolver / AbLegacyTransportResolver), used by the driver
  constructor, the Test Connect probe and (through the driver) the CLI.
  AbCip: null -> Native. AbLegacy: null -> Native unless any device is Plc5 or LogixPccc, then the
  whole driver resolves to Libplctag. Explicit values always win; explicit Native with a
  Plc5/LogixPccc device logs one Warning at init.
- Init log shows configured vs effective, e.g. transport=Native (default).
- AdminUI forms: "Default (...)" / "Native (managed)" / "libplctag (fallback)". Default omits the
  key on save (reverses Task 19's explicit re-emit, which would have pinned every edited driver).
- CLIs: --transport default|native|libplctag, default "default" via the resolver, so
  --plc-type Plc5 with no --transport runs libplctag.
- Tests pinned to Libplctag where a default-path runtime was created: found by instrumenting the
  native factories with the current xUnit test name (one unit test, AbLegacyReinitConfigAdoption;
  plus the two fixture-gated AbCip probe E2E tests whose exact messages were proven on libplctag).
  TransportMatrix and the differential harness already pass explicit transports.
- Switch/resolver/CLI/form tests are falsified by mutating the resolver (default -> Libplctag,
  exclusion removed, AbLegacy default -> Libplctag): each mutation turns them red.
- Replaces the inline role-name literal in AbCipDriverFactoryExtensions' ParseEnum call with a named
  constant; RoleAuthorityGuardTests was already red on the base commit for that line.
- Docs: AbCip.md / AbLegacy.md default + release note; CLAUDE.md AB section.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-07 20:49:33 -04:00
parent 17e1170e61
commit ed0881d672
29 changed files with 653 additions and 131 deletions
@@ -0,0 +1,32 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli;
/// <summary>
/// The CLI's <c>--transport</c> choice. <see cref="Default"/> leaves
/// <see cref="AbCipDriverOptions.Transport"/> null so the driver resolves it through
/// <see cref="AbCipTransportResolver"/> exactly as an absent <c>transport</c> key in a deployed config does.
/// </summary>
public enum AbCipCliTransport
{
/// <summary>Use the driver's default (resolved by <see cref="AbCipTransportResolver"/>).</summary>
Default,
/// <summary>The managed native transport.</summary>
Native,
/// <summary>The libplctag fallback transport.</summary>
Libplctag,
}
/// <summary>Maps a <see cref="AbCipCliTransport"/> onto the driver option.</summary>
public static class AbCipCliTransportExtensions
{
/// <summary>Returns the configured transport: null for <see cref="AbCipCliTransport.Default"/>.</summary>
/// <param name="choice">The CLI choice.</param>
/// <returns>The value for <see cref="AbCipDriverOptions.Transport"/>.</returns>
public static AbCipTransport? ToConfigured(this AbCipCliTransport choice) => choice switch
{
AbCipCliTransport.Native => AbCipTransport.Native,
AbCipCliTransport.Libplctag => AbCipTransport.Libplctag,
_ => null,
};
}
@@ -28,8 +28,8 @@ public abstract class AbCipCommandBase : DriverCommandBase
/// <summary>Gets the transport stack used to reach the PLC.</summary>
[CommandOption("transport", Description =
"Transport stack: libplctag (default) or native (managed EtherNet/IP + CIP).")]
public AbCipTransport Transport { get; init; } = AbCipTransport.Libplctag;
"Transport stack: default (= native), native (managed EtherNet/IP + CIP) or libplctag (fallback).")]
public AbCipCliTransport Transport { get; init; } = AbCipCliTransport.Default;
/// <summary>Gets the per-operation timeout in milliseconds.</summary>
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
@@ -73,7 +73,7 @@ public abstract class AbCipCommandBase : DriverCommandBase
WriteIdempotent: t.WriteIdempotent,
DeviceName: t.DeviceHostAddress))],
Timeout = Timeout,
Transport = Transport,
Transport = Transport.ToConfigured(),
Probe = new AbCipProbeOptions { Enabled = false },
EnableControllerBrowse = false,
EnableAlarmProjection = false,
@@ -0,0 +1,32 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli;
/// <summary>
/// The CLI's <c>--transport</c> choice. <see cref="Default"/> leaves
/// <see cref="AbLegacyDriverOptions.Transport"/> null so the driver resolves it through
/// <see cref="AbLegacyTransportResolver"/> exactly as an absent <c>transport</c> key in a deployed config does.
/// </summary>
public enum AbLegacyCliTransport
{
/// <summary>Use the driver's default (resolved by <see cref="AbLegacyTransportResolver"/>).</summary>
Default,
/// <summary>The managed native transport.</summary>
Native,
/// <summary>The libplctag fallback transport.</summary>
Libplctag,
}
/// <summary>Maps a <see cref="AbLegacyCliTransport"/> onto the driver option.</summary>
public static class AbLegacyCliTransportExtensions
{
/// <summary>Returns the configured transport: null for <see cref="AbLegacyCliTransport.Default"/>.</summary>
/// <param name="choice">The CLI choice.</param>
/// <returns>The value for <see cref="AbLegacyDriverOptions.Transport"/>.</returns>
public static AbLegacyTransport? ToConfigured(this AbLegacyCliTransport choice) => choice switch
{
AbLegacyCliTransport.Native => AbLegacyTransport.Native,
AbLegacyCliTransport.Libplctag => AbLegacyTransport.Libplctag,
_ => null,
};
}
@@ -27,9 +27,9 @@ public abstract class AbLegacyCommandBase : DriverCommandBase
/// <summary>Gets the transport stack used to reach the PLC.</summary>
[CommandOption("transport", Description =
"Transport stack: libplctag (default) or native (managed EtherNet/IP + PCCC; PLC-5 N-file reads only, " +
"no status-bit writes, LogixPccc unverified).")]
public AbLegacyTransport Transport { get; init; } = AbLegacyTransport.Libplctag;
"Transport stack: default, native or libplctag. default = native, except libplctag for --plc-type " +
"Plc5 / LogixPccc (native there is PLC-5 N-file reads only, no status-bit writes, LogixPccc unverified).")]
public AbLegacyCliTransport Transport { get; init; } = AbLegacyCliTransport.Default;
/// <summary>Gets the per-operation timeout in milliseconds.</summary>
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
@@ -62,7 +62,7 @@ public abstract class AbLegacyCommandBase : DriverCommandBase
RawTags = [.. tags.Select(t => new RawTagEntry(
t.Name, AbLegacyTagDefinitionFactory.ToTagConfig(t), t.WriteIdempotent, DeviceName: t.DeviceHostAddress))],
Timeout = Timeout,
Transport = Transport,
Transport = Transport.ToConfigured(),
Probe = new AbLegacyProbeOptions { Enabled = false },
};
@@ -33,14 +33,20 @@ public sealed class AbCipDriverOptions
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = [];
/// <summary>
/// Which transport stack the driver, its Test Connect probe and the CLI use. Defaults to
/// <see cref="AbCipTransport.Libplctag"/>.
/// Which transport stack the driver, its Test Connect probe and the CLI use. <c>null</c> (the
/// <c>transport</c> key absent) means "use the default", which is <see cref="AbCipTransport.Native"/>;
/// an explicit value always wins. The effective transport is resolved in one place,
/// <c>AbCipTransportResolver</c>. Set <see cref="AbCipTransport.Libplctag"/> to fall back.
/// <para>Changing this takes effect when the driver instance is RECREATED, not on
/// <c>ReinitializeAsync</c>: the factories are chosen once in the constructor and held across
/// Shutdown+Initialize. A deploy that changes the driver config stops and respawns the instance
/// (<c>DriverSpawnPlanner</c>, #516), so an operator edit does recreate it.</para>
/// </summary>
public AbCipTransport Transport { get; init; } = AbCipTransport.Libplctag;
/// <remarks>Null is never written: an absent key IS the "use the default" encoding, so a round-trip
/// through any serializer (the AdminUI form included) keeps a defaulted driver defaulted rather than
/// pinning it to whatever the default happened to be on the day it was saved.</remarks>
[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
public AbCipTransport? Transport { get; init; }
/// <summary>Per-device probe settings. Falls back to defaults when omitted.</summary>
public AbCipProbeOptions Probe { get; init; } = new();
@@ -137,9 +137,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// <summary>Initializes a new instance of the <see cref="AbCipDriver"/> class.</summary>
/// <param name="options">The driver configuration options.</param>
/// <param name="driverInstanceId">A unique identifier for this driver instance.</param>
/// <param name="tagFactory">Optional factory for creating tag runtimes; uses libplctag default if null.</param>
/// <param name="enumeratorFactory">Optional factory for enumerating tags; uses libplctag default if null.</param>
/// <param name="templateReaderFactory">Optional factory for reading UDT templates; uses libplctag default if null.</param>
/// <param name="tagFactory">Optional factory for creating tag runtimes; when null the resolved transport's (see <see cref="AbCipTransportResolver"/>) is used.</param>
/// <param name="enumeratorFactory">Optional factory for enumerating tags; when null the resolved transport's is used.</param>
/// <param name="templateReaderFactory">Optional factory for reading UDT templates; when null the resolved transport's is used.</param>
/// <param name="logger">Optional logger; uses null logger if not provided.</param>
public AbCipDriver(AbCipDriverOptions options, string driverInstanceId,
IAbCipTagFactory? tagFactory = null,
@@ -152,8 +152,9 @@ 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);
_transport = AbCipTransportResolver.Resolve(options);
_transportDescription = AbCipTransportResolver.Describe(options.Transport);
var defaults = AbCipTransportFactories.For(_transport);
_tagFactory = tagFactory ?? defaults.Tags;
_enumeratorFactory = enumeratorFactory ?? defaults.Enumerators;
_templateReaderFactory = templateReaderFactory ?? defaults.Templates;
@@ -179,10 +180,16 @@ 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;
/// <summary>The effective transport resolved at construction (see <see cref="AbCipTransportResolver"/>).</summary>
internal AbCipTransport EffectiveTransport => _transport;
// 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;
// Configured-vs-effective rendering of _transport for the init log, e.g. "Native (default)".
private readonly string _transportDescription;
/// <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);
@@ -289,7 +296,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
_health = new DriverHealth(DriverState.Initializing, null, null);
_logger.LogInformation(
"AbCip driver {DriverInstanceId} initializing: transport={Transport} tagFactory={TagFactory}",
_driverInstanceId, _transport, _tagFactory.GetType().Name);
_driverInstanceId, _transportDescription, _tagFactory.GetType().Name);
try
{
if (!string.IsNullOrWhiteSpace(driverConfigJson))
@@ -96,8 +96,11 @@ public static class AbCipDriverFactoryExtensions
ProbeTagPath = dto.Probe?.ProbeTagPath,
},
Timeout = PositiveTimeoutOrDefault(dto.TimeoutMs, 2_000),
Transport = ParseEnum<AbCipTransport>(dto.Transport, "driver", driverInstanceId, "Transport",
fallback: AbCipTransport.Libplctag),
// Absent ⇒ null ("use the default", resolved by AbCipTransportResolver); a bad value still
// fails exactly as any other bad enum field does.
Transport = string.IsNullOrWhiteSpace(dto.Transport)
? null
: ParseEnum<AbCipTransport>(dto.Transport, DriverConfigOwner, driverInstanceId, "Transport"),
EnableControllerBrowse = dto.EnableControllerBrowse ?? false,
EnableAlarmProjection = dto.EnableAlarmProjection ?? false,
AlarmPollInterval = TimeSpan.FromMilliseconds(dto.AlarmPollIntervalMs ?? 1_000),
@@ -120,6 +123,10 @@ public static class AbCipDriverFactoryExtensions
private static TimeSpan PositiveTimeoutOrDefault(int? ms, int defaultMs) =>
TimeSpan.FromMilliseconds(ms is int v and > 0 ? v : defaultMs);
// Names the owner of a driver-level enum field in ParseEnum's error text. A named constant rather
// than an inline literal: RoleAuthorityGuardTests forbids a bare role-name string literal outside RoleParser.
private const string DriverConfigOwner = "(driver config)";
private static T ParseEnum<T>(string? raw, string? tagName, string driverInstanceId, string field,
T? fallback = null) where T : struct, Enum
{
@@ -123,7 +123,7 @@ public sealed class AbCipDriverProbe : IDriverProbe
ConnectionSize: firstDevice?.ConnectionSize ?? profile.DefaultConnectionSize,
UseConnectedMessaging: profile.SupportsConnectedMessaging);
var rt = (_tagFactory ?? AbCipTransportFactories.For(opts.Transport).Tags).Create(p);
var rt = (_tagFactory ?? AbCipTransportFactories.For(AbCipTransportResolver.Resolve(opts)).Tags).Create(p);
try
{
await rt.InitializeAsync(cts.Token);
@@ -0,0 +1,36 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// The ONE place an AbCip driver's effective transport is decided from its configured
/// <see cref="AbCipDriverOptions.Transport"/>. The driver constructor, the Test Connect probe and the
/// CLI (through the driver) all resolve through here, so they cannot disagree about what an absent
/// <c>transport</c> key means.
/// </summary>
public static class AbCipTransportResolver
{
/// <summary>The transport an absent / null <c>transport</c> key selects.</summary>
public const AbCipTransport Default = AbCipTransport.Native;
/// <summary>Returns the effective transport: an explicit value always wins; null means <see cref="Default"/>.</summary>
/// <param name="configured">The configured transport, or null for "use the default".</param>
/// <returns>The transport the driver runs on.</returns>
public static AbCipTransport Resolve(AbCipTransport? configured) => configured ?? Default;
/// <summary>Returns the effective transport for <paramref name="options"/>.</summary>
/// <param name="options">The driver options.</param>
/// <returns>The transport the driver runs on.</returns>
public static AbCipTransport Resolve(AbCipDriverOptions options)
{
ArgumentNullException.ThrowIfNull(options);
return Resolve(options.Transport);
}
/// <summary>
/// Operator-facing description of the configured-vs-effective transport for the init log line:
/// <c>Native (default)</c> when the key was absent, the bare transport name when it was explicit.
/// </summary>
/// <param name="configured">The configured transport, or null.</param>
/// <returns>A short description.</returns>
public static string Describe(AbCipTransport? configured) =>
configured is null ? $"{Resolve(configured)} (default)" : $"{configured} (configured)";
}
@@ -26,14 +26,21 @@ public sealed class AbLegacyDriverOptions
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = [];
/// <summary>
/// Which transport stack the driver, its Test Connect probe and the CLI use. Defaults to
/// <see cref="AbLegacyTransport.Libplctag"/>.
/// Which transport stack the driver, its Test Connect probe and the CLI use. <c>null</c> (the
/// <c>transport</c> key absent) means "use the default": <see cref="AbLegacyTransport.Native"/>, UNLESS
/// any configured device is a PLC-5 or LogixPccc (limited native support), in which case the whole
/// driver resolves to <see cref="AbLegacyTransport.Libplctag"/>. An explicit value always wins. The
/// effective transport is resolved in one place, <c>AbLegacyTransportResolver</c>.
/// <para>Changing this takes effect when the driver instance is RECREATED, not on
/// <c>ReinitializeAsync</c>: the tag factory is chosen once in the constructor and held across
/// Shutdown+Initialize. A deploy that changes the driver config stops and respawns the instance
/// (<c>DriverSpawnPlanner</c>, #516), so an operator edit does recreate it.</para>
/// </summary>
public AbLegacyTransport Transport { get; init; } = AbLegacyTransport.Libplctag;
/// <remarks>Null is never written: an absent key IS the "use the default" encoding, so a round-trip
/// through any serializer (the AdminUI form included) keeps a defaulted driver defaulted rather than
/// pinning it to whatever the default happened to be on the day it was saved.</remarks>
[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
public AbLegacyTransport? Transport { get; init; }
/// <summary>Gets or sets the probe (connectivity check) options.</summary>
public AbLegacyProbeOptions Probe { get; init; } = new();
@@ -70,8 +70,9 @@ 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
_transport = AbLegacyTransportResolver.Resolve(options);
_transportDescription = AbLegacyTransportResolver.Describe(options.Transport, _transport);
_tagFactory = tagFactory ?? (_transport == AbLegacyTransport.Native
? new NativeAbLegacyTagFactory()
: new LibplctagLegacyTagFactory());
_logger = logger ?? NullLogger<AbLegacyDriver>.Instance;
@@ -93,10 +94,16 @@ 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;
/// <summary>The effective transport resolved at construction (see <see cref="AbLegacyTransportResolver"/>).</summary>
internal AbLegacyTransport EffectiveTransport => _transport;
// 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;
// Configured-vs-effective rendering of _transport for the init log, e.g. "Native (default)".
private readonly string _transportDescription;
/// <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);
@@ -136,7 +143,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
_health = new DriverHealth(DriverState.Initializing, null, null);
_logger.LogInformation(
"AbLegacy driver {DriverInstanceId} initializing: transport={Transport} tagFactory={TagFactory}",
_driverInstanceId, _transport, _tagFactory.GetType().Name);
_driverInstanceId, _transportDescription, _tagFactory.GetType().Name);
try
{
// #516: re-parse the supplied DriverConfig so a config change delivered through the IDriver
@@ -145,6 +152,19 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
if (HasConfigBody(driverConfigJson))
_options = AbLegacyDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);
// The resolver only defaults a PLC-5 / LogixPccc driver to libplctag; an operator may still pick
// Native explicitly (or a reinit may add such a device to a driver resolved Native). Allowed, but
// the limitation is client-visible, so name it once per init.
if (_transport == AbLegacyTransport.Native
&& AbLegacyTransportResolver.NativeLimitedFamilies(_options.Devices) is { Count: > 0 } limited)
{
_logger.LogWarning(
"AbLegacy driver {DriverInstanceId} runs the Native transport against {Families} device(s); native " +
"support is limited there (PLC-5: N-file reads only; LogixPccc: framing unverified). Set " +
"\"transport\": \"Libplctag\" to use the fallback stack.",
_driverInstanceId, string.Join(", ", limited));
}
foreach (var device in _options.Devices)
{
var addr = AbLegacyHostAddress.TryParse(device.HostAddress)
@@ -97,8 +97,11 @@ public static class AbLegacyDriverFactoryExtensions
ProbeAddress = dto.Probe?.ProbeAddress ?? "S:0",
},
Timeout = PositiveTimeoutOrDefault(dto.TimeoutMs, 2_000),
Transport = ParseEnum<AbLegacyTransport>(dto.Transport, driverInstanceId, "Transport",
fallback: AbLegacyTransport.Libplctag),
// Absent ⇒ null ("use the default", resolved by AbLegacyTransportResolver against the devices);
// a bad value still fails exactly as any other bad enum field does.
Transport = string.IsNullOrWhiteSpace(dto.Transport)
? null
: ParseEnum<AbLegacyTransport>(dto.Transport, driverInstanceId, "Transport"),
};
return options;
@@ -106,7 +106,7 @@ public sealed class AbLegacyDriverProbe : IDriverProbe
TagName: tagName,
Timeout: timeout);
var factory = _tagFactory ?? (opts.Transport == AbLegacyTransport.Native
var factory = _tagFactory ?? (AbLegacyTransportResolver.Resolve(opts) == AbLegacyTransport.Native
? new NativeAbLegacyTagFactory()
: new LibplctagLegacyTagFactory());
var rt = factory.Create(p);
@@ -0,0 +1,64 @@
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <summary>
/// The ONE place an AbLegacy driver's effective transport is decided from its configured
/// <see cref="AbLegacyDriverOptions.Transport"/> and its devices. The driver constructor, the Test
/// Connect probe and the CLI (through the driver) all resolve through here.
/// <para>An absent key means <see cref="AbLegacyTransport.Native"/> — UNLESS any configured device is a
/// <see cref="AbLegacyPlcFamily.Plc5"/> or <see cref="AbLegacyPlcFamily.LogixPccc"/>, whose native support
/// is limited (PLC-5: N-file reads only; LogixPccc: framing unverified). The tag factory is chosen once
/// per DRIVER, not per device, so one such device resolves the whole driver to
/// <see cref="AbLegacyTransport.Libplctag"/>. An explicit value always wins (an operator choice).</para>
/// </summary>
public static class AbLegacyTransportResolver
{
/// <summary>Returns true for a family whose native transport support is limited.</summary>
/// <param name="family">The PLC family.</param>
/// <returns>True for PLC-5 and LogixPccc.</returns>
public static bool IsNativeLimited(AbLegacyPlcFamily family) =>
family is AbLegacyPlcFamily.Plc5 or AbLegacyPlcFamily.LogixPccc;
/// <summary>Returns the distinct native-limited families among <paramref name="devices"/>.</summary>
/// <param name="devices">The configured devices.</param>
/// <returns>The limited families present, in declaration order.</returns>
public static IReadOnlyList<AbLegacyPlcFamily> NativeLimitedFamilies(IEnumerable<AbLegacyDeviceOptions> devices)
{
ArgumentNullException.ThrowIfNull(devices);
return [.. devices.Select(d => d.PlcFamily).Where(IsNativeLimited).Distinct()];
}
/// <summary>Returns the effective transport for a configured value and device set.</summary>
/// <param name="configured">The configured transport, or null for "use the default".</param>
/// <param name="devices">The configured devices.</param>
/// <returns>The transport the driver runs on.</returns>
public static AbLegacyTransport Resolve(AbLegacyTransport? configured, IEnumerable<AbLegacyDeviceOptions> devices)
{
ArgumentNullException.ThrowIfNull(devices);
if (configured is { } explicitValue) return explicitValue;
return devices.Any(d => IsNativeLimited(d.PlcFamily)) ? AbLegacyTransport.Libplctag : AbLegacyTransport.Native;
}
/// <summary>Returns the effective transport for <paramref name="options"/>.</summary>
/// <param name="options">The driver options.</param>
/// <returns>The transport the driver runs on.</returns>
public static AbLegacyTransport Resolve(AbLegacyDriverOptions options)
{
ArgumentNullException.ThrowIfNull(options);
return Resolve(options.Transport, options.Devices);
}
/// <summary>
/// Operator-facing description of the configured-vs-effective transport for the init log line, e.g.
/// <c>Native (default)</c>, <c>Libplctag (default; PLC-5/LogixPccc device present)</c> or
/// <c>Native (configured)</c>.
/// </summary>
/// <param name="configured">The configured transport, or null.</param>
/// <param name="effective">The effective transport.</param>
/// <returns>A short description.</returns>
public static string Describe(AbLegacyTransport? configured, AbLegacyTransport effective) =>
configured is not null ? $"{effective} (configured)"
: effective == AbLegacyTransport.Libplctag ? $"{effective} (default; PLC-5/LogixPccc device present)"
: $"{effective} (default)";
}