From 0c6a0d6e5030c4f36e19b936d707f4ba53a391da Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 25 Apr 2026 22:58:33 -0400 Subject: [PATCH] =?UTF-8?q?Auto:=20abcip-3.2=20=E2=80=94=20symbolic=20vs?= =?UTF-8?q?=20logical=20addressing=20toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #236 --- docs/Driver.AbCip.Cli.md | 1 + docs/drivers/AbCip-Performance.md | 155 ++++++++ docs/drivers/AbServer-Test-Fixture.md | 10 + scripts/e2e/test-abcip.ps1 | 25 ++ .../AbCipCommandBase.cs | 17 +- .../AbCipDriver.cs | 238 ++++++++++- .../AbCipDriverFactoryExtensions.cs | 13 +- .../AbCipDriverOptions.cs | 49 ++- .../IAbCipTagRuntime.cs | 17 +- .../LibplctagTagRuntime.cs | 55 +++ .../PlcFamilies/AbCipPlcFamilyProfile.cs | 19 +- .../AbCipAddressingModeBenchTests.cs | 76 ++++ .../AbCipAddressingModeTests.cs | 375 ++++++++++++++++++ 13 files changed, 1033 insertions(+), 17 deletions(-) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipAddressingModeBenchTests.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipAddressingModeTests.cs 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() { } + } + } +} -- 2.49.1