v3(b1-abcip): RawPath read-path for AbCip (multi-device)

Apply the reviewed Modbus exemplar to the AbCip multi-device driver:
- Mapper AbCipEquipmentTagParser -> AbCipTagDefinitionFactory; TryParse -> FromTagConfig(tagConfig, rawPath, out def); drop leading-{ heuristic + the deviceHostAddress key; add inverse ToTagConfig; round-trip arrays/members/safety for coverage.
- Options: Tags (typed) -> RawTags (RawTagEntry list); Devices list kept.
- Driver: byName-only resolver over _tagsByRawPath (Ordinal); table built from RawTags via FromTagConfig, threading entry.DeviceName (device routing key) + entry.WriteIdempotent onto the def. Multi-device routing keys on DeviceName-or-host via a _devicesByRoutingKey index; discovery groups _declaredTags by RoutesToDevice.
- Factory: retire the pre-declared Tags DTO/BuildTag; bind List<RawTagEntry> RawTags.
- Probe: derive probe tag path from RawTags via the mapper.
- Cli BuildOptions projects typed tags -> RawTagEntry (ToTagConfig).
- Tests: AbCipRawTags helper (typed def -> RawTagEntry); parser tests -> FromTagConfig; blob-fallback driver tests rewritten to author via RawTags + read by RawPath. Driver.AbCip.Tests 342/342, Cli.Tests 42/42.

TODO(v3 WaveC): DeviceName becomes a pure logical name once the device connection host moves to the Device row's DeviceConfig.
This commit is contained in:
Joseph Doherty
2026-07-15 20:18:28 -04:00
parent c379e246d0
commit c0379742bc
32 changed files with 819 additions and 783 deletions
@@ -82,12 +82,23 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
_discoveredUdtShapes[UdtShapeKey(deviceHostAddress, structName)] = shape;
private readonly PollGroupEngine _poll;
// Devices keyed by host address — the primary registry (GetDeviceState, discovery, probe, diagnostics).
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, AbCipTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
// Tag-routing index: a tag's device routing key (DeviceName ?? HostAddress, plus the HostAddress alias)
// → its DeviceState. A tag resolves to its device by DeviceName-or-host so multi-device routing keys on
// the RawPath's device segment. TODO(v3 WaveC): collapses to a pure DeviceName index once the connection
// host moves into the Device row's DeviceConfig.
private readonly Dictionary<string, DeviceState> _devicesByRoutingKey = new(StringComparer.OrdinalIgnoreCase);
// v3 authored RawPath → definition table (parents + fanned-out UDT members), keyed case-sensitive
// ordinal. Built in InitializeAsync from _options.RawTags via the pure mapper.
private readonly Dictionary<string, AbCipTagDefinition> _tagsByRawPath = new(StringComparer.Ordinal);
// Top-level authored definitions (parents + flat tags, NOT synthesised members), in author order —
// the source DiscoverAsync groups by device. Members are synthesised inside the discovery/read paths.
private readonly List<AbCipTagDefinition> _declaredTags = new();
// Resolves a read/write/subscribe fullReference to a tag definition, bridging the two
// authoring models: an authored tag-table entry (by name) OR an equipment tag whose
// reference is its raw TagConfig JSON (parsed once via AbCipEquipmentTagParser, cached).
// Resolves a read/write/subscribe fullReference (always a RawPath in v3) to a tag definition by a
// single hit on the authored _tagsByRawPath table; a miss is a miss (the pre-v3 blob-parse fallback
// is retired — see EquipmentTagRefResolver).
private readonly EquipmentTagRefResolver<AbCipTagDefinition> _resolver;
private readonly ILogger<AbCipDriver> _logger;
@@ -128,8 +139,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
_templateReaderFactory = templateReaderFactory ?? new LibplctagTemplateReaderFactory();
_logger = logger ?? NullLogger<AbCipDriver>.Instance;
_resolver = new EquipmentTagRefResolver<AbCipTagDefinition>(
r => _tagsByName.TryGetValue(r, out var t) ? t : null,
r => AbCipEquipmentTagParser.TryParse(r, out var d) ? d : null);
r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
_poll = new PollGroupEngine(
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
@@ -248,7 +258,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
if (!string.IsNullOrWhiteSpace(driverConfigJson))
{
var parsed = AbCipDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);
if (parsed.Devices.Count > 0 || parsed.Tags.Count > 0)
if (parsed.Devices.Count > 0 || parsed.RawTags.Count > 0)
{
_options = parsed;
_alarmProjection = new AbCipAlarmProjection(this, _options.AlarmPollInterval, _logger);
@@ -261,18 +271,39 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
?? throw new InvalidOperationException(
$"AbCip device has invalid HostAddress '{device.HostAddress}' — expected 'ab://gateway[:port]/cip-path'.");
var profile = AbCipPlcFamilyProfile.ForFamily(device.PlcFamily);
_devices[device.HostAddress] = new DeviceState(addr, device, profile);
var state = new DeviceState(addr, device, profile);
_devices[device.HostAddress] = state;
// Route index: a tag reaches this device by matching its DeviceName routing key OR its
// host address. Devices with no explicit DeviceName are identified purely by host today.
_devicesByRoutingKey[device.HostAddress] = state;
if (!string.IsNullOrEmpty(device.DeviceName))
_devicesByRoutingKey[device.DeviceName] = state;
}
foreach (var tag in _options.Tags)
// Build the RawPath → definition table from the authored raw tags. Each entry's TagConfig blob
// is mapped by the pure factory; the entry's DeviceName (routing key) + WriteIdempotent flag are
// threaded onto the def (they live on the RawTagEntry, not inside the blob). A mapper miss is
// skipped (logged), never thrown — a bad tag must not fail the whole driver init.
foreach (var entry in _options.RawTags)
{
// Duplicate-key check: a collision means two configured tags have the same name.
if (!AbCipTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var mapped))
{
_logger.LogWarning(
"AbCip tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
_driverInstanceId, entry.RawPath);
continue;
}
var tag = mapped with { DeviceHostAddress = entry.DeviceName, WriteIdempotent = entry.WriteIdempotent };
// Duplicate-key check: a collision means two authored tags share the same RawPath.
// Fail fast at init time with a diagnostic rather than silently clobbering.
if (_tagsByName.TryGetValue(tag.Name, out var existingTag))
if (_tagsByRawPath.TryGetValue(tag.Name, out var existingTag))
throw new InvalidOperationException(
$"AbCip tag name collision: '{tag.Name}' is declared more than once. " +
$"Existing entry DeviceHostAddress='{existingTag.DeviceHostAddress}', " +
$"TagPath='{existingTag.TagPath}'. Rename or remove the duplicate.");
_tagsByName[tag.Name] = tag;
_tagsByRawPath[tag.Name] = tag;
_declaredTags.Add(tag);
if (tag.DataType == AbCipDataType.Structure && tag.Members is { Count: > 0 })
{
@@ -295,12 +326,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// Member fan-out duplicate check: a member-path collision means two
// configured structure tags produce the same member path, or a member
// name collides with an independently-declared tag.
if (_tagsByName.TryGetValue(memberTag.Name, out var existingMember))
if (_tagsByRawPath.TryGetValue(memberTag.Name, out var existingMember))
throw new InvalidOperationException(
$"AbCip tag name collision: '{memberTag.Name}' is produced by both " +
$"'{tag.Name}.{member.Name}' (member fan-out) and an existing tag " +
$"'{existingMember.Name}'. Rename one of the configured tags to resolve.");
_tagsByName[memberTag.Name] = memberTag;
_tagsByRawPath[memberTag.Name] = memberTag;
}
}
}
@@ -383,8 +414,10 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
state.DisposeHandles();
}
_devices.Clear();
_tagsByName.Clear();
_resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses
_devicesByRoutingKey.Clear();
_tagsByRawPath.Clear();
_declaredTags.Clear();
_resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
}
@@ -502,19 +535,19 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// ---- IPerCallHostResolver ----
/// <summary>
/// Resolve a read/write/subscribe reference to the device host that keys its per-host
/// resilience (bulkhead / circuit breaker). CONV-4: routes through
/// <see cref="EquipmentTagRefResolver{TDef}"/> so an equipment-tag reference (raw TagConfig
/// JSON) resolves to its OWN authored device rather than the first-device fallback — else a
/// broken device B would trip device A's breaker. The resolver caches parses, so this adds
/// no per-call cost after first use.
/// Resolve a read/write/subscribe reference (a RawPath in v3) to the device host that keys its
/// per-host resilience (bulkhead / circuit breaker). CONV-4: routes through the authored table so a
/// tag resolves to its OWN device's host rather than the first-device fallback — else a broken
/// device B would trip device A's breaker. The tag's device routing key is resolved to the device's
/// actual host address (the resilience key), matching by DeviceName-or-host.
/// </summary>
/// <param name="fullReference">The tag reference (authored name or equipment-tag JSON).</param>
/// <param name="fullReference">The tag RawPath reference.</param>
/// <returns>The device host key.</returns>
public string ResolveHost(string fullReference)
{
if (_resolver.TryResolve(fullReference, out var def) && !string.IsNullOrEmpty(def.DeviceHostAddress))
return def.DeviceHostAddress;
if (_resolver.TryResolve(fullReference, out var def)
&& _devicesByRoutingKey.TryGetValue(def.DeviceHostAddress, out var state))
return state.Options.HostAddress;
return _options.Devices.FirstOrDefault()?.HostAddress ?? DriverInstanceId;
}
@@ -536,7 +569,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// grouping is itself gated behind EnableDeclarationOnlyUdtGrouping — Studio 5000 may
// reorder UDT members vs declaration order, so the fast path is opt-in only.
var plan = AbCipUdtReadPlanner.Build(
fullReferences, _tagsByName, _options.EnableDeclarationOnlyUdtGrouping);
fullReferences, _tagsByRawPath, _options.EnableDeclarationOnlyUdtGrouping);
foreach (var group in plan.Groups)
await ReadGroupAsync(group, results, now, cancellationToken).ConfigureAwait(false);
@@ -564,7 +597,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNotSupported, null, now);
return;
}
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
if (!_devicesByRoutingKey.TryGetValue(def.DeviceHostAddress, out var device))
{
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
return;
@@ -646,7 +679,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
{
var parent = group.ParentDefinition;
if (!_devices.TryGetValue(parent.DeviceHostAddress, out var device))
if (!_devicesByRoutingKey.TryGetValue(parent.DeviceHostAddress, out var device))
{
StampGroupStatus(group, results, now, AbCipStatusMapper.BadNodeIdUnknown);
return;
@@ -736,7 +769,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
results[i] = new WriteResult(AbCipStatusMapper.BadNotWritable);
continue;
}
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
if (!_devicesByRoutingKey.TryGetValue(def.DeviceHostAddress, out var device))
{
results[i] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
continue;
@@ -1017,9 +1050,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// Pre-declared tags — always emitted; the primary config path. UDT tags with declared
// Members fan out into a sub-folder + one Variable per member instead of a single
// Structure Variable (Structure has no useful scalar value + member-addressable paths
// are what downstream consumers actually want).
var preDeclared = _options.Tags.Where(t =>
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
// are what downstream consumers actually want). A tag reaches this device when its
// device routing key matches the device's DeviceName or its host address.
var preDeclared = _declaredTags.Where(t => RoutesToDevice(t, device));
foreach (var tag in preDeclared)
{
if (AbCipSystemTagFilter.IsSystemTag(tag.Name)) continue;
@@ -1235,6 +1268,14 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// a legacy definition carrying only ElementCount > 1 stays an array for back-compat.
private static bool IsArrayTag(AbCipTagDefinition tag) => tag.IsArray || tag.ElementCount > 1;
// A tag routes to a device when its device routing key (DeviceHostAddress, threaded from the
// RawTagEntry.DeviceName) matches the device's DeviceName or its host address. TODO(v3 WaveC):
// collapses to a DeviceName-only match once the connection host lives on the Device row's DeviceConfig.
private static bool RoutesToDevice(AbCipTagDefinition tag, AbCipDeviceOptions device) =>
string.Equals(tag.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase)
|| (!string.IsNullOrEmpty(device.DeviceName)
&& string.Equals(tag.DeviceHostAddress, device.DeviceName, StringComparison.OrdinalIgnoreCase));
private static DriverAttributeInfo ToAttributeInfo(AbCipTagDefinition tag) => new(
FullName: tag.Name,
DriverDataType: tag.DataType.ToDriverDataType(),
@@ -1,5 +1,5 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
@@ -68,9 +68,11 @@ public static class AbCipDriverFactoryExtensions
AllowPacking: d.AllowPacking,
ConnectionSize: d.ConnectionSize))]
: [],
Tags = dto.Tags is { Count: > 0 }
? [.. dto.Tags.Select(t => BuildTag(t, driverInstanceId))]
: [],
// v3: the deploy artifact (Wave C) populates RawTags with the cluster's authored raw AbCip
// tags (RawPath + TagConfig blob + WriteIdempotent + DeviceName). The driver maps each entry
// through AbCipTagDefinitionFactory.FromTagConfig at Initialize; the legacy pre-declared DTO
// tag path (structured Tags[] → BuildTag) is retired.
RawTags = dto.RawTags is { Count: > 0 } ? [.. dto.RawTags] : [],
Probe = new AbCipProbeOptions
{
Enabled = dto.Probe?.Enabled ?? true,
@@ -100,32 +102,6 @@ public static class AbCipDriverFactoryExtensions
private static TimeSpan PositiveTimeoutOrDefault(int? ms, int defaultMs) =>
TimeSpan.FromMilliseconds(ms is int v and > 0 ? v : defaultMs);
private static AbCipTagDefinition BuildTag(AbCipTagDto t, string driverInstanceId) =>
new(
Name: t.Name ?? throw new InvalidOperationException(
$"AB CIP config for '{driverInstanceId}' has a tag missing Name"),
DeviceHostAddress: t.DeviceHostAddress ?? throw new InvalidOperationException(
$"AB CIP tag '{t.Name}' in '{driverInstanceId}' missing DeviceHostAddress"),
TagPath: t.TagPath ?? throw new InvalidOperationException(
$"AB CIP tag '{t.Name}' in '{driverInstanceId}' missing TagPath"),
DataType: ParseEnum<AbCipDataType>(t.DataType, t.Name, driverInstanceId, "DataType"),
Writable: t.Writable ?? true,
WriteIdempotent: t.WriteIdempotent ?? false,
Members: t.Members is { Count: > 0 }
? [.. t.Members.Select(m => new AbCipStructureMember(
Name: m.Name ?? throw new InvalidOperationException(
$"AB CIP tag '{t.Name}' in '{driverInstanceId}' has a member missing Name"),
DataType: ParseEnum<AbCipDataType>(m.DataType, t.Name, driverInstanceId,
$"Members[{m.Name}].DataType"),
Writable: m.Writable ?? true,
WriteIdempotent: m.WriteIdempotent ?? false,
ElementCount: m.ElementCount is > 0 ? m.ElementCount.Value : 1,
IsArray: m.IsArray))]
: null,
SafetyTag: t.SafetyTag ?? false,
ElementCount: t.ElementCount is > 0 ? t.ElementCount.Value : 1,
IsArray: t.IsArray);
private static T ParseEnum<T>(string? raw, string? tagName, string driverInstanceId, string field,
T? fallback = null) where T : struct, Enum
{
@@ -182,9 +158,10 @@ public static class AbCipDriverFactoryExtensions
public List<AbCipDeviceDto>? Devices { get; init; }
/// <summary>
/// Gets or sets the list of tags to monitor.
/// Gets or sets the authored raw tags (RawPath + TagConfig blob + WriteIdempotent + DeviceName)
/// the deploy artifact delivers. The driver maps each through the tag-definition factory at Initialize.
/// </summary>
public List<AbCipTagDto>? Tags { get; init; }
public List<RawTagEntry>? RawTags { get; init; }
/// <summary>
/// Gets or sets the probe configuration.
@@ -220,100 +197,6 @@ public static class AbCipDriverFactoryExtensions
public int? ConnectionSize { get; init; }
}
internal sealed class AbCipTagDto
{
/// <summary>
/// Gets or sets the tag name.
/// </summary>
public string? Name { get; init; }
/// <summary>
/// Gets or sets the device host address.
/// </summary>
public string? DeviceHostAddress { get; init; }
/// <summary>
/// Gets or sets the tag path.
/// </summary>
public string? TagPath { get; init; }
/// <summary>
/// Gets or sets the data type.
/// </summary>
public string? DataType { get; init; }
/// <summary>
/// Gets or sets whether the tag is writable.
/// </summary>
public bool? Writable { get; init; }
/// <summary>
/// Gets or sets whether write is idempotent.
/// </summary>
public bool? WriteIdempotent { get; init; }
/// <summary>
/// Gets or sets the list of structure members.
/// </summary>
public List<AbCipMemberDto>? Members { get; init; }
/// <summary>
/// Gets or sets whether this is a safety tag.
/// </summary>
public bool? SafetyTag { get; init; }
/// <summary>
/// Gets or sets the explicit 1-D array signal — <c>true</c> ⟺ the tag is an array (even a
/// 1-element one). Mirrors <c>AbCipTagDefinition.IsArray</c>; optional (absent ⇒ scalar).
/// </summary>
[JsonPropertyName("isArray")]
public bool IsArray { get; init; }
/// <summary>
/// Gets or sets the number of elements for a 1-D array tag; <c>1</c> (or absent) for a scalar.
/// Mirrors <c>AbCipTagDefinition.ElementCount</c>; only positive values are honoured.
/// </summary>
[JsonPropertyName("elementCount")]
public int? ElementCount { get; init; }
}
internal sealed class AbCipMemberDto
{
/// <summary>
/// Gets or sets the member name.
/// </summary>
public string? Name { get; init; }
/// <summary>
/// Gets or sets the data type.
/// </summary>
public string? DataType { get; init; }
/// <summary>
/// Gets or sets whether the member is writable.
/// </summary>
public bool? Writable { get; init; }
/// <summary>
/// Gets or sets whether write is idempotent.
/// </summary>
public bool? WriteIdempotent { get; init; }
/// <summary>
/// Gets or sets the explicit 1-D array signal for the member — <c>true</c> ⟺ the member is
/// an array (even a 1-element one). Mirrors <c>AbCipStructureMember.IsArray</c>; optional.
/// </summary>
[JsonPropertyName("isArray")]
public bool IsArray { get; init; }
/// <summary>
/// Gets or sets the number of elements for a 1-D array member; <c>1</c> (or absent) for a
/// scalar. Mirrors <c>AbCipStructureMember.ElementCount</c>; only positive values are honoured.
/// </summary>
[JsonPropertyName("elementCount")]
public int? ElementCount { get; init; }
}
internal sealed class AbCipProbeDto
{
/// <summary>
@@ -89,14 +89,22 @@ public sealed class AbCipDriverProbe : IDriverProbe
// Forward Open to confirm the remote endpoint speaks CIP, not just TCP.
var profile = AbCipPlcFamilyProfile.ForFamily(firstDevice?.PlcFamily ?? AbCipPlcFamily.ControlLogix);
// Prefer the first declared tag path as the probe tag name (it must exist on the PLC).
// Fall back to a benign system attribute that is present on all ControlLogix/CompactLogix
// controllers (@raw_cpu_type); still returns ErrorNotFound on Micro800 / MicroLogix, which
// is sufficient to confirm CIP reachability.
var tagName = opts.Tags.FirstOrDefault(
t => string.Equals(t.DeviceHostAddress, firstDevice?.HostAddress, StringComparison.OrdinalIgnoreCase))
?.TagPath
?? opts.Tags.FirstOrDefault()?.TagPath
// Prefer the first authored tag path as the probe tag name (it must exist on the PLC). v3: the
// tags arrive as RawTagEntry blobs — map each through the pure factory to recover its TagPath +
// device routing key. Fall back to a benign system attribute present on all ControlLogix /
// CompactLogix controllers (@raw_cpu_type); still returns ErrorNotFound on Micro800 / MicroLogix,
// which is sufficient to confirm CIP reachability.
var mappedTags = opts.RawTags
.Select(e => AbCipTagDefinitionFactory.FromTagConfig(e.TagConfig, e.RawPath, out var d)
? (Ok: true, Device: e.DeviceName, d.TagPath)
: (Ok: false, Device: e.DeviceName, TagPath: null!))
.Where(x => x.Ok)
.ToList();
var tagName = mappedTags.FirstOrDefault(
x => string.Equals(x.Device, firstDevice?.HostAddress, StringComparison.OrdinalIgnoreCase)
|| (firstDevice?.DeviceName is { } dn && string.Equals(x.Device, dn, StringComparison.OrdinalIgnoreCase)))
.TagPath
?? mappedTags.FirstOrDefault().TagPath
?? "@raw_cpu_type";
var p = new AbCipTagCreateParams(
@@ -18,9 +18,10 @@ public static class AbCipUdtReadPlanner
{
/// <summary>
/// Split <paramref name="requests"/> into whole-UDT groups + per-tag leftovers.
/// <paramref name="tagsByName"/> is the driver's <c>_tagsByName</c> map — both parent
/// UDT rows and their fanned-out member rows live there. Lookup is OrdinalIgnoreCase
/// to match the driver's dictionary semantics. When
/// <paramref name="tagsByName"/> is the driver's <c>_tagsByRawPath</c> map (RawPath → def) —
/// both parent UDT rows and their fanned-out member rows live there. Tag lookup honours the
/// passed dictionary's comparer (v3: case-sensitive Ordinal RawPaths); the internal
/// parent-name grouping is OrdinalIgnoreCase. When
/// <paramref name="enableDeclarationOnlyGrouping"/> is <c>false</c> no groups are
/// formed — every reference goes to the per-tag fallback path so member decoding never
/// relies on declaration-order offsets that may not match the controller layout.