diff --git a/docs/Driver.AbCip.Cli.md b/docs/Driver.AbCip.Cli.md index 375f36b..211a719 100644 --- a/docs/Driver.AbCip.Cli.md +++ b/docs/Driver.AbCip.Cli.md @@ -56,6 +56,21 @@ otopcua-abcip-cli read -g ab://10.0.0.5/1,0 -t "Recipe[3]" --type Real otopcua-abcip-cli read -g ab://10.0.0.5/1,0 -t "Motor01.Speed" --type Real ``` +#### Diagnostic / system tags + +PR abcip-4.3 exposes five read-only diagnostic variables per device under +`AbCip//_System/` in the OPC UA address space (see +[AbCip-Operability §System tags](drivers/AbCip-Operability.md#system-tags--_system-folder) +for the full table). These are not reachable through the AB CIP CLI — they +live on the OPC UA server side, not the libplctag wire — so to read one, +point the **OPC UA client** CLI at the running OtOpcUa server: + +```powershell +# Read _ConnectionStatus for one device through the OPC UA server +otopcua-client-cli read -u opc.tcp://localhost:4840 \ + -n "ns=2;s=AbCip/ab://10.0.0.5/1,0/_System/_ConnectionStatus" +``` + ### `write` — single Logix tag Same shape as `read` plus `-v`. Values parse per `--type` using invariant diff --git a/docs/drivers/AbCip-Operability.md b/docs/drivers/AbCip-Operability.md index 6667f9b..d7818e4 100644 --- a/docs/drivers/AbCip-Operability.md +++ b/docs/drivers/AbCip-Operability.md @@ -297,3 +297,68 @@ the equality threshold too loose; revisit the per-tag config. - Modbus driver — read-side deadband in `ModbusDriver` predates this write-side equivalent; the config shape is intentionally similar. - Kepware "Deadband (write)" knob — this is the AB CIP equivalent. + +## System tags / `_System` folder + +PR abcip-4.3 surfaces five read-only diagnostic variables under +`AbCip//_System/` so SCADA / Admin clients can pivot from "is the +wire up?" to "what's our scan rate / tag count?" without leaving the OPC UA +address space. The values come straight from the live +`IHostConnectivityProbe` + `DriverHealth` surfaces — reads bypass libplctag +and are served from the in-memory snapshot the probe loop / read loop +updates. + +### What it ships + +| Variable | Type | Source | Notes | +|---|---|---|---| +| `_ConnectionStatus` | String | `HostState` | `Running` / `Stopped` / `Unknown` / `Faulted`. Mirrors what the connectivity probe sees. | +| `_ScanRate` | Float64 | `AbCipProbeOptions.Interval` | Configured probe interval in milliseconds — compare against `_LastScanTimeMs` to spot wire stretch. | +| `_TagCount` | Int32 | `_tagsByName` | Discovered tag count for this device, excluding `_System/*`. | +| `_DeviceError` | String | `DriverHealth.LastError` | Most recent error message; empty when the device is healthy. | +| `_LastScanTimeMs` | Float64 | `ReadAsync` wall-clock | Duration of the most-recent `ReadAsync` iteration on this device. | + +### When the snapshot updates + +- **Probe transitions** — every `Running ↔ Stopped` flip refreshes the + device's snapshot inline, so a client subscribed to + `_System/_ConnectionStatus` sees the new state on the next OPC UA + publish tick. +- **Read iterations** — `ReadAsync` recomputes `_LastScanTimeMs` per + device that owned at least one reference in the batch + writes a fresh + snapshot before returning. +- **Driver init** — every device gets a seeded snapshot + (`Unknown` / `0` / `""`) before the probe loop spins up so a read that + arrives before the first probe iteration returns a stable shape rather + than null. + +### Browse + read example + +```powershell +# Browse the synthetic folder +otopcua-client-cli browse -u opc.tcp://localhost:4840 \ + -n "ns=2;s=AbCip/ab://10.0.0.5/1,0/_System" + +# Read the connection status +otopcua-client-cli read -u opc.tcp://localhost:4840 \ + -n "ns=2;s=AbCip/ab://10.0.0.5/1,0/_System/_ConnectionStatus" +``` + +The driver-side reference embeds the device host address (the +`_System//` form) so the dispatcher can route by device +without an additional registry. PR abcip-4.4 will turn `_RefreshTagDb` into +a writeable refresh trigger; everything 4.3 ships is `ViewOnly`. + +### Verification + +- **Unit**: `AbCipSystemTagSourceTests` + (`tests/.../AbCip.Tests`) — covers snapshot round-trip, two-device + isolation, recognised-name lookup, default-shape on unseeded devices, + discovery emits the five canonical nodes, and `ReadAsync` dispatches + through the source instead of libplctag. +- **Integration**: `AbCipSystemTagDiscoveryTests` + (`tests/.../AbCip.IntegrationTests`) — `[AbServerFact]` connects to a + real `ab_server`, browses `_System/`, reads each variable, asserts + every one returns Good with a non-null value. +- **E2E**: `scripts/e2e/test-abcip.ps1` — see the *SystemTagBrowse* + assertion. diff --git a/docs/drivers/AbServer-Test-Fixture.md b/docs/drivers/AbServer-Test-Fixture.md index b4945d7..9f7d6ad 100644 --- a/docs/drivers/AbServer-Test-Fixture.md +++ b/docs/drivers/AbServer-Test-Fixture.md @@ -146,7 +146,14 @@ No smoke test for: atomic-write coverage end-to-end is still unit-only. - `ITagDiscovery.DiscoverAsync` (`@tags` walker) - `ISubscribable.SubscribeAsync` (poll-group engine) -- `IHostConnectivityProbe` state transitions under wire failure +- ~~`IHostConnectivityProbe` state transitions under wire failure~~ — + covered as of PR abcip-4.3. `AbCipSystemTagDiscoveryTests` connects to + `ab_server`, drives the discovery + read path against the synthetic + `_System/_ConnectionStatus` variable, and asserts the live snapshot + reflects the probe-driven `HostState`. Wire-failure transitions still + rely on unit-level `ThrowOnRead` injection rather than a real wire pull, + but the end-to-end probe → snapshot → OPC UA address-space link is + exercised against `ab_server`. - `IPerCallHostResolver` multi-device routing The driver implements all of these + they have unit coverage, but the only diff --git a/scripts/e2e/test-abcip.ps1 b/scripts/e2e/test-abcip.ps1 index f069463..16bb901 100644 --- a/scripts/e2e/test-abcip.ps1 +++ b/scripts/e2e/test-abcip.ps1 @@ -39,6 +39,13 @@ .PARAMETER SlowBridgeNodeId Optional NodeId for a Tag declared with ScanRateMs >= 1000. Pair with FastBridgeNodeId to enable the scan-rate assertion. + +.PARAMETER SystemConnectionStatusNodeId + Optional NodeId for the synthetic _System/_ConnectionStatus variable + emitted by AB CIP discovery (PR abcip-4.3). When supplied, the script + runs the SystemTagBrowse assertion — reads the value through the OPC UA + server + asserts it surfaces one of the canonical HostState strings. + NodeId form: ns=;s=AbCip//_System/_ConnectionStatus. #> param( @@ -48,7 +55,12 @@ param( [string]$OpcUaUrl = "opc.tcp://localhost:4840", [Parameter(Mandatory)] [string]$BridgeNodeId, [string]$FastBridgeNodeId, - [string]$SlowBridgeNodeId + [string]$SlowBridgeNodeId, + # PR abcip-4.3 — NodeId for the synthetic _System/_ConnectionStatus variable that + # discovery emits under each device. Optional — when wired, runs the + # SystemTagBrowse assertion that browses + reads the system folder through the OPC UA + # server. NodeId form: ns=;s=AbCip//_System/_ConnectionStatus. + [string]$SystemConnectionStatusNodeId ) $ErrorActionPreference = "Stop" @@ -208,5 +220,28 @@ $results += [PSCustomObject]@{ } } +# PR abcip-4.3 — _System/_ConnectionStatus browse-and-read assertion. Reads the live +# diagnostic snapshot via the OPC UA Client CLI; the value comes straight from the +# AbCipSystemTagSource (no libplctag round-trip). When the probe loop is healthy + the +# gateway is reachable, the value should be "Running"; on a stopped fixture it would be +# "Stopped". The assertion accepts any of the four canonical states, plus the "Unknown" +# transient that surfaces before the first probe iteration completes. +if ($SystemConnectionStatusNodeId) { + Write-Header "SystemTagBrowse (_System/_ConnectionStatus from $SystemConnectionStatusNodeId)" + $sysReadArgs = @($opcUaCli.PrefixArgs) + @("read", "-u", $OpcUaUrl, "-n", $SystemConnectionStatusNodeId) + $sysOut = & $opcUaCli.File @sysReadArgs 2>&1 + $sysJoined = ($sysOut -join "`n") + $sysMatched = $sysJoined -match "Running|Stopped|Unknown|Faulted" + $results += [PSCustomObject]@{ + Name = "SystemTagBrowse" + Passed = $sysMatched + Detail = if ($sysMatched) { + "_ConnectionStatus surfaced one of Running / Stopped / Unknown / Faulted via OPC UA" + } else { + "_ConnectionStatus did not surface a recognised HostState — got '$sysJoined'" + } + } +} + 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/AbCipDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs index 1be52db..fa629af 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs @@ -36,6 +36,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, private readonly AbCipAlarmProjection _alarmProjection; private readonly SemaphoreSlim _discoverySemaphore = new(1, 1); private readonly AbCipWriteCoalescer _writeCoalescer = new(); + private readonly AbCipSystemTagSource _systemTagSource = new(); private DriverHealth _health = new(DriverState.Unknown, null, null); public event EventHandler? OnDataChange; @@ -225,6 +226,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, } } + // PR abcip-4.3 — seed each device's system-tag snapshot before the probe / read loops + // start so an immediate _System read returns a stable shape (Unknown / 0 / "") instead + // of "no snapshot recorded yet". TransitionDeviceState + ReadAsync refresh from here. + foreach (var state in _devices.Values) + RefreshSystemTagSnapshot(state, lastScanTimeMs: 0.0); + // Probe loops — one per device when enabled + a ProbeTagPath is configured. if (_options.Probe.Enabled && !string.IsNullOrWhiteSpace(_options.Probe.ProbeTagPath)) { @@ -649,10 +656,46 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, // full round-trip + the coalescer rebuilds its cache from the new baseline. if (newState == HostState.Stopped || newState == HostState.Running) _writeCoalescer.Reset(state.Options.HostAddress); + // PR abcip-4.3 — refresh the diagnostic-tag snapshot on every transition so a client + // subscribed to _System/_ConnectionStatus sees the new state immediately + the + // _DeviceError mirror the driver's most-recent fault message. Pass newState explicitly + // so the refresh doesn't race the lock-release with state.HostState. + RefreshSystemTagSnapshot(state, overrideHostState: newState); OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(state.Options.HostAddress, old, newState)); } + /// + /// PR abcip-4.3 — rebuild a single device's from the + /// live , the configured probe interval, the + /// count of discovered tags excluding _System/*, the most-recent driver-error + /// message, and the supplied last-scan duration. Called from probe transitions, the + /// end of , and at seed time. + /// + private void RefreshSystemTagSnapshot( + DeviceState state, double? lastScanTimeMs = null, HostState? overrideHostState = null) + { + var tagCount = 0; + foreach (var t in _tagsByName.Values) + { + if (string.Equals(t.DeviceHostAddress, state.Options.HostAddress, StringComparison.OrdinalIgnoreCase) + && !AbCipSystemTagSource.IsSystemReference(t.Name)) + tagCount++; + } + var scanRateMs = _options.Probe.Interval.TotalMilliseconds; + var deviceError = _health.LastError ?? string.Empty; + // Caller can pass overrideHostState to dodge the read-from-volatile-state race that + // would otherwise sit between TransitionDeviceState's lock release + this refresh. + var connectionStatus = (overrideHostState ?? state.HostState).ToString(); + var resolvedScan = lastScanTimeMs ?? state.LastScanTimeMs; + _systemTagSource.Update(state.Options.HostAddress, new SystemTagSnapshot( + ConnectionStatus: connectionStatus, + ScanRateMs: scanRateMs, + TagCount: tagCount, + DeviceError: deviceError, + LastScanTimeMs: resolvedScan)); + } + // ---- IPerCallHostResolver ---- /// @@ -665,11 +708,48 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, /// public string ResolveHost(string fullReference) { + // PR abcip-4.3 — _System// carries the device in its + // address path, so route on the embedded host directly rather than falling back + // to "first configured device" + having the bulkhead key collide across devices. + if (AbCipSystemTagSource.IsSystemReference(fullReference)) + { + var host = ExtractSystemDeviceHost(fullReference); + if (host is not null) return host; + } if (_tagsByName.TryGetValue(fullReference, out var def)) return def.DeviceHostAddress; return _options.Devices.FirstOrDefault()?.HostAddress ?? DriverInstanceId; } + /// + /// PR abcip-4.3 — pull the device host address out of a _System/<host>/<name> + /// reference. Splits on the last '/' so device hosts that themselves contain a + /// forward-slash (the canonical ab://gateway/cip-path form does) survive the + /// round-trip. Returns null when the reference doesn't match the expected shape. + /// + internal static string? ExtractSystemDeviceHost(string reference) + { + if (!AbCipSystemTagSource.IsSystemReference(reference)) return null; + var withoutPrefix = reference[AbCipSystemTagSource.SystemFolderPrefix.Length..]; + var lastSlash = withoutPrefix.LastIndexOf('/'); + if (lastSlash <= 0) return null; + return withoutPrefix[..lastSlash]; + } + + /// + /// PR abcip-4.3 — pull the trailing system-tag name (e.g. _ConnectionStatus) out + /// of a _System/<host>/<name> reference. Pairs with + /// . + /// + internal static string? ExtractSystemTagName(string reference) + { + if (!AbCipSystemTagSource.IsSystemReference(reference)) return null; + var withoutPrefix = reference[AbCipSystemTagSource.SystemFolderPrefix.Length..]; + var lastSlash = withoutPrefix.LastIndexOf('/'); + if (lastSlash <= 0 || lastSlash >= withoutPrefix.Length - 1) return null; + return withoutPrefix[(lastSlash + 1)..]; + } + // ---- IReadable ---- /// @@ -685,6 +765,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, ArgumentNullException.ThrowIfNull(fullReferences); var now = DateTime.UtcNow; var results = new DataValueSnapshot[fullReferences.Count]; + var scanStart = System.Diagnostics.Stopwatch.GetTimestamp(); // 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 @@ -704,6 +785,30 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, // Auto — per-group heuristic on subscribedMembers / totalMembers. await ExecuteReadPlanAsync(fullReferences, results, now, cancellationToken).ConfigureAwait(false); + // PR abcip-4.3 — track wall-clock scan time per device that owned at least one ref in + // this batch. Surfaces as _System/_LastScanTimeMs; the snapshot refresh also picks up + // any health transitions / error messages that happened during the read. + var elapsedMs = (System.Diagnostics.Stopwatch.GetTimestamp() - scanStart) + * 1000.0 / System.Diagnostics.Stopwatch.Frequency; + var touched = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var fr in fullReferences) + { + string? deviceHost = null; + if (AbCipSystemTagSource.IsSystemReference(fr)) + { + deviceHost = ExtractSystemDeviceHost(fr); + } + else if (_tagsByName.TryGetValue(fr, out var def)) + { + deviceHost = def.DeviceHostAddress; + } + if (deviceHost is null || !touched.Add(deviceHost)) continue; + if (_devices.TryGetValue(deviceHost, out var state)) + { + state.LastScanTimeMs = elapsedMs; + RefreshSystemTagSnapshot(state, elapsedMs); + } + } return results; } @@ -973,6 +1078,24 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, private async Task ReadSingleAsync( AbCipUdtReadFallback fb, string reference, DataValueSnapshot[] results, DateTime now, CancellationToken ct) { + // PR abcip-4.3 — synthetic _System// reference; serve from the + // diagnostic snapshot instead of materialising a libplctag runtime. + if (AbCipSystemTagSource.IsSystemReference(reference)) + { + var deviceHost = ExtractSystemDeviceHost(reference); + var nameUnderSystem = ExtractSystemTagName(reference); + if (deviceHost is not null && nameUnderSystem is not null + && _systemTagSource.TryRead(nameUnderSystem, deviceHost, out var sysValue)) + { + results[fb.OriginalIndex] = new DataValueSnapshot(sysValue, AbCipStatusMapper.Good, now, now); + } + else + { + results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now); + } + return; + } + if (!_tagsByName.TryGetValue(reference, out var def)) { results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now); @@ -1606,6 +1729,13 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, var deviceLabel = device.DeviceName ?? device.HostAddress; var deviceFolder = root.Folder(device.HostAddress, deviceLabel); + // PR abcip-4.3 — diagnostic / system tags. Five read-only variables under + // _System/, each FullName-prefixed with _System// so the + // ReadAsync dispatcher can route by device without an additional registry. PR 4.4 + // will turn _RefreshTagDb into a writeable refresh trigger; everything 4.3 ships + // is ViewOnly. + EmitSystemTagFolder(deviceFolder, device.HostAddress); + // Pre-declared tags — always emitted; the primary config path. UDT tags with declared // Members fan out into a sub-folder + one Variable per member instead of a single // Structure Variable (Structure has no useful scalar value + member-addressable paths @@ -1710,6 +1840,53 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, } } + /// + /// PR abcip-4.3 — emit the per-device _System folder + its five read-only + /// diagnostic variables. The FullName on each variable encodes the owning + /// device's host address (_System/<host>/<name>) so the read path + /// can route to without a separate + /// registry. Names + types stay in lockstep with + /// . + /// + private static void EmitSystemTagFolder(IAddressSpaceBuilder deviceFolder, string deviceHostAddress) + { + var systemFolder = deviceFolder.Folder("_System", "_System"); + EmitSystemVariable(systemFolder, deviceHostAddress, "_ConnectionStatus", DriverDataType.String); + EmitSystemVariable(systemFolder, deviceHostAddress, "_ScanRate", DriverDataType.Float64); + EmitSystemVariable(systemFolder, deviceHostAddress, "_TagCount", DriverDataType.Int32); + EmitSystemVariable(systemFolder, deviceHostAddress, "_DeviceError", DriverDataType.String); + EmitSystemVariable(systemFolder, deviceHostAddress, "_LastScanTimeMs", DriverDataType.Float64); + } + + private static void EmitSystemVariable( + IAddressSpaceBuilder systemFolder, string deviceHostAddress, string name, DriverDataType type) + { + var fullName = $"{AbCipSystemTagSource.SystemFolderPrefix}{deviceHostAddress}/{name}"; + systemFolder.Variable(name, name, new DriverAttributeInfo( + FullName: fullName, + DriverDataType: type, + IsArray: false, + ArrayDim: null, + // Read-only for now — PR abcip-4.4 will flip _RefreshTagDb to Operate when the + // refresh trigger lands. Today the AbCip system folder has no writeable members. + SecurityClass: SecurityClassification.ViewOnly, + IsHistorized: false, + IsAlarm: false, + WriteIdempotent: false, + Description: name switch + { + "_ConnectionStatus" => "Live HostState (Running / Stopped / Unknown / Faulted) — driven by the connectivity probe.", + "_ScanRate" => "Configured probe / poll interval in milliseconds.", + "_TagCount" => "Count of discovered tags on this device, excluding _System.", + "_DeviceError" => "Most recent driver-error message; empty when the device is healthy.", + "_LastScanTimeMs" => "Wall-clock duration of the most recent ReadAsync iteration on this device, in milliseconds.", + _ => null, + })); + } + + /// Test seam — exposes the live system-tag source so unit tests can poke the snapshot directly. + internal AbCipSystemTagSource SystemTagSource => _systemTagSource; + private static DriverAttributeInfo ToAttributeInfo(AbCipTagDefinition tag) => new( FullName: tag.Name, DriverDataType: tag.DataType.ToDriverDataType(), @@ -1822,6 +1999,14 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, public CancellationTokenSource? ProbeCts { get; set; } public bool ProbeInitialized { get; set; } + /// + /// PR abcip-4.3 — wall-clock duration of the most recent + /// iteration that touched any tag on this device, in milliseconds. Surfaces as + /// _System/_LastScanTimeMs; 0.0 until the first read completes so an + /// unread device shows a stable zero rather than a stale value. + /// + public double LastScanTimeMs; + public Dictionary TagHandles { get; } = new(StringComparer.OrdinalIgnoreCase); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipSystemTagSource.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipSystemTagSource.cs new file mode 100644 index 0000000..cc93a56 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipSystemTagSource.cs @@ -0,0 +1,166 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +/// +/// PR abcip-4.3 — diagnostic / system-tag source. Holds the latest health snapshot for +/// each device, served back through when the +/// incoming reference points at the synthetic _System/<name> address. The +/// driver bypasses libplctag for these reads — values come straight from the +/// + surfaces. +/// +/// +/// Design parity with Modbus' ModbusSystemTags — the same five canonical +/// names are exposed under each device's _System folder so the Admin UI / SCADA +/// clients can pivot from "is the wire up?" to "what's our scan rate / tag count?" +/// without leaving the OPC UA address space. PR 4.4 will turn _RefreshTagDb +/// into a writeable refresh trigger; everything 4.3 ships is read-only. +/// +/// _ConnectionStatus — string, mirrors the device's . +/// _ScanRate — double, the configured probe interval in milliseconds +/// (operators can compare against _LastScanTimeMs to spot wire stretch). +/// _TagCount — int, count of discovered tags excluding the +/// _System folder itself. +/// _DeviceError — string, the most recent driver-error message or empty. +/// _LastScanTimeMs — double, wall-clock ms of the last poll-loop +/// iteration on this device. +/// +/// +public sealed class AbCipSystemTagSource +{ + /// Canonical names the system folder exposes — keep in lockstep with discovery. + public static readonly IReadOnlyList SystemTagNames = + [ + "_ConnectionStatus", + "_ScanRate", + "_TagCount", + "_DeviceError", + "_LastScanTimeMs", + ]; + + /// + /// Address-space prefix the driver stamps on each system variable's + /// so + /// can dispatch to instead + /// of materialising a libplctag runtime. + /// + public const string SystemFolderPrefix = "_System/"; + + private readonly Dictionary _snapshots = + new(StringComparer.OrdinalIgnoreCase); + private readonly object _lock = new(); + + /// + /// Replace the snapshot for one device. Called on every health transition + every + /// successful read iteration so the surfaced values track the live driver loop + /// without piling up extra timers. + /// + public void Update(string deviceHostAddress, SystemTagSnapshot snapshot) + { + ArgumentNullException.ThrowIfNull(deviceHostAddress); + ArgumentNullException.ThrowIfNull(snapshot); + lock (_lock) + { + _snapshots[deviceHostAddress] = snapshot; + } + } + + /// + /// Look up the current snapshot for a device. Returns null when no snapshot + /// has been recorded yet (the driver is still in + /// or no probe / read iteration has fired). + /// + public SystemTagSnapshot? TryGet(string deviceHostAddress) + { + lock (_lock) + { + return _snapshots.TryGetValue(deviceHostAddress, out var s) ? s : null; + } + } + + /// + /// Resolve a _System/<name> address against the current snapshot for + /// . may be + /// either the bare name (_ConnectionStatus) or the prefixed form + /// (_System/_ConnectionStatus) — both shapes the driver might pass in. + /// Returns true when the name is recognised; is + /// null when no snapshot has been recorded yet so the caller can stamp the + /// read with UncertainNoCommunicationLastUsableValue if it cares to. + /// + public bool TryRead(string addressUnderSystem, string deviceHostAddress, out object? value) + { + ArgumentNullException.ThrowIfNull(addressUnderSystem); + ArgumentNullException.ThrowIfNull(deviceHostAddress); + + var name = addressUnderSystem.StartsWith(SystemFolderPrefix, StringComparison.Ordinal) + ? addressUnderSystem[SystemFolderPrefix.Length..] + : addressUnderSystem; + + // Recognised name? + var matched = false; + for (var i = 0; i < SystemTagNames.Count; i++) + { + if (string.Equals(SystemTagNames[i], name, StringComparison.Ordinal)) + { + matched = true; + break; + } + } + if (!matched) + { + value = null; + return false; + } + + var snapshot = TryGet(deviceHostAddress); + if (snapshot is null) + { + // Recognised name but no data yet — surface a sensible default per the type so + // clients see a stable shape instead of nulls flickering across the address space. + value = name switch + { + "_ConnectionStatus" => "Unknown", + "_DeviceError" => string.Empty, + "_TagCount" => 0, + _ => 0.0, + }; + return true; + } + + value = name switch + { + "_ConnectionStatus" => snapshot.ConnectionStatus, + "_ScanRate" => snapshot.ScanRateMs, + "_TagCount" => snapshot.TagCount, + "_DeviceError" => snapshot.DeviceError, + "_LastScanTimeMs" => snapshot.LastScanTimeMs, + _ => null, + }; + return true; + } + + /// + /// true when targets a node under the synthetic + /// _System/ folder. The driver's read path uses this to bypass the libplctag + /// runtime + dispatch to directly. + /// + public static bool IsSystemReference(string reference) => + !string.IsNullOrEmpty(reference) + && reference.StartsWith(SystemFolderPrefix, StringComparison.Ordinal); +} + +/// +/// PR abcip-4.3 — immutable snapshot of one device's diagnostic surface. Five fields +/// match the five system-tag variables the discovery emits. +/// +/// Stringified HostState (Running / Stopped / Unknown / Faulted). +/// Configured probe / poll interval in milliseconds. +/// Count of discovered tags on this device, excluding _System. +/// Most recent error message; empty when the device is healthy. +/// Wall-clock ms the last poll iteration took on this device. +public sealed record SystemTagSnapshot( + string ConnectionStatus, + double ScanRateMs, + int TagCount, + string DeviceError, + double LastScanTimeMs); diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipSystemTagDiscoveryTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipSystemTagDiscoveryTests.cs new file mode 100644 index 0000000..663efa4 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipSystemTagDiscoveryTests.cs @@ -0,0 +1,95 @@ +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-4.3 — end-to-end coverage that the synthetic _System folder + its five +/// diagnostic variables ride a real ab_server lifecycle. Skipped when the binary isn't +/// on PATH (). +/// +[Trait("Category", "Integration")] +[Trait("Requires", "AbServer")] +public sealed class AbCipSystemTagDiscoveryTests +{ + [AbServerFact] + public async Task System_folder_browses_and_each_variable_reads_non_empty() + { + 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 drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(deviceUri, profile.Family)], + Tags = [new AbCipTagDefinition("Counter", deviceUri, "TestDINT", AbCipDataType.DInt)], + Timeout = TimeSpan.FromSeconds(5), + }, "drv-system-tags"); + + await drv.InitializeAsync("{}", CancellationToken.None); + + // Discovery — five system variables exposed under _System/ for the device. + var builder = new RecordingBuilder(); + await drv.DiscoverAsync(builder, CancellationToken.None); + + builder.Folders.ShouldContain(f => f.BrowseName == "_System"); + var systemVars = builder.Variables + .Where(v => v.Info.FullName.StartsWith("_System/")) + .Select(v => v.BrowseName) + .ToList(); + systemVars.ShouldContain("_ConnectionStatus"); + systemVars.ShouldContain("_ScanRate"); + systemVars.ShouldContain("_TagCount"); + systemVars.ShouldContain("_DeviceError"); + systemVars.ShouldContain("_LastScanTimeMs"); + + // Read — each system variable returns Good with a non-null value, with no + // libplctag round-trip required. + var refs = systemVars + .Select(name => $"_System/{deviceUri}/{name}") + .ToList(); + var snaps = await drv.ReadAsync(refs, CancellationToken.None); + for (var i = 0; i < snaps.Count; i++) + { + snaps[i].StatusCode.ShouldBe(AbCipStatusMapper.Good, + $"system variable {refs[i]} should read Good"); + snaps[i].Value.ShouldNotBeNull( + $"system variable {refs[i]} should not be null"); + } + + await drv.ShutdownAsync(CancellationToken.None); + } + finally + { + await fixture.DisposeAsync(); + } + } + + private sealed class RecordingBuilder : IAddressSpaceBuilder + { + public List<(string BrowseName, string DisplayName)> Folders { get; } = new(); + public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new(); + + public IAddressSpaceBuilder Folder(string browseName, string displayName) + { Folders.Add((browseName, displayName)); return this; } + + public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info) + { Variables.Add((browseName, info)); return new Handle(info.FullName); } + + public void AddProperty(string _, DriverDataType __, object? ___) { } + + private sealed class Handle(string fullRef) : IVariableHandle + { + public string FullReference => fullRef; + public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink(); + } + private sealed class NullSink : IAlarmConditionSink + { + public void OnTransition(AlarmEventArgs args) { } + } + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverDiscoveryTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverDiscoveryTests.cs index 1a07857..13de3a6 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverDiscoveryTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverDiscoveryTests.cs @@ -28,9 +28,11 @@ public sealed class AbCipDriverDiscoveryTests builder.Folders.ShouldContain(f => f.BrowseName == "AbCip"); builder.Folders.ShouldContain(f => f.BrowseName == "ab://10.0.0.5/1,0" && f.DisplayName == "Line1-PLC"); - builder.Variables.Count.ShouldBe(2); - builder.Variables.Single(v => v.BrowseName == "Speed").Info.SecurityClass.ShouldBe(SecurityClassification.Operate); - builder.Variables.Single(v => v.BrowseName == "Temperature").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly); + // PR abcip-4.3 — exclude the synthetic _System/ folder vars from the count. + var userVars = builder.Variables.Where(v => !v.Info.FullName.StartsWith("_System/")).ToList(); + userVars.Count.ShouldBe(2); + userVars.Single(v => v.BrowseName == "Speed").Info.SecurityClass.ShouldBe(SecurityClassification.Operate); + userVars.Single(v => v.BrowseName == "Temperature").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly); } [Fact] @@ -67,7 +69,10 @@ public sealed class AbCipDriverDiscoveryTests await drv.DiscoverAsync(builder, CancellationToken.None); - builder.Variables.Select(v => v.BrowseName).ShouldBe(["UserTag"]); + builder.Variables + .Where(v => !v.Info.FullName.StartsWith("_System/")) + .Select(v => v.BrowseName) + .ShouldBe(["UserTag"]); } [Fact] @@ -83,7 +88,7 @@ public sealed class AbCipDriverDiscoveryTests await drv.DiscoverAsync(builder, CancellationToken.None); - builder.Variables.ShouldBeEmpty(); + builder.Variables.Where(v => !v.Info.FullName.StartsWith("_System/")).ShouldBeEmpty(); } [Fact] @@ -126,7 +131,10 @@ public sealed class AbCipDriverDiscoveryTests await drv.DiscoverAsync(builder, CancellationToken.None); - builder.Variables.Select(v => v.Info.FullName).ShouldBe(["KeepMe"]); + builder.Variables + .Where(v => !v.Info.FullName.StartsWith("_System/")) + .Select(v => v.Info.FullName) + .ShouldBe(["KeepMe"]); } [Fact] @@ -145,7 +153,10 @@ public sealed class AbCipDriverDiscoveryTests await drv.DiscoverAsync(builder, CancellationToken.None); - builder.Variables.Single().Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly); + builder.Variables + .Where(v => !v.Info.FullName.StartsWith("_System/")) + .Single() + .Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly); } [Fact] diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipSystemTagSourceTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipSystemTagSourceTests.cs new file mode 100644 index 0000000..14d84bf --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipSystemTagSourceTests.cs @@ -0,0 +1,322 @@ +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.Tests; + +/// +/// PR abcip-4.3 unit coverage for the diagnostic / system-tag source. Tests both the +/// standalone + the +/// integration: discovery emits the five canonical nodes, ReadAsync routes +/// _System/... through the source instead of libplctag, and snapshot updates +/// follow probe transitions. +/// +[Trait("Category", "Unit")] +public sealed class AbCipSystemTagSourceTests +{ + [Fact] + public void Update_then_TryRead_returns_the_snapshot() + { + var src = new AbCipSystemTagSource(); + const string host = "ab://10.0.0.5/1,0"; + src.Update(host, new SystemTagSnapshot("Running", 500.0, 12, "", 4.7)); + + src.TryRead("_ConnectionStatus", host, out var status).ShouldBeTrue(); + status.ShouldBe("Running"); + src.TryRead("_ScanRate", host, out var rate).ShouldBeTrue(); + rate.ShouldBe(500.0); + src.TryRead("_TagCount", host, out var count).ShouldBeTrue(); + count.ShouldBe(12); + src.TryRead("_DeviceError", host, out var err).ShouldBeTrue(); + err.ShouldBe(""); + src.TryRead("_LastScanTimeMs", host, out var last).ShouldBeTrue(); + last.ShouldBe(4.7); + } + + [Fact] + public void TryRead_accepts_either_bare_or_prefixed_form() + { + var src = new AbCipSystemTagSource(); + const string host = "ab://10.0.0.5/1,0"; + src.Update(host, new SystemTagSnapshot("Running", 500.0, 1, "", 0.0)); + + src.TryRead("_ConnectionStatus", host, out var bare).ShouldBeTrue(); + src.TryRead("_System/_ConnectionStatus", host, out var prefixed).ShouldBeTrue(); + bare.ShouldBe(prefixed); + } + + [Fact] + public void TryRead_unknown_name_returns_false() + { + var src = new AbCipSystemTagSource(); + src.Update("h", new SystemTagSnapshot("Running", 500, 0, "", 0)); + + src.TryRead("_NotARealName", "h", out var v).ShouldBeFalse(); + v.ShouldBeNull(); + } + + [Fact] + public void TryRead_without_snapshot_returns_typed_default() + { + var src = new AbCipSystemTagSource(); + + src.TryRead("_ConnectionStatus", "missing", out var status).ShouldBeTrue(); + status.ShouldBe("Unknown"); + src.TryRead("_ScanRate", "missing", out var rate).ShouldBeTrue(); + rate.ShouldBe(0.0); + src.TryRead("_TagCount", "missing", out var count).ShouldBeTrue(); + count.ShouldBe(0); + src.TryRead("_DeviceError", "missing", out var err).ShouldBeTrue(); + err.ShouldBe(string.Empty); + src.TryRead("_LastScanTimeMs", "missing", out var last).ShouldBeTrue(); + last.ShouldBe(0.0); + } + + [Fact] + public void Two_devices_keep_independent_snapshots() + { + var src = new AbCipSystemTagSource(); + const string a = "ab://10.0.0.5/1,0"; + const string b = "ab://10.0.0.6/1,0"; + src.Update(a, new SystemTagSnapshot("Running", 500, 10, "", 1.0)); + src.Update(b, new SystemTagSnapshot("Stopped", 1000, 3, "boom", 99.9)); + + src.TryRead("_ConnectionStatus", a, out var sa).ShouldBeTrue(); + src.TryRead("_ConnectionStatus", b, out var sb).ShouldBeTrue(); + sa.ShouldBe("Running"); + sb.ShouldBe("Stopped"); + + src.TryRead("_DeviceError", a, out var ea).ShouldBeTrue(); + src.TryRead("_DeviceError", b, out var eb).ShouldBeTrue(); + ea.ShouldBe(""); + eb.ShouldBe("boom"); + } + + [Fact] + public void IsSystemReference_matches_only_the_System_prefix() + { + AbCipSystemTagSource.IsSystemReference("_System/foo/_ConnectionStatus").ShouldBeTrue(); + AbCipSystemTagSource.IsSystemReference("_System/").ShouldBeTrue(); + AbCipSystemTagSource.IsSystemReference("Motor.Speed").ShouldBeFalse(); + AbCipSystemTagSource.IsSystemReference("").ShouldBeFalse(); + AbCipSystemTagSource.IsSystemReference("MySystem/foo").ShouldBeFalse(); + } + + [Fact] + public async Task Discovery_emits_five_system_nodes_per_device() + { + var builder = new RecordingBuilder(); + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", DeviceName: "PLC-A")], + Probe = new AbCipProbeOptions { Enabled = false }, + }, "drv-1"); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.DiscoverAsync(builder, CancellationToken.None); + + builder.Folders.ShouldContain(f => f.BrowseName == "_System"); + var systemVars = builder.Variables + .Where(v => v.Info.FullName.StartsWith("_System/")) + .Select(v => v.BrowseName) + .OrderBy(s => s) + .ToList(); + systemVars.ShouldBe(new[] + { + "_ConnectionStatus", "_DeviceError", "_LastScanTimeMs", + "_ScanRate", "_TagCount", + }); + // All five carry the device host inside the FullName. + builder.Variables + .Where(v => v.Info.FullName.StartsWith("_System/")) + .ShouldAllBe(v => v.Info.FullName.StartsWith("_System/ab://10.0.0.5/1,0/")); + // PR 4.4 will flip _RefreshTagDb to writeable; today every system var is ViewOnly. + builder.Variables + .Where(v => v.Info.FullName.StartsWith("_System/")) + .ShouldAllBe(v => v.Info.SecurityClass == SecurityClassification.ViewOnly); + } + + [Fact] + public async Task Discovery_emits_System_folder_for_each_device() + { + var builder = new RecordingBuilder(); + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = + [ + new AbCipDeviceOptions("ab://10.0.0.5/1,0"), + new AbCipDeviceOptions("ab://10.0.0.6/1,0"), + ], + Probe = new AbCipProbeOptions { Enabled = false }, + }, "drv-2"); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.DiscoverAsync(builder, CancellationToken.None); + + builder.Folders.Count(f => f.BrowseName == "_System").ShouldBe(2); + builder.Variables.Count(v => v.Info.FullName.StartsWith("_System/")).ShouldBe(10); + builder.Variables + .Where(v => v.Info.FullName.StartsWith("_System/")) + .Select(v => v.Info.FullName) + .ShouldContain("_System/ab://10.0.0.5/1,0/_ConnectionStatus"); + builder.Variables + .Where(v => v.Info.FullName.StartsWith("_System/")) + .Select(v => v.Info.FullName) + .ShouldContain("_System/ab://10.0.0.6/1,0/_ConnectionStatus"); + } + + [Fact] + public async Task ReadAsync_dispatches_System_reference_to_source_not_libplctag() + { + // FakeAbCipTagFactory throws when used; if the driver materialises a libplctag handle + // for a _System/... reference, this test will trip ThrowOnRead and surface a Bad status. + var factory = new FakeAbCipTagFactory + { + Customise = p => new FakeAbCipTag(p) { ThrowOnRead = true }, + }; + const string host = "ab://10.0.0.5/1,0"; + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(host)], + Probe = new AbCipProbeOptions { Enabled = false }, + }, "drv-1", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + // Seed the source so we have a known snapshot to read back. + drv.SystemTagSource.Update(host, + new SystemTagSnapshot("Running", 250.0, 5, "", 7.5)); + + var snaps = await drv.ReadAsync( + [$"_System/{host}/_ConnectionStatus", $"_System/{host}/_TagCount"], + CancellationToken.None); + + snaps[0].StatusCode.ShouldBe(AbCipStatusMapper.Good); + snaps[0].Value.ShouldBe("Running"); + snaps[1].StatusCode.ShouldBe(AbCipStatusMapper.Good); + snaps[1].Value.ShouldBe(5); + } + + [Fact] + public async Task ReadAsync_unknown_System_name_returns_BadNodeIdUnknown() + { + const string host = "ab://10.0.0.5/1,0"; + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(host)], + Probe = new AbCipProbeOptions { Enabled = false }, + }, "drv-1"); + await drv.InitializeAsync("{}", CancellationToken.None); + + var snaps = await drv.ReadAsync( + [$"_System/{host}/_NotARealName"], CancellationToken.None); + + snaps[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown); + } + + [Fact] + public async Task TagCount_reflects_count_excluding_System_folder() + { + var builder = new RecordingBuilder(); + const string host = "ab://10.0.0.5/1,0"; + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(host)], + Tags = + [ + new AbCipTagDefinition("Speed", host, "Motor1.Speed", AbCipDataType.DInt), + new AbCipTagDefinition("Temp", host, "T", AbCipDataType.Real), + new AbCipTagDefinition("Pressure", host, "P", AbCipDataType.Real), + ], + Probe = new AbCipProbeOptions { Enabled = false }, + }, "drv-1"); + await drv.InitializeAsync("{}", CancellationToken.None); + await drv.DiscoverAsync(builder, CancellationToken.None); + + // Read the synthetic _TagCount and assert it matches the three pre-declared tags. + var snaps = await drv.ReadAsync( + [$"_System/{host}/_TagCount"], CancellationToken.None); + snaps[0].StatusCode.ShouldBe(AbCipStatusMapper.Good); + snaps[0].Value.ShouldBe(3); + } + + [Fact] + public async Task ResolveHost_for_System_reference_returns_embedded_device_host() + { + const string a = "ab://10.0.0.5/1,0"; + const string b = "ab://10.0.0.6/1,0"; + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(a), new AbCipDeviceOptions(b)], + Probe = new AbCipProbeOptions { Enabled = false }, + }, "drv-1"); + await drv.InitializeAsync("{}", CancellationToken.None); + + drv.ResolveHost($"_System/{a}/_ConnectionStatus").ShouldBe(a); + drv.ResolveHost($"_System/{b}/_ScanRate").ShouldBe(b); + } + + [Fact] + public async Task Probe_transition_updates_system_tag_snapshot() + { + var factory = new FakeAbCipTagFactory { Customise = p => new FakeAbCipTag(p) { Status = 0 } }; + const string host = "ab://10.0.0.5/1,0"; + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(host)], + Probe = new AbCipProbeOptions + { + Enabled = true, + Interval = TimeSpan.FromMilliseconds(100), + Timeout = TimeSpan.FromMilliseconds(50), + ProbeTagPath = "@raw_cpu_type", + }, + }, "drv-1", factory); + + await drv.InitializeAsync("{}", CancellationToken.None); + + // Wait until the snapshot itself flips to Running — the probe transition runs the + // snapshot refresh inline so once the snapshot reflects Running, GetHostStatuses must + // too. Polling the snapshot directly avoids a race against the post-transition + // refresh window. + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(2); + object? status = null; + while (DateTime.UtcNow < deadline) + { + drv.SystemTagSource.TryRead("_ConnectionStatus", host, out status); + if (Equals(status, "Running")) break; + await Task.Delay(20); + } + + status.ShouldBe("Running"); + drv.GetHostStatuses().Single().State.ShouldBe(HostState.Running); + + await drv.ShutdownAsync(CancellationToken.None); + } + + // ---- helpers (mirror AbCipDriverDiscoveryTests) ---- + + private sealed class RecordingBuilder : IAddressSpaceBuilder + { + public List<(string BrowseName, string DisplayName)> Folders { get; } = new(); + public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new(); + + public IAddressSpaceBuilder Folder(string browseName, string displayName) + { Folders.Add((browseName, displayName)); return this; } + + public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info) + { Variables.Add((browseName, info)); return new Handle(info.FullName); } + + public void AddProperty(string _, DriverDataType __, object? ___) { } + + private sealed class Handle(string fullRef) : IVariableHandle + { + public string FullReference => fullRef; + public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink(); + } + private sealed class NullSink : IAlarmConditionSink + { + public void OnTransition(AlarmEventArgs args) { } + } + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipUdtMemberTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipUdtMemberTests.cs index 8174386..05408f1 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipUdtMemberTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipUdtMemberTests.cs @@ -266,7 +266,11 @@ public sealed class AbCipUdtMemberTests await drv.DiscoverAsync(builder, CancellationToken.None); - builder.Variables.Select(v => v.BrowseName).ShouldBe(["FlatA", "Speed", "FlatB"], ignoreOrder: true); + // PR abcip-4.3 — exclude the synthetic _System/ folder vars from the count. + builder.Variables + .Where(v => !v.Info.FullName.StartsWith("_System/")) + .Select(v => v.BrowseName) + .ShouldBe(["FlatA", "Speed", "FlatB"], ignoreOrder: true); } // ---- helpers ----