fix(driver-abcip): resolve Low code-review findings (Driver.AbCip-007,011,012,013,015)

- Driver.AbCip-007: inject an optional ILogger<AbCipDriver> /
  ILogger<AbCipAlarmProjection> (default NullLogger) and log around
  every read / write / template-fetch / probe / alarm-poll failure path.
- Driver.AbCip-011: LogWarning when InitializeAsync is configured with
  Probe.Enabled=true but ProbeTagPath is blank — operators now see why
  GetHostStatuses keeps reporting Unknown.
- Driver.AbCip-012: documented the LibplctagTemplateReader per-call
  Tag cost as accepted given libplctag's own connection pool and the
  low-frequency discovery use-case.
- Driver.AbCip-013: per-device AllowPacking + ConnectionSize overrides
  on AbCipDeviceOptions, threaded through AbCipTagCreateParams; central
  BuildCreateParams helper replaces five ad-hoc clones; AllowPacking
  now reaches Tag.AllowPacking at runtime.
- Driver.AbCip-015: stale-comment sweep — every PR-N forward-reference
  is rewritten to describe present behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-23 07:45:19 -04:00
parent 9f7ae20995
commit 77b8686199
16 changed files with 544 additions and 89 deletions

View File

@@ -1,3 +1,5 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
@@ -32,14 +34,16 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
{
private readonly AbCipDriver _driver;
private readonly TimeSpan _pollInterval;
private readonly ILogger _logger;
private readonly Dictionary<long, Subscription> _subs = new();
private readonly Lock _subsLock = new();
private long _nextId;
public AbCipAlarmProjection(AbCipDriver driver, TimeSpan pollInterval)
public AbCipAlarmProjection(AbCipDriver driver, TimeSpan pollInterval, ILogger? logger = null)
{
_driver = driver;
_pollInterval = pollInterval;
_logger = logger ?? NullLogger.Instance;
}
public async Task<IAlarmSubscriptionHandle> SubscribeAsync(
@@ -158,7 +162,14 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
Tick(sub, results);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
catch { /* per-tick failures are non-fatal; next tick retries */ }
catch (Exception ex)
{
// Per-tick failures are non-fatal; next tick retries. Log at debug because a
// wedged controller produces one exception per tick and the operator already
// sees the failed-read warning from ReadAsync below this layer; this log just
// confirms the alarm projection loop is still running.
_logger.LogDebug(ex, "AbCip alarm-projection poll tick failed (will retry)");
}
try { await Task.Delay(_pollInterval, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { break; }

View File

@@ -5,7 +5,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Logix atomic + string data types, plus a <see cref="Structure"/> marker used when a tag
/// references a UDT / predefined structure (Timer, Counter, Control). The concrete UDT
/// shape is resolved via the CIP Template Object at discovery time (PR 5 / PR 6).
/// shape is resolved via the CIP Template Object at discovery time (see
/// <see cref="CipTemplateObjectDecoder"/> + <see cref="AbCipTemplateCache"/>).
/// </summary>
/// <remarks>
/// Mirrors the shape of <c>ModbusDataType</c>. Atomic Logix names (BOOL / SINT / INT / DINT /

View File

@@ -1,3 +1,5 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
@@ -34,6 +36,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
private readonly PollGroupEngine _poll;
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, AbCipTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
private readonly ILogger<AbCipDriver> _logger;
private AbCipAlarmProjection _alarmProjection;
private DriverHealth _health = new(DriverState.Unknown, null, null);
@@ -47,7 +50,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
public AbCipDriver(AbCipDriverOptions options, string driverInstanceId,
IAbCipTagFactory? tagFactory = null,
IAbCipTagEnumeratorFactory? enumeratorFactory = null,
IAbCipTemplateReaderFactory? templateReaderFactory = null)
IAbCipTemplateReaderFactory? templateReaderFactory = null,
ILogger<AbCipDriver>? logger = null)
{
ArgumentNullException.ThrowIfNull(options);
_options = options;
@@ -55,11 +59,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
_tagFactory = tagFactory ?? new LibplctagTagFactory();
_enumeratorFactory = enumeratorFactory ?? new LibplctagTagEnumeratorFactory();
_templateReaderFactory = templateReaderFactory ?? new LibplctagTemplateReaderFactory();
_logger = logger ?? NullLogger<AbCipDriver>.Instance;
_poll = new PollGroupEngine(
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)));
_alarmProjection = new AbCipAlarmProjection(this, _options.AlarmPollInterval);
_alarmProjection = new AbCipAlarmProjection(this, _options.AlarmPollInterval, _logger);
}
/// <summary>
@@ -77,13 +82,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
if (!_devices.TryGetValue(deviceHostAddress, out var device)) return null;
var deviceParams = new AbCipTagCreateParams(
Gateway: device.ParsedAddress.Gateway,
Port: device.ParsedAddress.Port,
CipPath: device.ParsedAddress.CipPath,
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
TagName: $"@udt/{templateInstanceId}",
Timeout: _options.Timeout);
var deviceParams = device.BuildCreateParams($"@udt/{templateInstanceId}", _options.Timeout);
try
{
@@ -95,16 +94,23 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
return shape;
}
catch (OperationCanceledException) { throw; }
catch
catch (Exception ex)
{
// Template read failure — log via the driver's health surface so operators see it,
// but don't propagate since callers should fall back to declaration-driven UDT
// semantics rather than failing the whole discovery run.
// Template read failure — surface via the driver's health surface AND a warning
// log so operators see it; don't propagate since callers should fall back to
// declaration-driven UDT semantics rather than failing the whole discovery run.
_logger.LogWarning(ex,
"AbCip driver {DriverInstanceId} failed to read UDT template {TemplateInstanceId} from device {Device}; " +
"falling back to declaration-driven UDT semantics",
_driverInstanceId, templateInstanceId, deviceHostAddress);
return null;
}
}
/// <summary>Shared UDT template cache. Exposed for PR 6 (UDT reader) + diagnostics.</summary>
/// <summary>
/// Shared UDT template cache populated by <see cref="FetchUdtShapeAsync"/>. Exposed
/// internally so tests + diagnostics can inspect cached shapes.
/// </summary>
internal AbCipTemplateCache TemplateCache => _templateCache;
public string DriverInstanceId => _driverInstanceId;
@@ -132,7 +138,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
if (parsed.Devices.Count > 0 || parsed.Tags.Count > 0)
{
_options = parsed;
_alarmProjection = new AbCipAlarmProjection(this, _options.AlarmPollInterval);
_alarmProjection = new AbCipAlarmProjection(this, _options.AlarmPollInterval, _logger);
}
}
@@ -192,11 +198,24 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
state.ProbeTask = Task.Run(() => ProbeLoopAsync(state, ct), ct);
}
}
else if (_options.Probe.Enabled && _devices.Count > 0)
{
// Driver.AbCip-011: probe is Enabled but no ProbeTagPath is configured. Without a
// tag path the loop has nothing to read, so HostState would stay Unknown forever
// and GetHostStatuses() would report every device as Unknown with no warning.
// Log a warning so the misconfiguration is visible in the rolling Serilog file.
_logger.LogWarning(
"AbCip probe is enabled but no ProbeTagPath is configured for driver {DriverInstanceId} — " +
"host connectivity probe loops were NOT started; GetHostStatuses() will report every device " +
"as Unknown until a ProbeTagPath is set or Probe.Enabled is set to false.",
_driverInstanceId);
}
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
}
catch (Exception ex)
{
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
_logger.LogError(ex, "AbCip driver {DriverInstanceId} failed to initialize", _driverInstanceId);
throw;
}
return Task.CompletedTask;
@@ -307,13 +326,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
private async Task ProbeLoopAsync(DeviceState state, CancellationToken ct)
{
var probeParams = new AbCipTagCreateParams(
Gateway: state.ParsedAddress.Gateway,
Port: state.ParsedAddress.Port,
CipPath: state.ParsedAddress.CipPath,
LibplctagPlcAttribute: state.Profile.LibplctagPlcAttribute,
TagName: _options.Probe.ProbeTagPath!,
Timeout: _options.Probe.Timeout);
var probeParams = state.BuildCreateParams(_options.Probe.ProbeTagPath!, _options.Probe.Timeout);
IAbCipTagRuntime? probeRuntime = null;
while (!ct.IsCancellationRequested)
@@ -336,9 +349,14 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
{
break;
}
catch
catch (Exception ex)
{
// Wire / init error — tear down the probe runtime so the next tick re-creates it.
// Log at debug because a wedged device produces one per tick; the
// OnHostStatusChanged event is the persistent record once the state transitions.
_logger.LogDebug(ex,
"AbCip probe tick failed for driver {DriverInstanceId} device {Device}",
_driverInstanceId, state.Options.HostAddress);
try { probeRuntime?.Dispose(); } catch { }
probeRuntime = null;
state.ProbeInitialized = false;
@@ -402,7 +420,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// Task #194 — plan the batch: members of the same parent UDT get collapsed into one
// whole-UDT read + in-memory member decode; every other reference falls back to the
// per-tag path that's been here since PR 3. Planner is a pure function over the
// per-tag read path. Planner is a pure function over the
// current tag map; BOOL/String/Structure members stay on the fallback path because
// declaration-only offsets can't place them under Logix alignment rules. Whole-UDT
// grouping is itself gated behind EnableDeclarationOnlyUdtGrouping — Studio 5000 may
@@ -460,6 +478,10 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
AbCipStatusMapper.MapLibplctagStatus(status), null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
$"libplctag status {status} reading {reference}");
_logger.LogWarning(
"AbCip read returned non-zero libplctag status {LibplctagStatus} for tag {Tag} on device {Device}; " +
"evicting cached runtime so next call re-creates it",
status, reference, def.DeviceHostAddress);
return;
}
@@ -480,6 +502,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
results[fb.OriginalIndex] = new DataValueSnapshot(null,
AbCipStatusMapper.BadCommunicationError, null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
_logger.LogWarning(ex,
"AbCip read transport exception for tag {Tag} on device {Device}",
reference, def.DeviceHostAddress);
}
}
@@ -514,6 +539,10 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
StampGroupStatus(group, results, now, mapped);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
$"libplctag status {status} reading UDT {group.ParentName}");
_logger.LogWarning(
"AbCip whole-UDT read returned non-zero libplctag status {LibplctagStatus} for parent {Parent} " +
"on device {Device}; {MemberCount} member values stamped with mapped status",
status, group.ParentName, parent.DeviceHostAddress, group.Members.Count);
return;
}
@@ -533,6 +562,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
EvictRuntime(device, parent.Name); // Driver.AbCip-010
StampGroupStatus(group, results, now, AbCipStatusMapper.BadCommunicationError);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
_logger.LogWarning(ex,
"AbCip whole-UDT read transport exception for parent {Parent} on device {Device}",
group.ParentName, parent.DeviceHostAddress);
}
}
@@ -605,6 +637,10 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
{
EvictRuntime(device, def.Name); // Driver.AbCip-010
results[i] = new WriteResult(AbCipStatusMapper.MapLibplctagStatus(status));
_logger.LogWarning(
"AbCip write returned non-zero libplctag status {LibplctagStatus} for tag {Tag} on device {Device}; " +
"evicting cached runtime so next call re-creates it",
status, w.FullReference, def.DeviceHostAddress);
}
else
{
@@ -621,22 +657,34 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// Type/protocol error — not a transport fault; don't evict the handle.
results[i] = new WriteResult(AbCipStatusMapper.BadNotSupported);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
_logger.LogWarning(nse,
"AbCip write not supported for tag {Tag} on device {Device}",
w.FullReference, def.DeviceHostAddress);
}
catch (FormatException fe)
{
// Value conversion error — not a transport fault; don't evict.
results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, fe.Message);
_logger.LogWarning(fe,
"AbCip write value-conversion error for tag {Tag} on device {Device}",
w.FullReference, def.DeviceHostAddress);
}
catch (InvalidCastException ice)
{
results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ice.Message);
_logger.LogWarning(ice,
"AbCip write type-cast error for tag {Tag} on device {Device}",
w.FullReference, def.DeviceHostAddress);
}
catch (OverflowException oe)
{
results[i] = new WriteResult(AbCipStatusMapper.BadOutOfRange);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, oe.Message);
_logger.LogWarning(oe,
"AbCip write value out of range for tag {Tag} on device {Device}",
w.FullReference, def.DeviceHostAddress);
}
catch (Exception ex)
{
@@ -644,6 +692,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
EvictRuntime(device, def.Name); // Driver.AbCip-010
results[i] = new WriteResult(AbCipStatusMapper.BadCommunicationError);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
_logger.LogWarning(ex,
"AbCip write transport exception for tag {Tag} on device {Device}",
w.FullReference, def.DeviceHostAddress);
}
}
@@ -698,13 +749,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
{
if (device.ParentRuntimes.TryGetValue(parentTagName, out var existing)) return existing;
var runtime = _tagFactory.Create(new AbCipTagCreateParams(
Gateway: device.ParsedAddress.Gateway,
Port: device.ParsedAddress.Port,
CipPath: device.ParsedAddress.CipPath,
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
TagName: parentTagName,
Timeout: _options.Timeout));
var runtime = _tagFactory.Create(device.BuildCreateParams(parentTagName, _options.Timeout));
try
{
await runtime.InitializeAsync(ct).ConfigureAwait(false);
@@ -736,13 +781,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
?? throw new InvalidOperationException(
$"AbCip tag '{def.Name}' has malformed TagPath '{def.TagPath}'.");
var runtime = _tagFactory.Create(new AbCipTagCreateParams(
Gateway: device.ParsedAddress.Gateway,
Port: device.ParsedAddress.Port,
CipPath: device.ParsedAddress.CipPath,
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
TagName: parsed.ToLibplctagName(),
Timeout: _options.Timeout));
var runtime = _tagFactory.Create(device.BuildCreateParams(parsed.ToLibplctagName(), _options.Timeout));
try
{
await runtime.InitializeAsync(ct).ConfigureAwait(false);
@@ -850,13 +889,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
if (_options.EnableControllerBrowse && _devices.TryGetValue(device.HostAddress, out var state))
{
using var enumerator = _enumeratorFactory.Create();
var deviceParams = new AbCipTagCreateParams(
Gateway: state.ParsedAddress.Gateway,
Port: state.ParsedAddress.Port,
CipPath: state.ParsedAddress.CipPath,
LibplctagPlcAttribute: state.Profile.LibplctagPlcAttribute,
TagName: "@tags",
Timeout: _options.Timeout);
var deviceParams = state.BuildCreateParams("@tags", _options.Timeout);
IAddressSpaceBuilder? discoveredFolder = null;
await foreach (var discovered in enumerator.EnumerateAsync(deviceParams, cancellationToken)
@@ -966,6 +999,23 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
public SemaphoreSlim GetRmwLock(string parentTagName) =>
_rmwLocks.GetOrAdd(parentTagName, _ => new SemaphoreSlim(1, 1));
/// <summary>
/// Driver.AbCip-013 — compute the effective <see cref="AbCipTagCreateParams"/> for a
/// tag on this device. Combines the per-device options
/// (<see cref="AbCipDeviceOptions.AllowPacking"/>,
/// <see cref="AbCipDeviceOptions.ConnectionSize"/>) with the family profile defaults
/// so the wire layer sees one place that resolves both.
/// </summary>
public AbCipTagCreateParams BuildCreateParams(string tagName, TimeSpan timeout) => new(
Gateway: ParsedAddress.Gateway,
Port: ParsedAddress.Port,
CipPath: ParsedAddress.CipPath,
LibplctagPlcAttribute: Profile.LibplctagPlcAttribute,
TagName: tagName,
Timeout: timeout,
AllowPacking: Options.AllowPacking ?? Profile.SupportsRequestPacking,
ConnectionSize: Options.ConnectionSize ?? Profile.DefaultConnectionSize);
public void DisposeHandles()
{
foreach (var r in Runtimes.Values) r.Dispose();

View File

@@ -51,7 +51,9 @@ public static class AbCipDriverFactoryExtensions
$"AB CIP config for '{driverInstanceId}' has a device missing HostAddress"),
PlcFamily: ParseEnum<AbCipPlcFamily>(d.PlcFamily, "device", driverInstanceId, "PlcFamily",
fallback: AbCipPlcFamily.ControlLogix),
DeviceName: d.DeviceName))]
DeviceName: d.DeviceName,
AllowPacking: d.AllowPacking,
ConnectionSize: d.ConnectionSize))]
: [],
Tags = dto.Tags is { Count: > 0 }
? [.. dto.Tags.Select(t => BuildTag(t, driverInstanceId))]
@@ -133,6 +135,8 @@ public static class AbCipDriverFactoryExtensions
public string? HostAddress { get; init; }
public string? PlcFamily { get; init; }
public string? DeviceName { get; init; }
public bool? AllowPacking { get; init; }
public int? ConnectionSize { get; init; }
}
internal sealed class AbCipTagDto

View File

@@ -18,7 +18,11 @@ public sealed class AbCipDriverOptions
/// </summary>
public IReadOnlyList<AbCipDeviceOptions> Devices { get; init; } = [];
/// <summary>Pre-declared tag map across all devices — AB discovery lands in PR 5.</summary>
/// <summary>
/// Pre-declared tag map across all devices. Pre-declared tags always emit during
/// discovery; opt in to controller-side discovery via
/// <see cref="EnableControllerBrowse"/>.
/// </summary>
public IReadOnlyList<AbCipTagDefinition> Tags { get; init; } = [];
/// <summary>Per-device probe settings. Falls back to defaults when omitted.</summary>
@@ -78,13 +82,28 @@ public sealed class AbCipDriverOptions
/// initialization rather than silently connecting to nothing.
/// </summary>
/// <param name="HostAddress">Canonical <c>ab://gateway[:port]/cip-path</c> string.</param>
/// <param name="PlcFamily">Which per-family profile to apply. Determines ConnectionSize,
/// request-packing support, unconnected-only hint, and other quirks.</param>
/// <param name="PlcFamily">Which per-family profile to apply. Determines the family
/// <c>AllowPacking</c> default, <c>ConnectionSize</c> default, unconnected-only hint, and
/// other quirks; per-device overrides via <see cref="AllowPacking"/> and
/// <see cref="ConnectionSize"/> take precedence when set.</param>
/// <param name="DeviceName">Optional display label for Admin UI. Falls back to <see cref="HostAddress"/>.</param>
/// <param name="AllowPacking">Driver.AbCip-013 — per-device override for CIP request-packing
/// (firmware 20+). <c>null</c> (the default) inherits the family profile's
/// <c>SupportsRequestPacking</c>; set explicitly to opt a single device in or out without
/// touching every other device on the same family.</param>
/// <param name="ConnectionSize">Driver.AbCip-013 — per-device override for the Forward Open
/// ConnectionSize (Large Forward Open packet size in bytes). <c>null</c> inherits the family
/// profile's <c>DefaultConnectionSize</c>. Honoured by the driver layer; the underlying
/// libplctag 1.5.2 wrapper has no direct <c>ConnectionSize</c> property, so the value is
/// plumbed through <see cref="AbCipTagCreateParams"/> for forward-compat with future wrapper
/// versions or a custom tag-attribute path; current builds use the family profile default at
/// the wire layer regardless.</param>
public sealed record AbCipDeviceOptions(
string HostAddress,
AbCipPlcFamily PlcFamily = AbCipPlcFamily.ControlLogix,
string? DeviceName = null);
string? DeviceName = null,
bool? AllowPacking = null,
int? ConnectionSize = null);
/// <summary>
/// One AB-backed OPC UA variable. Mirrors the <c>ModbusTagDefinition</c> shape.
@@ -149,9 +168,11 @@ public sealed class AbCipProbeOptions
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
/// <summary>
/// Tag path used for the probe. If null, the driver attempts to read a default
/// system tag (PR 8 wires this up — the choice is family-dependent, e.g.
/// <c>@raw_cpu_type</c> on ControlLogix or a user-configured probe tag on Micro800).
/// Tag path used for the probe. When <see cref="Enabled"/> is <c>true</c> but this is
/// <c>null</c>/blank, the driver logs a warning and runs no probe loops (Driver.AbCip-011);
/// <c>GetHostStatuses()</c> will then report every device as <c>Unknown</c>. A family-default
/// system-tag fallback (e.g. <c>@raw_cpu_type</c> on ControlLogix) is a deferred follow-up;
/// today an operator opting into the probe must supply a tag path explicitly.
/// </summary>
public string? ProbeTagPath { get; init; }
}

View File

@@ -9,8 +9,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// attribute consumes.
/// </summary>
/// <remarks>
/// Scope + members + subscripts are captured structurally so PR 6 (UDT support) can walk
/// the path against a cached template without re-parsing. <see cref="BitIndex"/> is
/// Scope + members + subscripts are captured structurally so UDT support can walk the
/// path against a cached template (see <see cref="AbCipTemplateCache"/>) without
/// re-parsing. <see cref="BitIndex"/> is
/// non-null only when the trailing segment is a decimal integer between 0 and 31 that
/// parses as a bit-selector — this is the <c>.N</c> syntax documented in the Logix 5000
/// General Instructions Reference §Tags, and it applies only to DINT-typed parents. The

View File

@@ -9,9 +9,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <c>ReinitializeAsync</c>.
/// </summary>
/// <remarks>
/// Template shape read (CIP Template Object class 0x6C, <c>GetAttributeList</c> +
/// <c>Read Template</c>) lands with PR 6. This class ships the cache surface so PR 6 can
/// drop the decoder in without reshaping any caller code.
/// Templates are decoded by <see cref="CipTemplateObjectDecoder"/> (CIP Template Object
/// class 0x6C, <c>GetAttributeList</c> + <c>Read Template</c>); the live reader is
/// <see cref="LibplctagTemplateReader"/>, and <see cref="AbCipDriver.FetchUdtShapeAsync"/>
/// populates this cache on first fetch.
/// </remarks>
public sealed class AbCipTemplateCache
{
@@ -36,8 +37,8 @@ public sealed class AbCipTemplateCache
/// <summary>
/// Decoded shape of one Logix UDT — member list + each member's offset + type. Populated
/// by PR 6's Template Object reader. At PR 5 time this is the cache's value type only;
/// no reader writes to it yet.
/// by <see cref="CipTemplateObjectDecoder.Decode"/> from a Template Object response buffer
/// read via <see cref="LibplctagTemplateReader"/>.
/// </summary>
/// <param name="TypeName">UDT name as reported by the Template Object.</param>
/// <param name="TotalSize">Bytes the UDT occupies in a whole-UDT read buffer.</param>

View File

@@ -3,11 +3,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Swappable scanner that walks a controller's symbol table (via libplctag's
/// <c>@tags</c> pseudo-tag or the CIP Symbol Object class 0x6B) and yields the tags it
/// finds. Defaults to <see cref="EmptyAbCipTagEnumeratorFactory"/> which returns no
/// controller-side tags — the full <c>@tags</c> decoder lands as a follow-up PR once
/// libplctag 1.5.2 either gains <c>TagInfoPlcMapper</c> upstream or we ship our own
/// <c>IPlcMapper</c> for the Symbol Object byte layout (tracked via follow-up task; PR 5
/// ships the abstraction + pre-declared-tag emission).
/// finds. Production default is <see cref="LibplctagTagEnumeratorFactory"/> which speaks
/// to the live controller; <see cref="EmptyAbCipTagEnumeratorFactory"/> is available for
/// tests / strict-config deployments where only pre-declared tags should appear.
/// </summary>
public interface IAbCipTagEnumerator : IDisposable
{
@@ -30,7 +28,8 @@ public interface IAbCipTagEnumeratorFactory
/// <param name="Name">Logix symbolic name as returned by the Symbol Object.</param>
/// <param name="ProgramScope">Program name if the tag is program-scoped; <c>null</c> for controller scope.</param>
/// <param name="DataType">Detected data type; <see cref="AbCipDataType.Structure"/> when the tag
/// is UDT-typed — the UDT shape lookup + per-member expansion ship with PR 6.</param>
/// is UDT-typed — per-member expansion runs against the cached
/// <see cref="AbCipUdtShape"/> via <see cref="AbCipTemplateCache"/>.</param>
/// <param name="ReadOnly"><c>true</c> when the Symbol Object's External Access attribute forbids writes.</param>
/// <param name="IsSystemTag">Hint from the enumerator that this is a system / infrastructure tag;
/// the driver applies <see cref="AbCipSystemTagFilter"/> on top so the enumerator is not the
@@ -43,9 +42,10 @@ public sealed record AbCipDiscoveredTag(
bool IsSystemTag = false);
/// <summary>
/// Default production enumerator — currently returns an empty sequence. The real <c>@tags</c>
/// walk lands as a follow-up PR. Documented in <c>driver-specs.md §3</c> as the gap the
/// Symbol Object walker closes.
/// No-op enumerator returning an empty sequence. Useful for tests + strict-config
/// deployments where <see cref="AbCipDriverOptions.EnableControllerBrowse"/> is set but the
/// operator wants only pre-declared tags to surface. Production drivers use
/// <see cref="LibplctagTagEnumeratorFactory"/> instead.
/// </summary>
internal sealed class EmptyAbCipTagEnumerator : IAbCipTagEnumerator
{

View File

@@ -65,10 +65,19 @@ public interface IAbCipTagFactory
/// <param name="LibplctagPlcAttribute">libplctag <c>plc=...</c> attribute, per family profile.</param>
/// <param name="TagName">Logix symbolic tag name as emitted by <see cref="AbCipTagPath.ToLibplctagName"/>.</param>
/// <param name="Timeout">libplctag operation timeout (applies to Initialize / Read / Write).</param>
/// <param name="AllowPacking">CIP request-packing flag — combines the per-device override (if
/// any) with the family profile's <c>SupportsRequestPacking</c>. Forwarded to the libplctag
/// <c>Tag.AllowPacking</c> property (Driver.AbCip-013).</param>
/// <param name="ConnectionSize">Forward Open ConnectionSize — combines the per-device override
/// (if any) with the family profile's <c>DefaultConnectionSize</c>. libplctag 1.5.2 has no
/// direct <c>ConnectionSize</c> property; the value is plumbed for forward-compat with future
/// wrappers / a custom tag-attribute path (Driver.AbCip-013).</param>
public sealed record AbCipTagCreateParams(
string Gateway,
int Port,
string CipPath,
string LibplctagPlcAttribute,
string TagName,
TimeSpan Timeout);
TimeSpan Timeout,
bool AllowPacking = true,
int ConnectionSize = 4002);

View File

@@ -13,9 +13,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// tag name is <c>@tags</c>. The decoder walks the concatenated entries + emits
/// <see cref="AbCipDiscoveredTag"/> records matching our driver surface.</para>
///
/// <para>Task #178 closed the stub gap from PR 5 — <see cref="EmptyAbCipTagEnumerator"/>
/// is still available for tests that don't want to touch the native library, but the
/// production factory default now wires this implementation in.</para>
/// <para><see cref="EmptyAbCipTagEnumerator"/> remains available for tests that don't want
/// to touch the native library; the production factory default
/// (<see cref="LibplctagTagEnumeratorFactory"/>) wires this implementation in.</para>
/// </remarks>
internal sealed class LibplctagTagEnumerator : IAbCipTagEnumerator
{

View File

@@ -23,7 +23,15 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
Protocol = Protocol.ab_eip,
Name = p.TagName,
Timeout = p.Timeout,
// Driver.AbCip-013 — honour the per-device or family-default AllowPacking knob so
// operators can disable CIP request-packing for older firmware or a single device.
AllowPacking = p.AllowPacking,
};
// ConnectionSize is captured on AbCipTagCreateParams for forward-compat (driver-specs.md
// exposes it as a per-device option) but libplctag.NET 1.5.2 has no direct Tag property
// for it. Until the wrapper exposes one (or we ship a custom tag-attribute path), the
// family profile DefaultConnectionSize is what the underlying CIP Forward Open
// negotiates with — Driver.AbCip-013.
}
public Task InitializeAsync(CancellationToken cancellationToken) => _tag.InitializeAsync(cancellationToken);
@@ -108,7 +116,11 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
_tag.SetInt32(0, Convert.ToInt32(value));
break;
case AbCipDataType.Structure:
throw new NotSupportedException("Whole-UDT writes land in PR 6.");
// Whole-UDT writes are not implemented — operators address individual UDT
// members via dotted tag paths, which route per-member through the atomic
// encode cases above. A whole-UDT writer is a deferred follow-up.
throw new NotSupportedException(
"Whole-UDT writes are not supported — address individual member paths instead.");
default:
throw new NotSupportedException($"AbCipDataType {type} not writable.");
}

View File

@@ -8,6 +8,19 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// internally via a normal read call, + returns the raw byte buffer so
/// <see cref="CipTemplateObjectDecoder"/> can decode it.
/// </summary>
/// <remarks>
/// Driver.AbCip-012 — by design each <c>FetchUdtShapeAsync</c> call creates one fresh
/// <see cref="Tag"/>, pays one CIP connection handshake, reads, and disposes. Per-type
/// connect cost is accepted because (a) template reads are a low-frequency discovery path
/// (one-shot per UDT type, then the decoded shape is cached in
/// <see cref="AbCipTemplateCache"/>), (b) libplctag pools its underlying CIP connections per
/// gateway+path so the underlying TCP/EIP session is reused even when individual
/// <see cref="Tag"/> instances are torn down, and (c) pooling at the wrapper layer here would
/// buy a single Forward Open per device per discovery run — small relative to the rest of a
/// bulk-tag-walk discovery. If telemetry ever shows discovery latency dominated by
/// template-read connects, revisit by holding one <c>@udt</c>-capable <see cref="Tag"/> per
/// device for the duration of a discovery run.
/// </remarks>
internal sealed class LibplctagTemplateReader : IAbCipTemplateReader
{
private Tag? _tag;

View File

@@ -8,7 +8,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
/// <remarks>
/// Mirrors the shape of the Modbus driver's per-family profiles (DL205, Siemens S7,
/// Mitsubishi MELSEC). ControlLogix is the baseline; each subsequent family is a delta.
/// Family-specific wire tests ship in PRs 912.
/// Family-specific behaviour (ControlLogix / CompactLogix / Micro800 / GuardLogix) is
/// covered by the unit tests in <c>AbCipPlcFamilyTests</c>.
/// </remarks>
public sealed record AbCipPlcFamilyProfile(
string LibplctagPlcAttribute,