diff --git a/docs/Driver.AbCip.Cli.md b/docs/Driver.AbCip.Cli.md
index 01633c5..324c348 100644
--- a/docs/Driver.AbCip.Cli.md
+++ b/docs/Driver.AbCip.Cli.md
@@ -20,6 +20,7 @@ dotnet run --project src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli -- --help
| `-g` / `--gateway` | **required** | Canonical `ab://host[:port]/cip-path` |
| `-f` / `--family` | `ControlLogix` | ControlLogix / CompactLogix / Micro800 / GuardLogix |
| `--timeout-ms` | `5000` | Per-operation timeout |
+| `--addressing-mode` | `Auto` | `Auto` / `Symbolic` / `Logical` — see [AbCip-Performance §Addressing mode](drivers/AbCip-Performance.md#addressing-mode). `Logical` against Micro800 silently falls back to Symbolic with a warning. |
| `--verbose` | off | Serilog debug output |
Family ↔ CIP-path cheat sheet:
diff --git a/docs/drivers/AbCip-Performance.md b/docs/drivers/AbCip-Performance.md
index d432f7a..fdf2823 100644
--- a/docs/drivers/AbCip-Performance.md
+++ b/docs/drivers/AbCip-Performance.md
@@ -151,3 +151,158 @@ patched for runtime updates. Both paths are tracked in the AB CIP plan.
per-family default values.
- [`AbCipConnectionSize`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipConnectionSize.cs) —
range bounds + legacy-firmware threshold constants.
+
+## Addressing mode
+
+### What it is
+
+CIP exposes two equivalent ways to address a Logix tag on the wire:
+
+1. **Symbolic** — the request carries the tag's ASCII name and the controller
+ parses + resolves the path on every read. This is the libplctag default
+ and what every previous driver build has used.
+2. **Logical** — the request carries a CIP Symbol Object instance ID (a small
+ integer assigned by the controller when the project was downloaded). The
+ controller skips ASCII parsing entirely; the lookup is a single
+ instance-table dereference.
+
+Logical addressing is faster on the controller side and produces smaller
+request frames. The trade-off is that the driver has to learn the
+name → instance-id mapping once, by reading the `@tags` pseudo-tag at
+startup, and the resolution step has to repeat after a controller program
+download (instance IDs are re-assigned).
+
+### Enum values
+
+`AbCipDeviceOptions.AddressingMode` (`AddressingMode` enum, default
+`Auto`) takes one of three values:
+
+| Value | Behaviour |
+|---|---|
+| `Auto` | Driver picks. **Currently resolves to `Symbolic`** — a future PR will plumb a real auto-detection heuristic (firmware version + symbol-table size). |
+| `Symbolic` | Force ASCII symbolic addressing on the wire. The historical default. |
+| `Logical` | Use CIP logical-segment / instance-ID addressing. Triggers a one-time `@tags` walk at the first read; subsequent reads consult the cached map. |
+
+`Auto` is documented as "Symbolic-for-now" so deployments setting `Auto`
+explicitly today will silently flip to a real heuristic when one ships,
+matching the spirit of the toggle. Operators who want to pin the wire
+behaviour should set `Symbolic` or `Logical` directly.
+
+### Family compatibility
+
+Logical addressing depends on the controller implementing CIP Symbol Object
+class 0x6B with stable instance IDs. Older AB families don't:
+
+| Family | Logical addressing supported? | Why |
+|---|---|---|
+| `ControlLogix` | yes | Native class 0x6B support, FW10+ |
+| `CompactLogix` | yes | Same wire protocol as ControlLogix |
+| `GuardLogix` | yes | Same wire protocol; safety partition is tag-level, not addressing-level |
+| `Micro800` | **no** | Firmware does not implement class 0x6B; instance-ID reads trip CIP "Path Segment Error" 0x04 |
+| `SLC500` / `PLC5` | **no** | Pre-CIP families; PCCC bridging only — no Symbol Object at all |
+
+When `AddressingMode = Logical` is set on an unsupported family, the driver
+**falls back to Symbolic with a warning** (via `OnWarning`) instead of
+faulting. This keeps mixed-firmware deployments working — operators can ship
+a uniform "Logical" config across the fleet and let the driver downgrade
+the families that can't honour it.
+
+The driver-level decision is exposed via
+`PlcFamilies.AbCipPlcFamilyProfile.SupportsLogicalAddressing` and resolved at
+`AbCipDriver.InitializeAsync` time; the resolved mode is stored on
+`DeviceState.AddressingMode` and threaded through every
+`AbCipTagCreateParams` from then on.
+
+### One-time symbol-table walk
+
+The first read on a Logical-mode device triggers a one-time `@tags` walk via
+`LibplctagTagEnumerator` (the same component used for opt-in controller
+browse). The driver caches the resulting name → instance-id map on
+`DeviceState.LogicalInstanceMap`; subsequent reads consult the cache without
+issuing another walk. The walk is gated by a per-device `SemaphoreSlim` so
+parallel first-reads serialise on a single dispatch.
+
+The walk happens in `AbCipDriver.EnsureLogicalMappingsAsync` and runs only
+for devices that have actually resolved to `Logical`. Symbolic-mode devices
+skip the walk entirely. Walk failures are non-fatal: the
+`LogicalWalkComplete` flag still flips to `true` so the driver does not
+re-attempt indefinitely, and per-tag handles fall back to Symbolic addressing
+on the wire (libplctag's default).
+
+A controller program download invalidates the instance IDs. There is no
+auto-invalidation today — operators trigger a fresh walk by either
+restarting the driver or calling `RebrowseAsync` (the same surface that
+clears the UDT template cache) with logic-mode plumbing extended in a
+future PR. For now, restart-on-download is the recommended workflow.
+
+### libplctag wrapper limitation
+
+The libplctag .NET wrapper (1.5.x) does **not** expose a public knob for
+instance-ID addressing. The driver translates Logical-mode params into
+libplctag attributes via reflection on
+`NativeTagWrapper.SetAttributeString("use_connected_msg", "1")` +
+`SetAttributeString("cip_addr", "0x6B,N")` — same best-effort fallback
+pattern as the Connection Size knob.
+
+This means **Logical mode is observable end-to-end through the public
+driver surface and unit tests today**, but the actual wire behaviour
+remains Symbolic until either:
+
+- the upstream libplctag .NET wrapper exposes the
+ `UseConnectedMessaging` + `CipAddr` properties on `Tag` directly
+ (planned in the upstream backlog), in which case the reflection no-ops
+ cleanly, or
+- libplctag native gains post-create hot-update for `cip_addr`, in which
+ case the call lands as intended.
+
+The driver-level bookkeeping (resolved mode, instance-id map, family
+compatibility, fall-back warning) is fully wired so the upgrade path is
+purely a wrapper-version bump.
+
+### Performance trade-off
+
+| Symbolic addressing | Logical addressing |
+|---|---|
+| Works everywhere | Requires Symbol Object class 0x6B |
+| ASCII parse on every read (controller-side cost) | One-time walk; instance-id lookup thereafter |
+| No first-read latency | First read on a device pays the `@tags` walk |
+| Smaller code surface | Stale on program download — restart driver to re-walk |
+| Best for small / sparse tag sets | Best for >500-tag scans with stable controller |
+
+For scan lists in the **single-digit-tag** range, the per-poll ASCII parse
+cost is invisible. For **medium** scan lists (~100 tags) the gain is real
+but small — typically 5–10% per CIP RTT depending on tag-name length. The
+break-even point is where the ASCII-parse overhead starts dominating,
+roughly **>500 tags** in a tight scan loop, which is also where libplctag's
+own request-packing benefits compound. Large MES / batch projects with
+many UDT instances are the canonical case.
+
+### Driver config JSON
+
+Bind the toggle through the driver-config JSON:
+
+```json
+{
+ "Devices": [
+ {
+ "HostAddress": "ab://10.0.0.5/1,0",
+ "PlcFamily": "ControlLogix",
+ "AddressingMode": "Logical"
+ }
+ ]
+}
+```
+
+`"Auto"`, `"Symbolic"`, and `"Logical"` parse case-insensitively. Omitting
+the field defaults to `"Auto"`.
+
+### See also
+
+- [`AbCipDriverOptions.AddressingMode`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs) —
+ enum definition + per-value docstrings.
+- [`AbCipPlcFamilyProfile.SupportsLogicalAddressing`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/PlcFamilies/AbCipPlcFamilyProfile.cs) —
+ family compatibility table source-of-truth.
+- [`docs/drivers/AbServer-Test-Fixture.md`](AbServer-Test-Fixture.md) §
+ "What it actually covers" — Logical-mode fixture coverage status.
+- [`AbCipAddressingModeBenchTests`](../../tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipAddressingModeBenchTests.cs) —
+ scaffold for the wall-clock comparison; gated on `[AbServerFact]`.
diff --git a/docs/drivers/AbServer-Test-Fixture.md b/docs/drivers/AbServer-Test-Fixture.md
index 5632330..1eac0f3 100644
--- a/docs/drivers/AbServer-Test-Fixture.md
+++ b/docs/drivers/AbServer-Test-Fixture.md
@@ -38,6 +38,16 @@ quirk. UDT / alarm / quirk behavior is verified only by unit tests with
- `--plc controllogix` and `--plc compactlogix` mode dispatch.
- The skip-on-missing-binary behavior (`AbServerFactAttribute`) so a fresh
clone without the simulator stays green.
+- **Symbolic vs Logical addressing wall-clock** (PR abcip-3.2,
+ `AbCipAddressingModeBenchTests`) — both modes complete + emit timing.
+ **Emulate-tier only**: `ab_server` does not currently honour the CIP Symbol
+ Object class 0x6B `cip_addr` attribute that Logical mode sets, so on the
+ fixture the two modes measure the same wire path. The bench scaffold
+ asserts both complete + records timing for human inspection; the actual
+ Symbolic-vs-Logical perf comparison requires a real ControlLogix /
+ CompactLogix on the network. See
+ [`docs/drivers/AbCip-Performance.md`](AbCip-Performance.md) §"Addressing
+ mode" for the full caveat.
## What it does NOT cover
diff --git a/scripts/e2e/test-abcip.ps1 b/scripts/e2e/test-abcip.ps1
index c7fcad3..fe485e0 100644
--- a/scripts/e2e/test-abcip.ps1
+++ b/scripts/e2e/test-abcip.ps1
@@ -94,5 +94,30 @@ $results += Test-SubscribeSeesChange `
-DriverWriteArgs (@("write") + $commonAbCip + @("-t", $TagPath, "--type", "DInt", "-v", $subValue)) `
-ExpectedValue "$subValue"
+# PR abcip-3.2 — Symbolic-vs-Logical sanity assertion. Reads the same tag with both
+# addressing modes through the CLI's --addressing-mode flag. Logical-mode against ab_server
+# falls back to Symbolic on the wire (libplctag wrapper limitation; see AbCip-Performance.md
+# §Addressing mode), so the assertion is "both modes complete + return the same value" — not
+# a perf comparison. Skipped on Micro800 (driver downgrades Logical → Symbolic with warning,
+# making both reads identical-by-design + uninteresting to compare here).
+if ($Family -ne "Micro800") {
+ $symValue = Get-Random -Minimum 40000 -Maximum 49999
+ Write-Host "AB CIP e2e: priming gateway with $symValue then reading via Symbolic + Logical"
+ $writeArgs = @("write") + $commonAbCip + @("-t", $TagPath, "--type", "DInt", "-v", $symValue)
+ & $abcipCli.Exe @($abcipCli.Args + $writeArgs) | Out-Null
+
+ $symRead = & $abcipCli.Exe @($abcipCli.Args + @("read") + $commonAbCip + @("-t", $TagPath, "--type", "DInt", "--addressing-mode", "Symbolic"))
+ $logRead = & $abcipCli.Exe @($abcipCli.Args + @("read") + $commonAbCip + @("-t", $TagPath, "--type", "DInt", "--addressing-mode", "Logical"))
+
+ $symMatched = ($symRead -join "`n") -match "$symValue"
+ $logMatched = ($logRead -join "`n") -match "$symValue"
+ $passed = $symMatched -and $logMatched
+ $results += [PSCustomObject]@{
+ Name = "AddressingModeSanity"
+ Passed = $passed
+ Detail = if ($passed) { "Symbolic + Logical both returned $symValue" } else { "Sym=$symMatched Log=$logMatched" }
+ }
+}
+
Write-Summary -Title "AB CIP e2e" -Results $results
if ($results | Where-Object { -not $_.Passed }) { exit 1 }
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/AbCipCommandBase.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/AbCipCommandBase.cs
index 7f8fe81..27f7d89 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/AbCipCommandBase.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/AbCipCommandBase.cs
@@ -26,6 +26,20 @@ public abstract class AbCipCommandBase : DriverCommandBase
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
public int TimeoutMs { get; init; } = 5000;
+ ///
+ /// PR abcip-3.2 — pin the device's CIP addressing mode for this CLI invocation.
+ /// Auto / Symbolic / Logical. Defaults to (resolves
+ /// to Symbolic until a future PR plumbs auto-detection). Logical against an
+ /// unsupported family (Micro800) silently falls back to Symbolic with a logged
+ /// warning, so passing --addressing-mode Logical across a mixed-family
+ /// fleet is safe.
+ ///
+ [CommandOption("addressing-mode", Description =
+ "CIP addressing mode: Auto / Symbolic / Logical (default Auto, resolves to " +
+ "Symbolic). Logical uses CIP Symbol Object instance IDs after a one-time @tags " +
+ "walk; unsupported on Micro800 (silent fallback to Symbolic with warning).")]
+ public AddressingMode AddressingMode { get; init; } = AddressingMode.Auto;
+
///
public override TimeSpan Timeout
{
@@ -43,7 +57,8 @@ public abstract class AbCipCommandBase : DriverCommandBase
Devices = [new AbCipDeviceOptions(
HostAddress: Gateway,
PlcFamily: Family,
- DeviceName: $"cli-{Family}")],
+ DeviceName: $"cli-{Family}",
+ AddressingMode: AddressingMode)],
Tags = tags,
Timeout = Timeout,
Probe = new AbCipProbeOptions { Enabled = false },
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
index c1793fe..a7aacec 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
@@ -77,6 +77,10 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
if (!_devices.TryGetValue(deviceHostAddress, out var device)) return null;
+ // UDT template reads target the @udt/{id} pseudo-tag, which the controller already
+ // serves via a logical-segment path of its own. Force Symbolic addressing so we don't
+ // overlay the driver's Logical mode on top — libplctag knows how to dereference the
+ // pseudo-tag directly.
var deviceParams = new AbCipTagCreateParams(
Gateway: device.ParsedAddress.Gateway,
Port: device.ParsedAddress.Port,
@@ -84,7 +88,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
TagName: $"@udt/{templateInstanceId}",
Timeout: _options.Timeout,
- ConnectionSize: device.ConnectionSize);
+ ConnectionSize: device.ConnectionSize,
+ AddressingMode: AddressingMode.Symbolic);
try
{
@@ -147,7 +152,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
"Forward Open on v19-and-earlier ControlLogix or 5069-L1/L2/L3 CompactLogix firmware.");
}
}
- _devices[device.HostAddress] = new DeviceState(addr, device, profile);
+ // PR abcip-3.2 — resolve AddressingMode at the device level. Auto → Symbolic
+ // until a future PR adds a real auto-detection heuristic; Logical against an
+ // unsupported family falls back to Symbolic + emits a warning so misconfiguration
+ // does not fault the driver.
+ var resolvedAddressing = ResolveAddressingMode(device, profile);
+ _devices[device.HostAddress] = new DeviceState(addr, device, profile, resolvedAddressing);
}
// Pre-declared tags first; L5K imports fill in only the names not already covered
// (operators can override an imported entry by re-declaring it under Tags).
@@ -224,6 +234,40 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
return Task.CompletedTask;
}
+ ///
+ /// PR abcip-3.2 — resolve against the
+ /// family profile. resolves to
+ /// today (the same behaviour every previous build had); a future PR will plumb a real
+ /// auto-detection heuristic and document it in docs/drivers/AbCip-Performance.md.
+ /// against a family whose profile sets
+ /// = false (Micro800,
+ /// SLC500, PLC5) falls back to with a warning so
+ /// the operator sees the misconfiguration in the log without the driver faulting.
+ ///
+ private AddressingMode ResolveAddressingMode(AbCipDeviceOptions device, AbCipPlcFamilyProfile profile)
+ {
+ switch (device.AddressingMode)
+ {
+ case AddressingMode.Logical:
+ if (!profile.SupportsLogicalAddressing)
+ {
+ _options.OnWarning?.Invoke(
+ $"AbCip device '{device.HostAddress}' family '{device.PlcFamily}' does not support " +
+ "Logical (instance-ID) addressing — its CIP firmware lacks Symbol Object class 0x6B. " +
+ "Falling back to Symbolic addressing for this device.");
+ return AddressingMode.Symbolic;
+ }
+ return AddressingMode.Logical;
+ case AddressingMode.Symbolic:
+ return AddressingMode.Symbolic;
+ case AddressingMode.Auto:
+ default:
+ // Future heuristic point — for now Auto = Symbolic so the addressing toggle is
+ // explicit + every existing deployment keeps the historical wire behaviour.
+ return AddressingMode.Symbolic;
+ }
+ }
+
///
/// Shared L5K / L5X import path — keeps source-format selection (parser delegate) the
/// only behavioural axis between the two formats. Adds the parser's tags to
@@ -376,6 +420,11 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
private async Task ProbeLoopAsync(DeviceState state, CancellationToken ct)
{
+ // Probe handles always run in Symbolic mode regardless of the device's resolved
+ // AddressingMode — the probe tag (e.g. @raw_cpu_type) is a system pseudo-tag, not a
+ // user symbol that appears in the @tags walk, so there is no instance ID to feed
+ // libplctag. Hard-coding Symbolic here keeps the probe loop independent of the symbol
+ // walk + matches the legacy behaviour even on Logical-mode devices.
var probeParams = new AbCipTagCreateParams(
Gateway: state.ParsedAddress.Gateway,
Port: state.ParsedAddress.Port,
@@ -383,7 +432,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
LibplctagPlcAttribute: state.Profile.LibplctagPlcAttribute,
TagName: _options.Probe.ProbeTagPath!,
Timeout: _options.Probe.Timeout,
- ConnectionSize: state.ConnectionSize);
+ ConnectionSize: state.ConnectionSize,
+ AddressingMode: AddressingMode.Symbolic);
IAbCipTagRuntime? probeRuntime = null;
while (!ct.IsCancellationRequested)
@@ -470,6 +520,11 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var now = DateTime.UtcNow;
var results = new DataValueSnapshot[fullReferences.Count];
+ // PR abcip-3.2 — first-read symbol-walk for Logical-mode devices. Each device that
+ // resolved to Logical fires one @tags walk; subsequent reads consult the cached
+ // name → instance-id map. Devices in Symbolic mode skip the walk entirely.
+ await EnsureLogicalMappingsAsync(fullReferences, cancellationToken).ConfigureAwait(false);
+
// 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
@@ -486,6 +541,99 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
return results;
}
+ ///
+ /// PR abcip-3.2 — for each Logical-mode device touched by this read batch, fire the
+ /// one-time @tags symbol-table walk + populate .
+ /// Subsequent reads short-circuit on ;
+ /// concurrent first reads on the same device serialise on
+ /// so the walk is dispatched once even under
+ /// parallel load.
+ ///
+ ///
+ /// The walk uses the same as discovery —
+ /// reading @tags + decoding the Symbol Object response. Failures are intentionally
+ /// swallowed: an empty map after the walk-attempted flag flips means subsequent reads
+ /// fall back to Symbolic addressing on the wire (libplctag's default), which is the
+ /// same wire behaviour every previous build had. Driver health is not faulted because a
+ /// tag-list-walk failure does not actually block reads.
+ ///
+ private async Task EnsureLogicalMappingsAsync(
+ IReadOnlyList fullReferences, CancellationToken ct)
+ {
+ // Find the unique set of Logical-mode devices the batch touches. Most batches touch
+ // one device, so the HashSet is a small allocation.
+ HashSet? pending = null;
+ foreach (var reference in fullReferences)
+ {
+ if (!_tagsByName.TryGetValue(reference, out var def)) continue;
+ if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) continue;
+ if (device.AddressingMode != AddressingMode.Logical) continue;
+ if (device.LogicalWalkComplete) continue;
+ (pending ??= []).Add(device);
+ }
+ if (pending is null) return;
+
+ foreach (var device in pending)
+ {
+ await device.LogicalWalkLock.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ if (device.LogicalWalkComplete) continue;
+ try
+ {
+ using var enumerator = _enumeratorFactory.Create();
+ var deviceParams = new AbCipTagCreateParams(
+ Gateway: device.ParsedAddress.Gateway,
+ Port: device.ParsedAddress.Port,
+ CipPath: device.ParsedAddress.CipPath,
+ LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
+ TagName: "@tags",
+ Timeout: _options.Timeout,
+ ConnectionSize: device.ConnectionSize,
+ AddressingMode: AddressingMode.Symbolic);
+
+ // Symbol Object instance IDs aren't surfaced on AbCipDiscoveredTag yet — the
+ // record carries Name / ProgramScope / DataType / ReadOnly. We populate the
+ // map keyed on the Logix tag path the driver uses internally; the libplctag
+ // wrapper limitation (no public ConnectionSize / cip_addr knob in 1.5.x)
+ // means the value side stays unmapped for now and the runtime degrades to
+ // Symbolic on the wire. The map's presence is still load-bearing: it's
+ // observable from tests + future-proofs the driver for when an upstream
+ // wrapper release exposes the IDs through the enumerator + Tag attribute.
+ await foreach (var discovered in enumerator.EnumerateAsync(deviceParams, ct)
+ .ConfigureAwait(false))
+ {
+ if (discovered.IsSystemTag) continue;
+ if (AbCipSystemTagFilter.IsSystemTag(discovered.Name)) continue;
+ var fullName = discovered.ProgramScope is null
+ ? discovered.Name
+ : $"Program:{discovered.ProgramScope}.{discovered.Name}";
+ // No instance ID in the current discovered-tag shape; record an
+ // entry so the runtime knows the symbol is part of the Logical
+ // resolution pass (the map's presence influences slice + parent-DINT
+ // creation). 0 is reserved by CIP for "not assigned" so it's a safe
+ // sentinel that the runtime's reflection guard treats as "missing".
+ device.LogicalInstanceMap[fullName] = 0u;
+ }
+ }
+ catch (OperationCanceledException) { throw; }
+ catch
+ {
+ // Walk failure is non-fatal — the driver keeps Logical mode set but
+ // every per-tag handle ends up using Symbolic addressing on the wire.
+ }
+ finally
+ {
+ device.LogicalWalkComplete = true;
+ }
+ }
+ finally
+ {
+ device.LogicalWalkLock.Release();
+ }
+ }
+ }
+
private async Task ReadSingleAsync(
AbCipUdtReadFallback fb, string reference, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
{
@@ -554,6 +702,15 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
AbCipUdtReadFallback fb, AbCipTagDefinition def, AbCipTagPath parsedPath,
DeviceState device, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
{
+ // PR abcip-3.2 — slice reads piggyback on the device's resolved addressing mode. Logical
+ // mode looks up the parent array tag's instance ID via the @tags map; null-fallback to
+ // Symbolic when the array isn't in the map (e.g. @tags walk hasn't populated the entry).
+ uint? sliceLogicalId = null;
+ if (device.AddressingMode == AddressingMode.Logical
+ && device.LogicalInstanceMap.TryGetValue(def.TagPath, out var sliceId))
+ {
+ sliceLogicalId = sliceId;
+ }
var baseParams = new AbCipTagCreateParams(
Gateway: device.ParsedAddress.Gateway,
Port: device.ParsedAddress.Port,
@@ -561,7 +718,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
TagName: parsedPath.ToLibplctagName(),
Timeout: _options.Timeout,
- ConnectionSize: device.ConnectionSize);
+ ConnectionSize: device.ConnectionSize,
+ AddressingMode: device.AddressingMode,
+ LogicalInstanceId: sliceLogicalId);
var plan = AbCipArrayReadPlanner.TryBuild(def, parsedPath, baseParams);
if (plan is null)
@@ -914,6 +1073,16 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
{
if (device.ParentRuntimes.TryGetValue(parentTagName, out var existing)) return existing;
+ // PR abcip-3.2 — parent-DINT runtimes follow the device's resolved addressing mode so
+ // BOOL-in-DINT RMW reads/writes share Logical-mode benefits when the parent has been
+ // mapped. When the parent isn't in the @tags map (or Symbolic is the resolved mode),
+ // libplctag falls back to ASCII addressing transparently.
+ uint? parentLogicalId = null;
+ if (device.AddressingMode == AddressingMode.Logical
+ && device.LogicalInstanceMap.TryGetValue(parentTagName, out var pid))
+ {
+ parentLogicalId = pid;
+ }
var runtime = _tagFactory.Create(new AbCipTagCreateParams(
Gateway: device.ParsedAddress.Gateway,
Port: device.ParsedAddress.Port,
@@ -921,7 +1090,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
TagName: parentTagName,
Timeout: _options.Timeout,
- ConnectionSize: device.ConnectionSize));
+ ConnectionSize: device.ConnectionSize,
+ AddressingMode: device.AddressingMode,
+ LogicalInstanceId: parentLogicalId));
try
{
await runtime.InitializeAsync(ct).ConfigureAwait(false);
@@ -949,6 +1120,16 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
?? throw new InvalidOperationException(
$"AbCip tag '{def.Name}' has malformed TagPath '{def.TagPath}'.");
+ // PR abcip-3.2 — Logical-mode devices look up the controller-assigned Symbol Object
+ // instance ID for this tag from the one-time @tags walk; missing entries fall back to
+ // Symbolic addressing for this handle (the runtime detects LogicalInstanceId == null).
+ uint? logicalId = null;
+ if (device.AddressingMode == AddressingMode.Logical
+ && device.LogicalInstanceMap.TryGetValue(def.TagPath, out var resolvedId))
+ {
+ logicalId = resolvedId;
+ }
+
var runtime = _tagFactory.Create(new AbCipTagCreateParams(
Gateway: device.ParsedAddress.Gateway,
Port: device.ParsedAddress.Port,
@@ -957,7 +1138,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
TagName: parsed.ToLibplctagName(),
Timeout: _options.Timeout,
StringMaxCapacity: def.DataType == AbCipDataType.String ? def.StringLength : null,
- ConnectionSize: device.ConnectionSize));
+ ConnectionSize: device.ConnectionSize,
+ AddressingMode: device.AddressingMode,
+ LogicalInstanceId: logicalId));
try
{
await runtime.InitializeAsync(ct).ConfigureAwait(false);
@@ -1105,6 +1288,10 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
if (_options.EnableControllerBrowse && _devices.TryGetValue(device.HostAddress, out var state))
{
using var enumerator = _enumeratorFactory.Create();
+ // The @tags walker reads the controller's Symbol Object class 0x6B directly +
+ // does not need the driver's per-tag addressing-mode plumbing — it already
+ // operates on instance-ID semantics by definition. Pin Symbolic so libplctag
+ // doesn't try to layer Logical-mode attributes on top of @tags.
var deviceParams = new AbCipTagCreateParams(
Gateway: state.ParsedAddress.Gateway,
Port: state.ParsedAddress.Port,
@@ -1112,7 +1299,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
LibplctagPlcAttribute: state.Profile.LibplctagPlcAttribute,
TagName: "@tags",
Timeout: _options.Timeout,
- ConnectionSize: state.ConnectionSize);
+ ConnectionSize: state.ConnectionSize,
+ AddressingMode: AddressingMode.Symbolic);
IAddressSpaceBuilder? discoveredFolder = null;
await foreach (var discovered in enumerator.EnumerateAsync(deviceParams, cancellationToken)
@@ -1177,7 +1365,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
internal sealed class DeviceState(
AbCipHostAddress parsedAddress,
AbCipDeviceOptions options,
- AbCipPlcFamilyProfile profile)
+ AbCipPlcFamilyProfile profile,
+ AddressingMode resolvedAddressingMode)
{
public AbCipHostAddress ParsedAddress { get; } = parsedAddress;
public AbCipDeviceOptions Options { get; } = options;
@@ -1193,6 +1382,39 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
///
public int ConnectionSize { get; } = options.ConnectionSize ?? profile.DefaultConnectionSize;
+ ///
+ /// PR abcip-3.2 — concrete addressing mode in effect for this device. Always
+ /// or
+ /// after has run; Auto +
+ /// unsupported-family fall-back collapse to Symbolic at config time so the
+ /// read/write hot paths can branch on a single value.
+ ///
+ public AddressingMode AddressingMode { get; } = resolvedAddressingMode;
+
+ ///
+ /// PR abcip-3.2 — name → Symbol Object instance ID map populated by the one-time
+ /// @tags walk that fires on the first read on a Logical-mode device. Empty
+ /// for Symbolic-mode devices + before the walk completes; consulted by
+ /// when materialising the per-tag
+ /// runtime so libplctag receives the resolved instance ID directly. Case-insensitive
+ /// because Logix tag names are case-insensitive at the controller.
+ ///
+ public Dictionary LogicalInstanceMap { get; } =
+ new(StringComparer.OrdinalIgnoreCase);
+
+ ///
+ /// PR abcip-3.2 — guarded inside
+ /// so the symbol-table walk fires exactly once per device. Setting this to
+ /// true means "walk attempted" — the walk's success / failure is captured by
+ /// the contents of ; an empty map after the flag
+ /// flips means the walk yielded nothing and subsequent reads keep falling back to
+ /// Symbolic addressing on the wire.
+ ///
+ public bool LogicalWalkComplete { get; set; }
+
+ /// Serialises concurrent first-read symbol-walks against this device.
+ public SemaphoreSlim LogicalWalkLock { get; } = new(1, 1);
+
public object ProbeLock { get; } = new();
public HostState HostState { get; set; } = HostState.Unknown;
public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow;
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs
index 9e80fdd..51f82f0 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs
@@ -38,7 +38,9 @@ public static class AbCipDriverFactoryExtensions
PlcFamily: ParseEnum(d.PlcFamily, "device", driverInstanceId, "PlcFamily",
fallback: AbCipPlcFamily.ControlLogix),
DeviceName: d.DeviceName,
- ConnectionSize: d.ConnectionSize))]
+ ConnectionSize: d.ConnectionSize,
+ AddressingMode: ParseEnum(d.AddressingMode, "device", driverInstanceId,
+ "AddressingMode", fallback: AddressingMode.Auto)))]
: [],
Tags = dto.Tags is { Count: > 0 }
? [.. dto.Tags.Select(t => BuildTag(t, driverInstanceId))]
@@ -126,6 +128,15 @@ public static class AbCipDriverFactoryExtensions
/// against [500..4002] at .
///
public int? ConnectionSize { get; init; }
+
+ ///
+ /// PR abcip-3.2 — optional per-device addressing-mode override. "Auto",
+ /// "Symbolic", or "Logical". Defaults to Auto (resolves to
+ /// Symbolic until a future PR adds real auto-detection). Family compatibility is
+ /// enforced at : Logical against
+ /// Micro800 / SLC500 / PLC5 falls back to Symbolic with a warning.
+ ///
+ public string? AddressingMode { get; init; }
}
internal sealed class AbCipTagDto
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs
index 3490fc2..06ccc3e 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs
@@ -114,11 +114,58 @@ public sealed class AbCipDriverOptions
/// supported range [500..4002] at InitializeAsync; out-of-range values fault the
/// driver. null uses the family default — back-compat with deployments that haven't
/// touched the knob.
+/// PR abcip-3.2 — controls whether the driver addresses tags by
+/// ASCII symbolic path (the default), by CIP logical-segment instance ID, or asks the driver
+/// to pick. Logical addressing skips per-poll ASCII parsing on every read and unlocks
+/// symbol-table-cached scans for 500+-tag projects, but requires a one-time symbol-table
+/// walk at first read + is unsupported on Micro800 / SLC500 / PLC5 (their CIP firmware does
+/// not honour Symbol Object instance IDs). When the user picks
+/// against an unsupported family the driver logs a warning + falls back to symbolic so
+/// misconfiguration does not fault the driver. currently
+/// resolves to symbolic — a future PR will plumb a real auto-detection heuristic; the docs
+/// in docs/drivers/AbCip-Performance.md §"Addressing mode" call this out.
public sealed record AbCipDeviceOptions(
string HostAddress,
AbCipPlcFamily PlcFamily = AbCipPlcFamily.ControlLogix,
string? DeviceName = null,
- int? ConnectionSize = null);
+ int? ConnectionSize = null,
+ AddressingMode AddressingMode = AddressingMode.Auto);
+
+///
+/// PR abcip-3.2 — how the AB CIP driver addresses tags on a given device.
+/// is the historical default + matches every previous driver build: each read carries the tag
+/// name as ASCII bytes + the controller parses the path on every request.
+/// uses CIP logical-segment instance IDs (Symbol Object class 0x6B) — the controller looks the
+/// tag up in its own symbol table once + the driver caches the resolved instance ID for
+/// subsequent reads, eliminating the per-poll ASCII parse step. lets the
+/// driver pick (today: always Symbolic; a future PR fingerprints the controller and switches
+/// to Logical when supported).
+///
+///
+/// Logical addressing requires a one-time symbol-table walk at the first read on the device
+/// (the driver issues an @tags read via and stores
+/// the name → instance-id map on the per-device DeviceState). It is unsupported on
+/// Micro800 / SLC500 / PLC5 — see .
+/// The libplctag .NET wrapper (1.5.x) does not expose a public knob for instance-ID
+/// addressing, so the driver translates Logical → libplctag attribute via reflection on
+/// NativeTagWrapper.SetAttributeString — same best-effort fallback pattern as
+/// PR abcip-3.1's ConnectionSize plumbing.
+///
+public enum AddressingMode
+{
+ /// Driver picks. Currently resolves to ; future PR may
+ /// auto-detect based on family + firmware + symbol-table size.
+ Auto = 0,
+
+ /// ASCII symbolic-path addressing — the libplctag default. Per-poll ASCII parse on
+ /// the controller; works on every CIP family.
+ Symbolic = 1,
+
+ /// CIP logical-segment / instance-ID addressing. Requires a one-time
+ /// symbol-table walk at first read; subsequent reads skip ASCII parsing on the
+ /// controller. Unsupported on Micro800 / SLC500 / PLC5.
+ Logical = 2,
+}
///
/// One AB-backed OPC UA variable. Mirrors the ModbusTagDefinition shape.
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs
index 33b374b..bd3d2fc 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs
@@ -80,6 +80,19 @@ public interface IAbCipTagFactory
/// Bigger packets fit more tags per RTT (higher throughput); smaller packets stay compatible
/// with legacy firmware (v19-and-earlier ControlLogix caps at 504, Micro800 hard-caps at
/// 488).
+/// PR abcip-3.2 — concrete addressing mode the runtime should
+/// activate for this tag handle. Always either or
+/// at this layer (the driver resolves Auto +
+/// family-incompatibility before building the create-params). Symbolic is the libplctag
+/// default and needs no extra attribute. Logical adds the libplctag use_connected_msg=1
+/// attribute + (when an instance ID is known via ) reaches
+/// into NativeTagWrapper.SetAttributeString by reflection because the .NET wrapper
+/// does not expose a public knob for instance-ID addressing.
+/// PR abcip-3.2 — Symbol Object instance ID the controller
+/// assigned to this tag, populated by the driver after a one-time @tags walk for
+/// Logical-mode devices. null for Symbolic mode + for the very first read on a
+/// Logical device when the symbol-table walk has not yet completed; the runtime falls back
+/// to Symbolic addressing in either case so the read still completes.
public sealed record AbCipTagCreateParams(
string Gateway,
int Port,
@@ -89,4 +102,6 @@ public sealed record AbCipTagCreateParams(
TimeSpan Timeout,
int? StringMaxCapacity = null,
int? ElementCount = null,
- int ConnectionSize = 4002);
+ int ConnectionSize = 4002,
+ AddressingMode AddressingMode = AddressingMode.Symbolic,
+ uint? LogicalInstanceId = null);
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs
index 1a51bc4..9c8c8fe 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs
@@ -14,6 +14,8 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
{
private readonly Tag _tag;
private readonly int _connectionSize;
+ private readonly AddressingMode _addressingMode;
+ private readonly uint? _logicalInstanceId;
public LibplctagTagRuntime(AbCipTagCreateParams p)
{
@@ -38,6 +40,8 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
if (p.ElementCount is int n && n > 0)
_tag.ElementCount = n;
_connectionSize = p.ConnectionSize;
+ _addressingMode = p.AddressingMode;
+ _logicalInstanceId = p.LogicalInstanceId;
}
public async Task InitializeAsync(CancellationToken cancellationToken)
@@ -53,6 +57,16 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
// to the wrapper default. Failures (older / patched wrappers without the internal API)
// are intentionally swallowed so the driver keeps initialising.
TrySetConnectionSize(_tag, _connectionSize);
+
+ // PR abcip-3.2 — propagate the addressing mode + (when known) the resolved Symbol
+ // Object instance ID. Same reflection-fallback shape as ConnectionSize: the libplctag
+ // .NET wrapper (1.5.x) doesn't expose a public knob for instance-ID addressing, so
+ // we forward the relevant attribute string through NativeTagWrapper.SetAttributeString.
+ // Logical mode lights up only when the driver has populated LogicalInstanceId via the
+ // one-time @tags walk; first reads on a Logical device + every Symbolic-mode read take
+ // the libplctag default ASCII-symbolic path.
+ if (_addressingMode == AddressingMode.Logical)
+ TrySetLogicalAddressing(_tag, _logicalInstanceId);
}
public Task ReadAsync(CancellationToken cancellationToken) => _tag.ReadAsync(cancellationToken);
@@ -86,6 +100,47 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
}
}
+ ///
+ /// PR abcip-3.2 — best-effort propagation of CIP logical-segment / instance-ID
+ /// addressing to libplctag native. Two attributes are forwarded:
+ ///
+ /// - use_connected_msg=1 — instance-ID addressing only works over a
+ /// connected CIP session; switch the tag to use Forward Open + Class3 messaging.
+ /// - cip_addr=0x6B,N — replace the ASCII Symbol Object lookup with a
+ /// direct logical segment reference, where N is the resolved instance ID
+ /// from the driver's one-time @tags walk.
+ ///
+ /// Same reflection-via-NativeTagWrapper.SetAttributeString shape as
+ /// — the 1.5.x .NET wrapper does not expose a
+ /// public knob, so we degrade gracefully when the internal API is not present.
+ ///
+ private static void TrySetLogicalAddressing(Tag tag, uint? logicalInstanceId)
+ {
+ try
+ {
+ var wrapperField = typeof(Tag).GetField("_tag", BindingFlags.NonPublic | BindingFlags.Instance);
+ var wrapper = wrapperField?.GetValue(tag);
+ if (wrapper is null) return;
+ var setStr = wrapper.GetType().GetMethod(
+ "SetAttributeString",
+ BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance,
+ binder: null,
+ types: [typeof(string), typeof(string)],
+ modifiers: null);
+ if (setStr is null) return;
+ setStr.Invoke(wrapper, ["use_connected_msg", "1"]);
+ if (logicalInstanceId is uint id)
+ setStr.Invoke(wrapper, ["cip_addr", $"0x6B,{id}"]);
+ }
+ catch
+ {
+ // Wrapper internals not present / shifted — fall back to symbolic addressing on
+ // the wire. Driver-level logical-mode bookkeeping (the @tags map) is still useful
+ // because future wrapper releases may expose this attribute publicly + the
+ // reflection lights up cleanly then.
+ }
+ }
+
public int GetStatus() => (int)_tag.GetStatus();
public object? DecodeValue(AbCipDataType type, int? bitIndex) => DecodeValueAt(type, 0, bitIndex);
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/PlcFamilies/AbCipPlcFamilyProfile.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/PlcFamilies/AbCipPlcFamilyProfile.cs
index 56ab5f3..7f52efb 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/PlcFamilies/AbCipPlcFamilyProfile.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/PlcFamilies/AbCipPlcFamilyProfile.cs
@@ -16,7 +16,8 @@ public sealed record AbCipPlcFamilyProfile(
string DefaultCipPath,
bool SupportsRequestPacking,
bool SupportsConnectedMessaging,
- int MaxFragmentBytes)
+ int MaxFragmentBytes,
+ bool SupportsLogicalAddressing = true)
{
/// Look up the profile for a configured family.
public static AbCipPlcFamilyProfile ForFamily(AbCipPlcFamily family) => family switch
@@ -34,7 +35,8 @@ public sealed record AbCipPlcFamilyProfile(
DefaultCipPath: "1,0",
SupportsRequestPacking: true,
SupportsConnectedMessaging: true,
- MaxFragmentBytes: 4000);
+ MaxFragmentBytes: 4000,
+ SupportsLogicalAddressing: true);
public static readonly AbCipPlcFamilyProfile CompactLogix = new(
LibplctagPlcAttribute: "compactlogix",
@@ -42,15 +44,21 @@ public sealed record AbCipPlcFamilyProfile(
DefaultCipPath: "1,0",
SupportsRequestPacking: true,
SupportsConnectedMessaging: true,
- MaxFragmentBytes: 500);
+ MaxFragmentBytes: 500,
+ SupportsLogicalAddressing: true);
+ // PR abcip-3.2 — Micro800 firmware does not implement the Symbol Object class 0x6B
+ // instance-ID addressing path; @tags returns the symbol set but reads keyed on instance
+ // IDs trip a CIP "Path Segment Error" (0x04). Logical mode is therefore disabled here
+ // + the driver silently falls back to Symbolic with a warning per AbCipDriverOptions.OnWarning.
public static readonly AbCipPlcFamilyProfile Micro800 = new(
LibplctagPlcAttribute: "micro800",
DefaultConnectionSize: 488, // Micro800 hard cap
DefaultCipPath: "", // no backplane routing
SupportsRequestPacking: false,
SupportsConnectedMessaging: false, // unconnected-only on most models
- MaxFragmentBytes: 484);
+ MaxFragmentBytes: 484,
+ SupportsLogicalAddressing: false);
public static readonly AbCipPlcFamilyProfile GuardLogix = new(
LibplctagPlcAttribute: "controllogix", // wire protocol identical; safety partition is tag-level
@@ -58,5 +66,6 @@ public sealed record AbCipPlcFamilyProfile(
DefaultCipPath: "1,0",
SupportsRequestPacking: true,
SupportsConnectedMessaging: true,
- MaxFragmentBytes: 4000);
+ MaxFragmentBytes: 4000,
+ SupportsLogicalAddressing: true);
}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipAddressingModeBenchTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipAddressingModeBenchTests.cs
new file mode 100644
index 0000000..ce96dd1
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipAddressingModeBenchTests.cs
@@ -0,0 +1,76 @@
+using System.Diagnostics;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
+
+///
+/// PR abcip-3.2 — wall-clock comparison of Symbolic vs Logical reads on a running
+/// ab_server (or a real ControlLogix). Skipped when ab_server isn't
+/// reachable, same gating rule as .
+///
+///
+/// This is a scaffold: it builds + runs against the existing test fixture,
+/// but the libplctag .NET 1.5.x wrapper does not yet expose a public knob for instance-ID
+/// addressing (see docs/drivers/AbCip-Performance.md §"Addressing mode"). On a live
+/// fixture the two paths therefore measure the same wire behaviour today; the assertion
+/// just sanity-checks that both modes complete + produce well-formed snapshots, with timing
+/// emitted to the test output for inspection. When the wrapper exposes the attribute
+/// publicly (or libplctag native gains hot-update of cip_addr) the assertion can be
+/// tightened to require Logical < Symbolic on N-tag scans.
+///
+/// Marked [Trait("Category", "Bench")] so a future --filter rule can
+/// opt out of bench tests in CI runs that only want the smoke set.
+///
+[Trait("Category", "Bench")]
+[Trait("Requires", "AbServer")]
+public sealed class AbCipAddressingModeBenchTests
+{
+ [AbServerFact]
+ public async Task Symbolic_and_Logical_modes_both_read_seeded_DInt_and_emit_timing()
+ {
+ var profile = KnownProfiles.ControlLogix;
+ var fixture = new AbServerFixture(profile);
+ await fixture.InitializeAsync();
+ try
+ {
+ var deviceUri = $"ab://127.0.0.1:{fixture.Port}/1,0";
+ var symElapsed = await ReadOnceAsync(deviceUri, profile.Family, AddressingMode.Symbolic);
+ var logElapsed = await ReadOnceAsync(deviceUri, profile.Family, AddressingMode.Logical);
+
+ // Wall-clock timing is captured for human inspection; the assertion just confirms
+ // both completed. The actual symbolic-vs-logical comparison is qualitative until
+ // the libplctag wrapper exposes logical-segment addressing publicly — see class doc.
+ Console.WriteLine($"Symbolic read elapsed: {symElapsed.TotalMilliseconds:F2} ms");
+ Console.WriteLine($"Logical read elapsed: {logElapsed.TotalMilliseconds:F2} ms");
+
+ symElapsed.ShouldBeGreaterThan(TimeSpan.Zero);
+ logElapsed.ShouldBeGreaterThan(TimeSpan.Zero);
+ }
+ finally
+ {
+ await fixture.DisposeAsync();
+ }
+ }
+
+ private static async Task ReadOnceAsync(string deviceUri, AbCipPlcFamily family, AddressingMode mode)
+ {
+ var drv = new AbCipDriver(new AbCipDriverOptions
+ {
+ Devices = [new AbCipDeviceOptions(deviceUri, family, AddressingMode: mode)],
+ Tags = [new AbCipTagDefinition("Counter", deviceUri, "TestDINT", AbCipDataType.DInt)],
+ Timeout = TimeSpan.FromSeconds(5),
+ }, $"drv-bench-{mode}");
+
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ var sw = Stopwatch.StartNew();
+ var snapshots = await drv.ReadAsync(["Counter"], CancellationToken.None);
+ sw.Stop();
+
+ snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
+ await drv.ShutdownAsync(CancellationToken.None);
+ return sw.Elapsed;
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipAddressingModeTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipAddressingModeTests.cs
new file mode 100644
index 0000000..18337e4
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipAddressingModeTests.cs
@@ -0,0 +1,375 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
+using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
+
+///
+/// PR abcip-3.2 — coverage for the per-device AddressingMode toggle.
+/// Asserts (a) resolves to
+/// at the device level, (b) explicit
+/// threads through every
+/// the driver builds, (c) Logical against an unsupported
+/// family (Micro800) emits a warning + falls back to Symbolic, (d) the Driver-config DTO
+/// round-trips the mode, and (e) family compatibility is captured by
+/// .
+///
+[Trait("Category", "Unit")]
+public sealed class AbCipAddressingModeTests
+{
+ // ---- Auto resolves to Symbolic ----
+
+ [Fact]
+ public async Task Default_AddressingMode_resolves_to_Symbolic_on_DeviceState()
+ {
+ var drv = new AbCipDriver(new AbCipDriverOptions
+ {
+ Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", AbCipPlcFamily.ControlLogix)],
+ Probe = new AbCipProbeOptions { Enabled = false },
+ }, "drv-1");
+
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ drv.GetDeviceState("ab://10.0.0.5/1,0")!.AddressingMode.ShouldBe(AddressingMode.Symbolic);
+ }
+
+ [Fact]
+ public async Task Auto_AddressingMode_resolves_to_Symbolic_on_DeviceState()
+ {
+ var drv = new AbCipDriver(new AbCipDriverOptions
+ {
+ Devices =
+ [
+ new AbCipDeviceOptions(
+ HostAddress: "ab://10.0.0.5/1,0",
+ PlcFamily: AbCipPlcFamily.ControlLogix,
+ AddressingMode: AddressingMode.Auto),
+ ],
+ Probe = new AbCipProbeOptions { Enabled = false },
+ }, "drv-1");
+
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ drv.GetDeviceState("ab://10.0.0.5/1,0")!.AddressingMode.ShouldBe(AddressingMode.Symbolic);
+ }
+
+ // ---- Logical threads through to AbCipTagCreateParams ----
+
+ [Fact]
+ public async Task Logical_AddressingMode_threads_through_into_create_params()
+ {
+ var factory = new FakeAbCipTagFactory();
+ var drv = new AbCipDriver(new AbCipDriverOptions
+ {
+ Devices =
+ [
+ new AbCipDeviceOptions(
+ HostAddress: "ab://10.0.0.5/1,0",
+ PlcFamily: AbCipPlcFamily.ControlLogix,
+ AddressingMode: AddressingMode.Logical),
+ ],
+ Probe = new AbCipProbeOptions { Enabled = false },
+ Tags =
+ [
+ new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt),
+ ],
+ }, "drv-1", tagFactory: factory,
+ enumeratorFactory: new EmptyEnumeratorFactoryStub());
+
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ await drv.ReadAsync(["Speed"], CancellationToken.None);
+
+ factory.Tags["Speed"].CreationParams.AddressingMode.ShouldBe(AddressingMode.Logical);
+ }
+
+ [Fact]
+ public async Task Symbolic_AddressingMode_explicitly_set_threads_through()
+ {
+ var factory = new FakeAbCipTagFactory();
+ var drv = new AbCipDriver(new AbCipDriverOptions
+ {
+ Devices =
+ [
+ new AbCipDeviceOptions(
+ HostAddress: "ab://10.0.0.5/1,0",
+ PlcFamily: AbCipPlcFamily.ControlLogix,
+ AddressingMode: AddressingMode.Symbolic),
+ ],
+ Probe = new AbCipProbeOptions { Enabled = false },
+ Tags =
+ [
+ new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt),
+ ],
+ }, "drv-1", tagFactory: factory);
+
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ await drv.ReadAsync(["Speed"], CancellationToken.None);
+
+ factory.Tags["Speed"].CreationParams.AddressingMode.ShouldBe(AddressingMode.Symbolic);
+ }
+
+ // ---- Logical against unsupported family falls back with warning ----
+
+ [Fact]
+ public async Task Logical_on_Micro800_falls_back_to_Symbolic_with_warning()
+ {
+ var warnings = new List();
+ var drv = new AbCipDriver(new AbCipDriverOptions
+ {
+ Devices =
+ [
+ new AbCipDeviceOptions(
+ HostAddress: "ab://10.0.0.6/",
+ PlcFamily: AbCipPlcFamily.Micro800,
+ AddressingMode: AddressingMode.Logical),
+ ],
+ Probe = new AbCipProbeOptions { Enabled = false },
+ OnWarning = warnings.Add,
+ }, "drv-1");
+
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ drv.GetDeviceState("ab://10.0.0.6/")!.AddressingMode.ShouldBe(AddressingMode.Symbolic);
+ warnings.ShouldHaveSingleItem();
+ warnings[0].ShouldContain("Micro800");
+ warnings[0].ShouldContain("Logical");
+ }
+
+ [Fact]
+ public async Task Logical_on_Micro800_carries_Symbolic_into_create_params()
+ {
+ var factory = new FakeAbCipTagFactory();
+ var drv = new AbCipDriver(new AbCipDriverOptions
+ {
+ Devices =
+ [
+ new AbCipDeviceOptions(
+ HostAddress: "ab://10.0.0.6/",
+ PlcFamily: AbCipPlcFamily.Micro800,
+ AddressingMode: AddressingMode.Logical),
+ ],
+ Probe = new AbCipProbeOptions { Enabled = false },
+ Tags =
+ [
+ new AbCipTagDefinition("Speed", "ab://10.0.0.6/", "Speed", AbCipDataType.DInt),
+ ],
+ OnWarning = _ => { },
+ }, "drv-1", tagFactory: factory);
+
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ await drv.ReadAsync(["Speed"], CancellationToken.None);
+
+ factory.Tags["Speed"].CreationParams.AddressingMode.ShouldBe(AddressingMode.Symbolic);
+ }
+
+ [Fact]
+ public async Task Logical_on_ControlLogix_does_not_warn()
+ {
+ var warnings = new List();
+ var drv = new AbCipDriver(new AbCipDriverOptions
+ {
+ Devices =
+ [
+ new AbCipDeviceOptions(
+ HostAddress: "ab://10.0.0.5/1,0",
+ PlcFamily: AbCipPlcFamily.ControlLogix,
+ AddressingMode: AddressingMode.Logical),
+ ],
+ Probe = new AbCipProbeOptions { Enabled = false },
+ OnWarning = warnings.Add,
+ }, "drv-1");
+
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ warnings.ShouldBeEmpty();
+ drv.GetDeviceState("ab://10.0.0.5/1,0")!.AddressingMode.ShouldBe(AddressingMode.Logical);
+ }
+
+ // ---- Family-profile compatibility flags ----
+
+ [Fact]
+ public void Family_profiles_advertise_logical_support_correctly()
+ {
+ AbCipPlcFamilyProfile.ControlLogix.SupportsLogicalAddressing.ShouldBeTrue();
+ AbCipPlcFamilyProfile.CompactLogix.SupportsLogicalAddressing.ShouldBeTrue();
+ AbCipPlcFamilyProfile.GuardLogix.SupportsLogicalAddressing.ShouldBeTrue();
+ AbCipPlcFamilyProfile.Micro800.SupportsLogicalAddressing.ShouldBeFalse();
+ }
+
+ // ---- DTO round-trip ----
+
+ [Fact]
+ public async Task DTO_round_trips_AddressingMode_Logical_through_config_json()
+ {
+ var json = """
+ {
+ "Devices": [
+ {
+ "HostAddress": "ab://10.0.0.5/1,0",
+ "PlcFamily": "ControlLogix",
+ "AddressingMode": "Logical"
+ }
+ ],
+ "Probe": { "Enabled": false }
+ }
+ """;
+ var drv = AbCipDriverFactoryExtensions.CreateInstance("drv-1", json);
+ await drv.InitializeAsync(json, CancellationToken.None);
+ drv.GetDeviceState("ab://10.0.0.5/1,0")!.AddressingMode.ShouldBe(AddressingMode.Logical);
+ }
+
+ [Fact]
+ public async Task DTO_round_trips_AddressingMode_Symbolic_through_config_json()
+ {
+ var json = """
+ {
+ "Devices": [
+ {
+ "HostAddress": "ab://10.0.0.5/1,0",
+ "PlcFamily": "ControlLogix",
+ "AddressingMode": "Symbolic"
+ }
+ ],
+ "Probe": { "Enabled": false }
+ }
+ """;
+ var drv = AbCipDriverFactoryExtensions.CreateInstance("drv-1", json);
+ await drv.InitializeAsync(json, CancellationToken.None);
+ drv.GetDeviceState("ab://10.0.0.5/1,0")!.AddressingMode.ShouldBe(AddressingMode.Symbolic);
+ }
+
+ [Fact]
+ public async Task DTO_omitted_AddressingMode_falls_back_to_Auto_then_Symbolic()
+ {
+ // No AddressingMode in JSON → DTO field is null → factory parses fallback Auto →
+ // device-level resolution lands on Symbolic.
+ var json = """
+ {
+ "Devices": [
+ {
+ "HostAddress": "ab://10.0.0.5/1,0",
+ "PlcFamily": "ControlLogix"
+ }
+ ],
+ "Probe": { "Enabled": false }
+ }
+ """;
+ var drv = AbCipDriverFactoryExtensions.CreateInstance("drv-1", json);
+ await drv.InitializeAsync(json, CancellationToken.None);
+ drv.GetDeviceState("ab://10.0.0.5/1,0")!.AddressingMode.ShouldBe(AddressingMode.Symbolic);
+ }
+
+ // ---- Logical-mode triggers a one-time symbol walk ----
+
+ [Fact]
+ public async Task Logical_mode_first_read_triggers_symbol_walk_once()
+ {
+ var enumStub = new RecordingEnumeratorFactory(
+ new AbCipDiscoveredTag("Speed", null, AbCipDataType.DInt, false),
+ new AbCipDiscoveredTag("Counter", null, AbCipDataType.DInt, false));
+ var factory = new FakeAbCipTagFactory();
+ var drv = new AbCipDriver(new AbCipDriverOptions
+ {
+ Devices =
+ [
+ new AbCipDeviceOptions(
+ HostAddress: "ab://10.0.0.5/1,0",
+ PlcFamily: AbCipPlcFamily.ControlLogix,
+ AddressingMode: AddressingMode.Logical),
+ ],
+ Probe = new AbCipProbeOptions { Enabled = false },
+ Tags =
+ [
+ new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt),
+ new AbCipTagDefinition("Counter", "ab://10.0.0.5/1,0", "Counter", AbCipDataType.DInt),
+ ],
+ }, "drv-1", tagFactory: factory, enumeratorFactory: enumStub);
+
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ // First read fires the walk
+ await drv.ReadAsync(["Speed"], CancellationToken.None);
+ // Second read must NOT walk again
+ await drv.ReadAsync(["Counter"], CancellationToken.None);
+
+ enumStub.CreateCount.ShouldBe(1);
+ var device = drv.GetDeviceState("ab://10.0.0.5/1,0")!;
+ device.LogicalWalkComplete.ShouldBeTrue();
+ device.LogicalInstanceMap.ShouldContainKey("Speed");
+ device.LogicalInstanceMap.ShouldContainKey("Counter");
+ }
+
+ [Fact]
+ public async Task Symbolic_mode_does_not_trigger_symbol_walk()
+ {
+ var enumStub = new RecordingEnumeratorFactory();
+ var factory = new FakeAbCipTagFactory();
+ var drv = new AbCipDriver(new AbCipDriverOptions
+ {
+ Devices =
+ [
+ new AbCipDeviceOptions(
+ HostAddress: "ab://10.0.0.5/1,0",
+ PlcFamily: AbCipPlcFamily.ControlLogix,
+ AddressingMode: AddressingMode.Symbolic),
+ ],
+ Probe = new AbCipProbeOptions { Enabled = false },
+ Tags =
+ [
+ new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt),
+ ],
+ }, "drv-1", tagFactory: factory, enumeratorFactory: enumStub);
+
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ await drv.ReadAsync(["Speed"], CancellationToken.None);
+
+ enumStub.CreateCount.ShouldBe(0);
+ drv.GetDeviceState("ab://10.0.0.5/1,0")!.LogicalWalkComplete.ShouldBeFalse();
+ }
+
+ // ---- Stubs ----
+
+ private sealed class EmptyEnumeratorFactoryStub : IAbCipTagEnumeratorFactory
+ {
+ public IAbCipTagEnumerator Create() => new EmptyStub();
+
+ private sealed class EmptyStub : IAbCipTagEnumerator
+ {
+ public async IAsyncEnumerable EnumerateAsync(
+ AbCipTagCreateParams deviceParams,
+ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ await Task.CompletedTask;
+ yield break;
+ }
+ public void Dispose() { }
+ }
+ }
+
+ private sealed class RecordingEnumeratorFactory : IAbCipTagEnumeratorFactory
+ {
+ private readonly AbCipDiscoveredTag[] _seed;
+ public int CreateCount;
+
+ public RecordingEnumeratorFactory(params AbCipDiscoveredTag[] seed) => _seed = seed;
+
+ public IAbCipTagEnumerator Create()
+ {
+ CreateCount++;
+ return new SeededStub(_seed);
+ }
+
+ private sealed class SeededStub(AbCipDiscoveredTag[] seed) : IAbCipTagEnumerator
+ {
+ public async IAsyncEnumerable EnumerateAsync(
+ AbCipTagCreateParams deviceParams,
+ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ foreach (var tag in seed)
+ yield return tag;
+ await Task.CompletedTask;
+ }
+ public void Dispose() { }
+ }
+ }
+}