From e0e5e04e4839cb5376455cd1fa89a06898eefb71 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 03:16:28 -0400 Subject: [PATCH] =?UTF-8?q?Auto:=20abcip-4.4=20=E2=80=94=20=5FRefreshTagDb?= =?UTF-8?q?=20writeable=20system=20tag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #241 --- docs/Driver.AbCip.Cli.md | 36 ++ docs/drivers/AbCip-Operability.md | 68 +++- scripts/e2e/test-abcip.ps1 | 52 ++- .../AbCipDriver.cs | 165 +++++++- .../AbCipSystemTagSource.cs | 114 +++++- .../AbCipRefreshTagDbTests.cs | 91 +++++ .../AbCipRefreshTagDbTests.cs | 376 ++++++++++++++++++ .../AbCipSystemTagSourceTests.cs | 20 +- 8 files changed, 877 insertions(+), 45 deletions(-) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipRefreshTagDbTests.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipRefreshTagDbTests.cs diff --git a/docs/Driver.AbCip.Cli.md b/docs/Driver.AbCip.Cli.md index 211a719..184a2f4 100644 --- a/docs/Driver.AbCip.Cli.md +++ b/docs/Driver.AbCip.Cli.md @@ -89,6 +89,38 @@ otopcua-abcip-cli write -g ab://10.0.0.5/1,0 -t StartCommand --type Bool -v true otopcua-abcip-cli subscribe -g ab://10.0.0.5/1,0 -t Motor01_Speed --type Real -i 500 ``` +### `rebrowse` — force a controller-side `@tags` re-walk + +PR abcip-2.5 (issue #233) added `RebrowseAsync` to drop the cached UDT +template shapes and re-run the symbol-table enumerator without restarting +the driver. The CLI variant builds a transient driver against the supplied +gateway, runs the rebrowse, and prints the freshly discovered tag names — +useful after a controller program-download to confirm the new tags are +visible on the wire before wiring them through the OtOpcUa server. + +```powershell +otopcua-abcip-cli rebrowse -g ab://10.0.0.5/1,0 +``` + +## Refreshing the tag DB + +Two operator-facing surfaces drive the same `RebrowseAsync` plumbing — pick +the one that matches your context: + +| Surface | When to use | Command | +|---|---|---| +| **CLI `rebrowse`** | Off-server validation. Spins up a transient driver against the gateway, prints the discovered tag list, no shared state with the live OtOpcUa server. | `otopcua-abcip-cli rebrowse -g ab://10.0.0.5/1,0` | +| **OPC UA write to `_RefreshTagDb`** | Production / Admin-UI button (PR abcip-4.4). Forces the **live** driver to re-walk + clear its template cache. The `AbCip.RefreshTriggers` driver-diagnostics counter increments per truthy write. | `otopcua-client-cli write -u opc.tcp://localhost:4840 -n "ns=2;s=AbCip/ab://10.0.0.5/1,0/_System/_RefreshTagDb" -v true --type Boolean` | + +Read-back semantics: `_RefreshTagDb` always reads back as `false` (Kepware- +style "latches to idle the moment the dispatch returns") so a subscribed +client sees a stable shape regardless of how many refreshes have fired. +Falsy / unparseable writes are no-ops that still report `Good` so a UI +template that resets the trigger flag after firing it doesn't see a phantom +error. See +[AbCip-Operability §System tags](drivers/AbCip-Operability.md#refreshing-the-tag-db-via-opc-ua-write) +for the full semantics + the diagnostics counter wiring. + ## Typical workflows - **"Is the PLC reachable?"** → `probe`. @@ -97,6 +129,10 @@ otopcua-abcip-cli subscribe -g ab://10.0.0.5/1,0 -t Motor01_Speed --type Real -i - **"Is this GuardLogix safety tag writable from non-safety?"** → `write` and read the status code — safety tags surface `BadNotWritable` / CIP errors, non-safety tags surface `Good`. +- **"Did my program download show up in the address space?"** → `rebrowse` + (off-server) or write `true` to the live server's `_RefreshTagDb` system + tag (in-server, PR abcip-4.4) — both drop the template cache + force a + fresh `@tags` walk. ## Connection Size diff --git a/docs/drivers/AbCip-Operability.md b/docs/drivers/AbCip-Operability.md index d7818e4..42a89f1 100644 --- a/docs/drivers/AbCip-Operability.md +++ b/docs/drivers/AbCip-Operability.md @@ -306,17 +306,19 @@ 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. +updates. PR abcip-4.4 added `_RefreshTagDb` as a sixth, writeable entry — +the Kepware-style refresh trigger. ### 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. | +| Variable | Type | Access | Source | Notes | +|---|---|---|---|---| +| `_ConnectionStatus` | String | ViewOnly | `HostState` | `Running` / `Stopped` / `Unknown` / `Faulted`. Mirrors what the connectivity probe sees. | +| `_ScanRate` | Float64 | ViewOnly | `AbCipProbeOptions.Interval` | Configured probe interval in milliseconds — compare against `_LastScanTimeMs` to spot wire stretch. | +| `_TagCount` | Int32 | ViewOnly | `_tagsByName` | Discovered tag count for this device, excluding `_System/*`. | +| `_DeviceError` | String | ViewOnly | `DriverHealth.LastError` | Most recent error message; empty when the device is healthy. | +| `_LastScanTimeMs` | Float64 | ViewOnly | `ReadAsync` wall-clock | Duration of the most-recent `ReadAsync` iteration on this device. | +| `_RefreshTagDb` | Boolean | **Operate** | n/a (write-only trigger) | PR abcip-4.4 — Kepware-style refresh trigger. Reads always return `false`. Writing any truthy value (`true`, non-zero number, `"true"` / `"1"` strings, case-insensitive) dispatches to `RebrowseAsync` against the device's cached `IAddressSpaceBuilder`. Falsy / unparseable writes are no-ops that report `Good` so a UI that resets the trigger flag doesn't see a phantom error. The `AbCip.RefreshTriggers` diagnostic counter increments per truthy write. | ### When the snapshot updates @@ -346,19 +348,59 @@ otopcua-client-cli read -u opc.tcp://localhost:4840 \ 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`. +without an additional registry. PR abcip-4.4 turned `_RefreshTagDb` into +a writeable refresh trigger; the rest of the surface remains `ViewOnly`. + +### Refreshing the tag DB via OPC UA write + +PR abcip-4.4 wires `_RefreshTagDb` to the same `RebrowseAsync` entry point +the CLI's `rebrowse` command exercises (issue #233). Operators have two +roughly-equivalent ways to force a controller-side `@tags` re-walk after a +program download: + +```powershell +# Path A — OPC UA write to the system tag (production / Admin UI path) +otopcua-client-cli write -u opc.tcp://localhost:4840 \ + -n "ns=2;s=AbCip/ab://10.0.0.5/1,0/_System/_RefreshTagDb" \ + -v true --type Boolean + +# Path B — direct CLI rebrowse against a transient driver (admin / debug path) +otopcua-abcip-cli rebrowse -g ab://10.0.0.5/1,0 +``` + +Both paths drop the UDT template cache + re-run the enumerator walk. Path A +is the operator-facing surface (the same `IDriverControl.RebrowseAsync` +contract, just dispatched from the OPC UA write surface instead of an +in-process call). Path B spins up its own driver instance so it doesn't +share the live server's cache, which makes it useful for one-off +controller-side validation. + +The `AbCip.RefreshTriggers` driver-diagnostics counter increments per +successful truthy write, so the Admin UI / driver-diagnostics RPC can show +a "Refreshes since boot" tile that pairs naturally with the existing +`WritesSuppressed` / `WritesPassedThrough` write-coalescer counters. ### 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 + discovery emits the six canonical nodes, and `ReadAsync` dispatches through the source instead of libplctag. +- **Unit**: `AbCipRefreshTagDbTests` + (`tests/.../AbCip.Tests`) — PR abcip-4.4 — covers discovery emits the + trigger as Operate, reads always return `false`, truthy/falsy/null write + semantics, the `AbCip.RefreshTriggers` counter, two-device counter + independence, defends-in-depth `BadNotWritable` for read-only system + variables, no-op-Good when no builder is cached yet, and mixed-batch + routing alongside ordinary tag writes. - **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. +- **Integration**: `AbCipRefreshTagDbTests` + (`tests/.../AbCip.IntegrationTests`) — PR abcip-4.4 — `[AbServerFact]` + drives a `_RefreshTagDb` write, asserts the template cache drops + the + per-device counter advances against a live `ab_server`. +- **E2E**: `scripts/e2e/test-abcip.ps1` — see the *SystemTagBrowse* + + *RefreshTagDbWrite* assertions. diff --git a/scripts/e2e/test-abcip.ps1 b/scripts/e2e/test-abcip.ps1 index 16bb901..57db22e 100644 --- a/scripts/e2e/test-abcip.ps1 +++ b/scripts/e2e/test-abcip.ps1 @@ -46,6 +46,14 @@ 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. + +.PARAMETER RefreshTagDbNodeId + Optional NodeId for the writeable _System/_RefreshTagDb trigger added in + PR abcip-4.4. When supplied, the script runs the RefreshTagDbWrite + assertion — writes True through the OPC UA server + reads back, asserting + the trigger latches to False (Kepware-style "always idle" semantics) and + the write itself surfaces Good. NodeId form: + ns=;s=AbCip//_System/_RefreshTagDb. #> param( @@ -60,7 +68,12 @@ param( # 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 + [string]$SystemConnectionStatusNodeId, + # PR abcip-4.4 — NodeId for the writeable _System/_RefreshTagDb refresh-trigger. + # Mirrors the SystemConnectionStatusNodeId knob: optional, only runs the + # RefreshTagDbWrite assertion when supplied. NodeId form: + # ns=;s=AbCip//_System/_RefreshTagDb. + [string]$RefreshTagDbNodeId ) $ErrorActionPreference = "Stop" @@ -243,5 +256,42 @@ if ($SystemConnectionStatusNodeId) { } } +# PR abcip-4.4 — _RefreshTagDb write-then-verify assertion. Writes True through the +# OPC UA server (the live driver intercepts the write + dispatches to RebrowseAsync +# against the cached IAddressSpaceBuilder) + reads back, asserting Kepware-style +# latch semantics: the trigger always reads False the moment the dispatch returns. +# Pairs with the existing rebrowse step driven by the AbCip CLI (issue #233) — both +# surfaces hit the same RebrowseAsync entry point, just from different sides of the +# OPC UA wire. +if ($RefreshTagDbNodeId) { + Write-Header "RefreshTagDbWrite (_System/_RefreshTagDb from $RefreshTagDbNodeId)" + $writeArgs = @($opcUaCli.PrefixArgs) + @( + "write", "-u", $OpcUaUrl, "-n", $RefreshTagDbNodeId, "-v", "true", "--type", "Boolean") + $writeOut = & $opcUaCli.File @writeArgs 2>&1 + $writeJoined = ($writeOut -join "`n") + # The OPC UA Client CLI surfaces "Good" on success; a non-Good result still + # round-trips the literal status code so we can match generously. + $writeOk = $writeJoined -match "Good" + + $readArgs = @($opcUaCli.PrefixArgs) + @("read", "-u", $OpcUaUrl, "-n", $RefreshTagDbNodeId) + $readOut = & $opcUaCli.File @readArgs 2>&1 + $readJoined = ($readOut -join "`n") + # Kepware-style trigger reads always return false — assert the trigger isn't + # latched to true after the write. Match case-insensitively because the OPC UA + # Client CLI may render the value as "False" or "false". + $readFalse = $readJoined -imatch "false" + + $passed = $writeOk -and $readFalse + $results += [PSCustomObject]@{ + Name = "RefreshTagDbWrite" + Passed = $passed + Detail = if ($passed) { + "_RefreshTagDb write returned Good and read-back surfaced false — Kepware-style latch held" + } else { + "RefreshTagDb write/verify failed — write='$writeJoined' read='$readJoined'" + } + } +} + 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 fa629af..16ea093 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs @@ -37,6 +37,11 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, private readonly SemaphoreSlim _discoverySemaphore = new(1, 1); private readonly AbCipWriteCoalescer _writeCoalescer = new(); private readonly AbCipSystemTagSource _systemTagSource = new(); + // PR abcip-4.4 — cached builder reference so a _RefreshTagDb write can dispatch to + // RebrowseAsync without an out-of-band call back into Core. Set by every successful + // DiscoverAsync / RebrowseAsync run; null before first discovery (a refresh write that + // arrives before the address space exists is a no-op + reports Good). + private IAddressSpaceBuilder? _cachedBuilder; private DriverHealth _health = new(DriverState.Unknown, null, null); public event EventHandler? OnDataChange; @@ -1330,22 +1335,60 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, ArgumentNullException.ThrowIfNull(writes); var results = new WriteResult[writes.Count]; + // PR abcip-4.4 — intercept _System//_RefreshTagDb writes BEFORE the + // multi-write planner runs. These are driver-local control writes: they never + // hit libplctag, they're not coalesced, and they don't ride the per-device + // bulkhead because the dispatch is in-memory (RebrowseAsync's discovery semaphore + // already serialises concurrent refreshes). Truthy writes invoke RebrowseAsync; + // falsy / unparseable writes report Good as a no-op so a UI that toggles the + // trigger off after firing it doesn't see a phantom error. + // + // Filter the request list to a non-system slice for the planner so genuine tag + // writes still flow through the multi-write packing path unchanged. + var nonSystemIndices = new List(writes.Count); + for (var i = 0; i < writes.Count; i++) + { + var w = writes[i]; + if (AbCipSystemTagSource.IsSystemReference(w.FullReference)) + { + results[i] = await HandleSystemWriteAsync(w, cancellationToken).ConfigureAwait(false); + } + else + { + nonSystemIndices.Add(i); + } + } + + if (nonSystemIndices.Count == 0) return results; + + // Slice the input down to the genuine-tag writes; the planner reports preflight + // failures back through the original-index closure so we have to remap. + var nonSystemWrites = new WriteRequest[nonSystemIndices.Count]; + for (var i = 0; i < nonSystemIndices.Count; i++) + nonSystemWrites[i] = writes[nonSystemIndices[i]]; + var plans = AbCipMultiWritePlanner.Build( - writes, _tagsByName, _devices, - reportPreflight: (idx, code) => results[idx] = new WriteResult(code)); + nonSystemWrites, _tagsByName, _devices, + reportPreflight: (slicedIdx, code) => + results[nonSystemIndices[slicedIdx]] = new WriteResult(code)); + + // PR abcip-4.4 — the planner's OriginalIndex addresses the sliced input list, so + // every write back into the caller-visible results array translates through + // nonSystemIndices to land at the right slot. + int Remap(int slicedIdx) => nonSystemIndices[slicedIdx]; foreach (var plan in plans) { if (!_devices.TryGetValue(plan.DeviceHostAddress, out var device)) { - foreach (var e in plan.Packable) results[e.OriginalIndex] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown); - foreach (var e in plan.BitRmw) results[e.OriginalIndex] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown); + foreach (var e in plan.Packable) results[Remap(e.OriginalIndex)] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown); + foreach (var e in plan.BitRmw) results[Remap(e.OriginalIndex)] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown); continue; } // Bit-RMW writes always serialise per-parent — never packed. foreach (var entry in plan.BitRmw) - results[entry.OriginalIndex] = new WriteResult( + results[Remap(entry.OriginalIndex)] = new WriteResult( await ExecuteBitRmwWriteAsync(device, entry, cancellationToken).ConfigureAwait(false)); if (plan.Packable.Count == 0) continue; @@ -1362,7 +1405,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, } var outcomes = await Task.WhenAll(tasks).ConfigureAwait(false); foreach (var (idx, code) in outcomes) - results[idx] = new WriteResult(code); + results[Remap(idx)] = new WriteResult(code); } else { @@ -1371,7 +1414,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, { var code = await ExecutePackableWriteAsync(device, entry, cancellationToken) .ConfigureAwait(false); - results[entry.OriginalIndex] = new WriteResult(code.code); + results[Remap(entry.OriginalIndex)] = new WriteResult(code.code); } } } @@ -1379,6 +1422,66 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, return results; } + /// + /// PR abcip-4.4 — handle a write against a _System/<host>/<name> + /// reference. Today only _RefreshTagDb is writeable; everything else under + /// _System/ is + the OPC UA + /// server layer rejects the write before it reaches the driver. The driver still + /// defends in depth here: an unrecognised system-tag write reports + /// rather than silently succeeding. + /// + private async Task HandleSystemWriteAsync(WriteRequest write, CancellationToken cancellationToken) + { + var deviceHost = ExtractSystemDeviceHost(write.FullReference); + var nameUnderSystem = ExtractSystemTagName(write.FullReference); + if (deviceHost is null || nameUnderSystem is null) + return new WriteResult(AbCipStatusMapper.BadNodeIdUnknown); + + if (!AbCipSystemTagSource.IsRefreshTagDb(nameUnderSystem)) + { + // Read-only system variable — the server-layer ACL should already have + // rejected this, but defend in depth so a misconfigured client gets a + // recognisable error instead of "Good but nothing happened". + return new WriteResult(AbCipStatusMapper.BadNotWritable); + } + + // Falsy / unparseable writes are a no-op so a UI that resets the trigger flag + // back to false (after firing it) doesn't see a phantom error. Good is the same + // shape Kepware's driver returns for an inert trigger write. + if (!AbCipSystemTagSource.IsTruthyRefresh(write.Value)) + return new WriteResult(AbCipStatusMapper.Good); + + if (!_devices.ContainsKey(deviceHost)) + return new WriteResult(AbCipStatusMapper.BadNodeIdUnknown); + + var builder = _cachedBuilder; + if (builder is null) + { + // Refresh fired before discovery had a chance to cache the builder — bump the + // counter (so operators can correlate the click with the lack of effect) but + // skip the dispatch since RebrowseAsync needs a builder to stream nodes into. + _systemTagSource.RecordRefreshTrigger(deviceHost); + return new WriteResult(AbCipStatusMapper.Good); + } + + try + { + await RebrowseAsync(builder, cancellationToken).ConfigureAwait(false); + _systemTagSource.RecordRefreshTrigger(deviceHost); + return new WriteResult(AbCipStatusMapper.Good); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, + $"_RefreshTagDb dispatch failed: {ex.Message}"); + return new WriteResult(AbCipStatusMapper.BadCommunicationError); + } + } + /// /// Execute one packable write — encode the value into the per-tag runtime, flush, and /// map the resulting libplctag status. Exception-to-StatusCode mapping mirrors the @@ -1652,6 +1755,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, { ["AbCip.WritesSuppressed"] = _writeCoalescer.TotalWritesSuppressed, ["AbCip.WritesPassedThrough"] = _writeCoalescer.TotalWritesPassedThrough, + // PR abcip-4.4 — total _RefreshTagDb truthy writes that dispatched to RebrowseAsync. + ["AbCip.RefreshTriggers"] = _systemTagSource.TotalRefreshTriggers, }; /// @@ -1722,6 +1827,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, private async Task DiscoverCoreAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) { + // PR abcip-4.4 — remember the most-recent builder so a subsequent _RefreshTagDb + // write can hand it back to RebrowseAsync without a callback through Core. The + // IAddressSpaceBuilder contract documents that builders are reusable for the + // lifetime of the address space + the host owns the lifecycle, so caching the + // reference here is safe. + _cachedBuilder = builder; var root = builder.Folder("AbCip", "AbCip"); foreach (var device in _options.Devices) @@ -1841,25 +1952,29 @@ 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 - /// . + /// PR abcip-4.3 — emit the per-device _System folder + its diagnostic + /// variables. PR abcip-4.4 added _RefreshTagDb as the sixth writeable entry. + /// 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); + EmitSystemVariable(systemFolder, deviceHostAddress, "_ConnectionStatus", DriverDataType.String, writeable: false); + EmitSystemVariable(systemFolder, deviceHostAddress, "_ScanRate", DriverDataType.Float64, writeable: false); + EmitSystemVariable(systemFolder, deviceHostAddress, "_TagCount", DriverDataType.Int32, writeable: false); + EmitSystemVariable(systemFolder, deviceHostAddress, "_DeviceError", DriverDataType.String, writeable: false); + EmitSystemVariable(systemFolder, deviceHostAddress, "_LastScanTimeMs", DriverDataType.Float64, writeable: false); + // PR abcip-4.4 — Kepware-style writeable refresh trigger. Reads return false; a + // truthy write dispatches to RebrowseAsync via the cached IAddressSpaceBuilder. + EmitSystemVariable(systemFolder, deviceHostAddress, AbCipSystemTagSource.RefreshTagDbName, + DriverDataType.Boolean, writeable: true); } private static void EmitSystemVariable( - IAddressSpaceBuilder systemFolder, string deviceHostAddress, string name, DriverDataType type) + IAddressSpaceBuilder systemFolder, string deviceHostAddress, string name, DriverDataType type, bool writeable) { var fullName = $"{AbCipSystemTagSource.SystemFolderPrefix}{deviceHostAddress}/{name}"; systemFolder.Variable(name, name, new DriverAttributeInfo( @@ -1867,11 +1982,15 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, 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, + // PR abcip-4.4 — _RefreshTagDb is the only writeable entry; everything else + // remains ViewOnly so subscribed clients can't accidentally write the + // diagnostic surface from a misbehaving SCADA template. + SecurityClass: writeable ? SecurityClassification.Operate : SecurityClassification.ViewOnly, IsHistorized: false, IsAlarm: false, + // _RefreshTagDb is idempotent in spirit (writing true twice is the same as + // once — both fire one rebrowse) but Kepware-style triggers don't deduplicate + // because operators expect each click to issue a fresh refresh. WriteIdempotent: false, Description: name switch { @@ -1880,6 +1999,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, "_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.", + AbCipSystemTagSource.RefreshTagDbName => + "Writeable Kepware-style refresh trigger. Reads always return false. Writing a truthy value (true / non-zero / \"true\" / \"1\") forces a controller-side @tags re-walk via RebrowseAsync.", _ => null, })); } diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipSystemTagSource.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipSystemTagSource.cs index cc93a56..ae273c1 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipSystemTagSource.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipSystemTagSource.cs @@ -10,11 +10,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; /// + surfaces. /// /// -/// Design parity with Modbus' ModbusSystemTags — the same five canonical +/// Design parity with Modbus' ModbusSystemTags — the same six 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. +/// without leaving the OPC UA address space. PR 4.4 turns _RefreshTagDb into a +/// writeable Kepware-style trigger — reads always return false, writes of any +/// truthy value dispatch to . /// /// _ConnectionStatus — string, mirrors the device's . /// _ScanRate — double, the configured probe interval in milliseconds @@ -24,10 +25,21 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; /// _DeviceError — string, the most recent driver-error message or empty. /// _LastScanTimeMs — double, wall-clock ms of the last poll-loop /// iteration on this device. +/// _RefreshTagDb — boolean, writeable Kepware-style trigger. Reads +/// always return false; writing any truthy value (true / non-zero / "true" +/// / "1") forces a controller-side re-walk via +/// . /// /// public sealed class AbCipSystemTagSource { + /// + /// PR abcip-4.4 — the writeable Kepware-style refresh-trigger system tag. Reads + /// always return false; writes of any truthy value cause the driver to + /// re-run discovery against the live controller symbol table. + /// + public const string RefreshTagDbName = "_RefreshTagDb"; + /// Canonical names the system folder exposes — keep in lockstep with discovery. public static readonly IReadOnlyList SystemTagNames = [ @@ -36,6 +48,7 @@ public sealed class AbCipSystemTagSource "_TagCount", "_DeviceError", "_LastScanTimeMs", + RefreshTagDbName, ]; /// @@ -48,8 +61,49 @@ public sealed class AbCipSystemTagSource private readonly Dictionary _snapshots = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _refreshTriggers = + new(StringComparer.OrdinalIgnoreCase); + private long _totalRefreshTriggers; private readonly object _lock = new(); + /// + /// PR abcip-4.4 — total _RefreshTagDb writes across every device managed by + /// the driver. Surfaced through as the + /// AbCip.RefreshTriggers diagnostic counter. + /// + public long TotalRefreshTriggers => Interlocked.Read(ref _totalRefreshTriggers); + + /// + /// PR abcip-4.4 — number of times _RefreshTagDb has been written for this + /// specific device. Returns 0 when the device has never seen a refresh + /// write (or isn't known to the source). + /// + public long GetRefreshTriggerCount(string deviceHostAddress) + { + ArgumentNullException.ThrowIfNull(deviceHostAddress); + lock (_lock) + { + return _refreshTriggers.TryGetValue(deviceHostAddress, out var n) ? n : 0; + } + } + + /// + /// PR abcip-4.4 — bump the per-device + global refresh counters for a successful + /// _RefreshTagDb write. Called from + /// after the rebrowse dispatch lands so a failed dispatch doesn't pollute the + /// counter. + /// + public void RecordRefreshTrigger(string deviceHostAddress) + { + ArgumentNullException.ThrowIfNull(deviceHostAddress); + Interlocked.Increment(ref _totalRefreshTriggers); + lock (_lock) + { + _refreshTriggers[deviceHostAddress] = + (_refreshTriggers.TryGetValue(deviceHostAddress, out var n) ? n : 0) + 1; + } + } + /// /// Replace the snapshot for one device. Called on every health transition + every /// successful read iteration so the surfaced values track the live driver loop @@ -112,6 +166,15 @@ public sealed class AbCipSystemTagSource return false; } + // PR abcip-4.4 — _RefreshTagDb is a Kepware-style writeable trigger: reads always + // return false (the trigger latches back to "idle" the moment the dispatch returns) + // so subscribed clients see a stable shape regardless of how many refreshes fired. + if (string.Equals(name, RefreshTagDbName, StringComparison.Ordinal)) + { + value = false; + return true; + } + var snapshot = TryGet(deviceHostAddress); if (snapshot is null) { @@ -139,6 +202,51 @@ public sealed class AbCipSystemTagSource return true; } + /// + /// PR abcip-4.4 — recognise _RefreshTagDb writes. Accepts both bare + /// (_RefreshTagDb) + prefixed (_System/_RefreshTagDb) shapes so + /// callers can pass whichever form the address came in as. + /// + public static bool IsRefreshTagDb(string addressUnderSystem) + { + if (string.IsNullOrEmpty(addressUnderSystem)) return false; + var name = addressUnderSystem.StartsWith(SystemFolderPrefix, StringComparison.Ordinal) + ? addressUnderSystem[SystemFolderPrefix.Length..] + : addressUnderSystem; + return string.Equals(name, RefreshTagDbName, StringComparison.Ordinal); + } + + /// + /// PR abcip-4.4 — Kepware-style truthy coercion for the _RefreshTagDb trigger. + /// Mirrors the same wire-format the OPC UA stack delivers: booleans pass through; + /// integers / doubles trigger when non-zero; strings parse as "true" / + /// "1" (case-insensitive). Anything else (null, empty, an unparseable string) + /// is treated as false + the write becomes a no-op. + /// + public static bool IsTruthyRefresh(object? value) + { + if (value is null) return false; + return value switch + { + bool b => b, + sbyte s => s != 0, + byte b => b != 0, + short s => s != 0, + ushort u => u != 0, + int i => i != 0, + uint u => u != 0, + long l => l != 0, + ulong u => u != 0, + float f => f != 0f && !float.IsNaN(f), + double d => d != 0.0 && !double.IsNaN(d), + decimal m => m != 0m, + string s => bool.TryParse(s, out var parsed) + ? parsed + : (int.TryParse(s, out var n) && n != 0), + _ => false, + }; + } + /// /// true when targets a node under the synthetic /// _System/ folder. The driver's read path uses this to bypass the libplctag diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipRefreshTagDbTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipRefreshTagDbTests.cs new file mode 100644 index 0000000..e587514 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipRefreshTagDbTests.cs @@ -0,0 +1,91 @@ +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.4 — end-to-end coverage that writing _RefreshTagDb on the +/// synthetic system folder dispatches to +/// against a live ab_server. Mirrors +/// but exercises the write entry point so the same outcome (template cache cleared, +/// enumerator re-walked) is observable through the OPC UA write surface. +/// +[Trait("Category", "Integration")] +[Trait("Requires", "AbServer")] +public sealed class AbCipRefreshTagDbTests +{ + [AbServerFact] + public async Task RefreshTagDb_write_invokes_rebrowse_and_bumps_counter() + { + 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)], + EnableControllerBrowse = true, + Timeout = TimeSpan.FromSeconds(5), + }, "drv-refresh-tagdb"); + + await drv.InitializeAsync("{}", CancellationToken.None); + + // Discovery primes the cached builder so the subsequent _RefreshTagDb write + // has a target to dispatch to. The same fixture pattern from AbCipRebrowseTests + // is exercised here through the write surface instead of a direct + // RebrowseAsync call. + var builder = new RecordingBuilder(); + await drv.DiscoverAsync(builder, CancellationToken.None); + + // Seed the template cache so we can assert RebrowseAsync clears it — same + // behavioural contract as the unit test, validated against a live walker. + drv.TemplateCache.Put(deviceUri, 42, new AbCipUdtShape("T", 4, [])); + drv.TemplateCache.Count.ShouldBe(1); + + var refreshRef = $"_System/{deviceUri}/{AbCipSystemTagSource.RefreshTagDbName}"; + var results = await drv.WriteAsync( + [new WriteRequest(refreshRef, true)], CancellationToken.None); + + results[0].StatusCode.ShouldBe(AbCipStatusMapper.Good); + // RebrowseAsync drops the template cache + the diagnostics counter advances. + drv.TemplateCache.Count.ShouldBe(0); + drv.SystemTagSource.GetRefreshTriggerCount(deviceUri).ShouldBe(1); + drv.GetHealth().DiagnosticsOrEmpty["AbCip.RefreshTriggers"].ShouldBe(1); + + 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/AbCipRefreshTagDbTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipRefreshTagDbTests.cs new file mode 100644 index 0000000..a24238a --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipRefreshTagDbTests.cs @@ -0,0 +1,376 @@ +using System.Runtime.CompilerServices; +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.4 — coverage for the writeable _RefreshTagDb system tag. Discovery +/// emits the entry as Operate, reads always return false, and writes of any +/// truthy value dispatch to while bumping the +/// AbCip.RefreshTriggers diagnostic counter. Falsy / unparseable writes are +/// no-ops so a SCADA template that pulses the trigger off after firing it doesn't see +/// a phantom error. +/// +[Trait("Category", "Unit")] +public sealed class AbCipRefreshTagDbTests +{ + private const string Host = "ab://10.0.0.5/1,0"; + private static string RefreshRef(string host = Host) => + $"_System/{host}/{AbCipSystemTagSource.RefreshTagDbName}"; + + [Fact] + public async Task Discovery_emits_RefreshTagDb_as_writeable() + { + var builder = new RecordingBuilder(); + await using var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(Host)], + Probe = new AbCipProbeOptions { Enabled = false }, + }, "drv-1"); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.DiscoverAsync(builder, CancellationToken.None); + + var refreshVar = builder.Variables.Single(v => v.Info.FullName.EndsWith("/_RefreshTagDb")); + refreshVar.Info.SecurityClass.ShouldBe(SecurityClassification.Operate); + refreshVar.Info.DriverDataType.ShouldBe(DriverDataType.Boolean); + refreshVar.Info.FullName.ShouldBe($"_System/{Host}/_RefreshTagDb"); + } + + [Fact] + public async Task Read_RefreshTagDb_returns_false() + { + await using 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([RefreshRef()], CancellationToken.None); + + snaps[0].StatusCode.ShouldBe(AbCipStatusMapper.Good); + snaps[0].Value.ShouldBe(false); + } + + [Fact] + public async Task Read_RefreshTagDb_returns_false_after_truthy_write() + { + // Writing the trigger doesn't change the read shape — it's always false the next + // time a client reads it (Kepware-style "latches back to idle" semantics). + var factory = new CountingEnumeratorFactory(); + await using var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(Host)], + EnableControllerBrowse = true, + Probe = new AbCipProbeOptions { Enabled = false }, + }, "drv-1", enumeratorFactory: factory); + await drv.InitializeAsync("{}", CancellationToken.None); + await drv.DiscoverAsync(new RecordingBuilder(), CancellationToken.None); + + await drv.WriteAsync([new WriteRequest(RefreshRef(), true)], CancellationToken.None); + + var snaps = await drv.ReadAsync([RefreshRef()], CancellationToken.None); + snaps[0].StatusCode.ShouldBe(AbCipStatusMapper.Good); + snaps[0].Value.ShouldBe(false); + } + + [Fact] + public async Task Truthy_write_dispatches_to_RebrowseAsync() + { + var factory = new CountingEnumeratorFactory( + new AbCipDiscoveredTag("Pressure", null, AbCipDataType.Real, ReadOnly: false)); + await using var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(Host)], + EnableControllerBrowse = true, + Probe = new AbCipProbeOptions { Enabled = false }, + }, "drv-1", enumeratorFactory: factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + // First DiscoverAsync caches the builder + bumps the enumerator once. + await drv.DiscoverAsync(new RecordingBuilder(), CancellationToken.None); + factory.EnumerationCount.ShouldBe(1); + + var results = await drv.WriteAsync( + [new WriteRequest(RefreshRef(), true)], CancellationToken.None); + + results[0].StatusCode.ShouldBe(AbCipStatusMapper.Good); + // RebrowseAsync re-runs the enumerator → count bumps. + factory.EnumerationCount.ShouldBe(2); + } + + [Theory] + [InlineData(true)] + [InlineData(1)] + [InlineData(1.5)] + [InlineData("true")] + [InlineData("True")] + [InlineData("1")] + public async Task Various_truthy_shapes_all_trigger_a_refresh(object value) + { + var factory = new CountingEnumeratorFactory(); + await using var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(Host)], + EnableControllerBrowse = true, + Probe = new AbCipProbeOptions { Enabled = false }, + }, "drv-1", enumeratorFactory: factory); + await drv.InitializeAsync("{}", CancellationToken.None); + await drv.DiscoverAsync(new RecordingBuilder(), CancellationToken.None); + var baseline = factory.EnumerationCount; + + var results = await drv.WriteAsync( + [new WriteRequest(RefreshRef(), value)], CancellationToken.None); + + results[0].StatusCode.ShouldBe(AbCipStatusMapper.Good); + factory.EnumerationCount.ShouldBe(baseline + 1); + } + + [Theory] + [InlineData(false)] + [InlineData(0)] + [InlineData(0.0)] + [InlineData("false")] + [InlineData("False")] + [InlineData("0")] + [InlineData("")] + [InlineData("not-a-bool")] + public async Task Falsy_or_unparseable_write_is_a_noop(object value) + { + var factory = new CountingEnumeratorFactory(); + await using var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(Host)], + EnableControllerBrowse = true, + Probe = new AbCipProbeOptions { Enabled = false }, + }, "drv-1", enumeratorFactory: factory); + await drv.InitializeAsync("{}", CancellationToken.None); + await drv.DiscoverAsync(new RecordingBuilder(), CancellationToken.None); + var baseline = factory.EnumerationCount; + + var results = await drv.WriteAsync( + [new WriteRequest(RefreshRef(), value)], CancellationToken.None); + + results[0].StatusCode.ShouldBe(AbCipStatusMapper.Good); + factory.EnumerationCount.ShouldBe(baseline); + drv.SystemTagSource.GetRefreshTriggerCount(Host).ShouldBe(0); + } + + [Fact] + public async Task Null_write_is_a_noop() + { + var factory = new CountingEnumeratorFactory(); + await using var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(Host)], + EnableControllerBrowse = true, + Probe = new AbCipProbeOptions { Enabled = false }, + }, "drv-1", enumeratorFactory: factory); + await drv.InitializeAsync("{}", CancellationToken.None); + await drv.DiscoverAsync(new RecordingBuilder(), CancellationToken.None); + var baseline = factory.EnumerationCount; + + var results = await drv.WriteAsync( + [new WriteRequest(RefreshRef(), null)], CancellationToken.None); + + results[0].StatusCode.ShouldBe(AbCipStatusMapper.Good); + factory.EnumerationCount.ShouldBe(baseline); + } + + [Fact] + public async Task RefreshTriggers_counter_bumps_per_truthy_write() + { + var factory = new CountingEnumeratorFactory(); + await using var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(Host)], + EnableControllerBrowse = true, + Probe = new AbCipProbeOptions { Enabled = false }, + }, "drv-1", enumeratorFactory: factory); + await drv.InitializeAsync("{}", CancellationToken.None); + await drv.DiscoverAsync(new RecordingBuilder(), CancellationToken.None); + + await drv.WriteAsync([new WriteRequest(RefreshRef(), true)], CancellationToken.None); + await drv.WriteAsync([new WriteRequest(RefreshRef(), true)], CancellationToken.None); + await drv.WriteAsync([new WriteRequest(RefreshRef(), false)], CancellationToken.None); + + drv.GetHealth().DiagnosticsOrEmpty["AbCip.RefreshTriggers"].ShouldBe(2); + drv.SystemTagSource.GetRefreshTriggerCount(Host).ShouldBe(2); + drv.SystemTagSource.TotalRefreshTriggers.ShouldBe(2); + } + + [Fact] + public async Task Two_devices_keep_independent_refresh_counters() + { + const string a = "ab://10.0.0.5/1,0"; + const string b = "ab://10.0.0.6/1,0"; + var factory = new CountingEnumeratorFactory(); + await using var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(a), new AbCipDeviceOptions(b)], + EnableControllerBrowse = true, + Probe = new AbCipProbeOptions { Enabled = false }, + }, "drv-1", enumeratorFactory: factory); + await drv.InitializeAsync("{}", CancellationToken.None); + await drv.DiscoverAsync(new RecordingBuilder(), CancellationToken.None); + + await drv.WriteAsync( + [new WriteRequest($"_System/{a}/_RefreshTagDb", true)], CancellationToken.None); + await drv.WriteAsync( + [new WriteRequest($"_System/{b}/_RefreshTagDb", true)], CancellationToken.None); + await drv.WriteAsync( + [new WriteRequest($"_System/{a}/_RefreshTagDb", true)], CancellationToken.None); + + drv.SystemTagSource.GetRefreshTriggerCount(a).ShouldBe(2); + drv.SystemTagSource.GetRefreshTriggerCount(b).ShouldBe(1); + drv.SystemTagSource.TotalRefreshTriggers.ShouldBe(3); + } + + [Fact] + public async Task Write_to_unknown_System_name_returns_BadNotWritable() + { + await using var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(Host)], + Probe = new AbCipProbeOptions { Enabled = false }, + }, "drv-1"); + await drv.InitializeAsync("{}", CancellationToken.None); + await drv.DiscoverAsync(new RecordingBuilder(), CancellationToken.None); + + var results = await drv.WriteAsync( + [new WriteRequest($"_System/{Host}/_ConnectionStatus", "Running")], + CancellationToken.None); + + results[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable); + } + + [Fact] + public async Task Write_to_unknown_System_device_returns_BadNodeIdUnknown() + { + await using var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(Host)], + Probe = new AbCipProbeOptions { Enabled = false }, + }, "drv-1"); + await drv.InitializeAsync("{}", CancellationToken.None); + await drv.DiscoverAsync(new RecordingBuilder(), CancellationToken.None); + + var results = await drv.WriteAsync( + [new WriteRequest("_System/ab://10.99.99.99/1,0/_RefreshTagDb", true)], + CancellationToken.None); + + results[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown); + } + + [Fact] + public async Task Refresh_write_before_discovery_is_a_noop_Good() + { + // _cachedBuilder is null before DiscoverAsync runs — the write should still report + // Good (Kepware-style trigger semantics never bubble "no address space yet" up to + // the OPC UA client) but not invoke RebrowseAsync. + var factory = new CountingEnumeratorFactory(); + await using var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(Host)], + EnableControllerBrowse = true, + Probe = new AbCipProbeOptions { Enabled = false }, + }, "drv-1", enumeratorFactory: factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + var results = await drv.WriteAsync( + [new WriteRequest(RefreshRef(), true)], CancellationToken.None); + + results[0].StatusCode.ShouldBe(AbCipStatusMapper.Good); + factory.EnumerationCount.ShouldBe(0); + } + + [Fact] + public async Task System_writes_do_not_block_genuine_tag_writes_in_the_same_batch() + { + // Mixed batch: one _RefreshTagDb + one ordinary tag write. The system entry is + // intercepted before the planner runs, the ordinary entry still flows through + // multi-write packing untouched, and both results land at their original indices. + var factory = new CountingEnumeratorFactory(); + var tagFactory = new FakeAbCipTagFactory + { + Customise = p => new FakeAbCipTag(p) { Status = 0 }, + }; + await using var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(Host)], + EnableControllerBrowse = true, + Tags = [new AbCipTagDefinition("Speed", Host, "Motor1.Speed", AbCipDataType.DInt)], + Probe = new AbCipProbeOptions { Enabled = false }, + }, "drv-1", tagFactory: tagFactory, enumeratorFactory: factory); + await drv.InitializeAsync("{}", CancellationToken.None); + await drv.DiscoverAsync(new RecordingBuilder(), CancellationToken.None); + + var results = await drv.WriteAsync( + [ + new WriteRequest(RefreshRef(), true), + new WriteRequest("Speed", 42), + ], CancellationToken.None); + + results[0].StatusCode.ShouldBe(AbCipStatusMapper.Good); + results[1].StatusCode.ShouldBe(AbCipStatusMapper.Good); + drv.SystemTagSource.GetRefreshTriggerCount(Host).ShouldBe(1); + } + + // ---- helpers (mirror AbCipRebrowseTests) ---- + + 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) { } + } + } + + private sealed class CountingEnumeratorFactory : IAbCipTagEnumeratorFactory + { + private readonly AbCipDiscoveredTag[] _tags; + public int CreateCount { get; private set; } + public int EnumerationCount { get; private set; } + + public CountingEnumeratorFactory(params AbCipDiscoveredTag[] tags) => _tags = tags; + + public IAbCipTagEnumerator Create() + { + CreateCount++; + return new CountingEnumerator(this); + } + + private sealed class CountingEnumerator(CountingEnumeratorFactory outer) : IAbCipTagEnumerator + { + public async IAsyncEnumerable EnumerateAsync( + AbCipTagCreateParams deviceParams, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + outer.EnumerationCount++; + await Task.CompletedTask; + foreach (var t in outer._tags) yield return t; + } + public void Dispose() { } + } + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipSystemTagSourceTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipSystemTagSourceTests.cs index 14d84bf..f827131 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipSystemTagSourceTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipSystemTagSourceTests.cs @@ -104,7 +104,7 @@ public sealed class AbCipSystemTagSourceTests } [Fact] - public async Task Discovery_emits_five_system_nodes_per_device() + public async Task Discovery_emits_six_system_nodes_per_device() { var builder = new RecordingBuilder(); var drv = new AbCipDriver(new AbCipDriverOptions @@ -122,19 +122,26 @@ public sealed class AbCipSystemTagSourceTests .Select(v => v.BrowseName) .OrderBy(s => s) .ToList(); + // PR abcip-4.4 — _RefreshTagDb joins the original five. systemVars.ShouldBe(new[] { "_ConnectionStatus", "_DeviceError", "_LastScanTimeMs", - "_ScanRate", "_TagCount", + "_RefreshTagDb", "_ScanRate", "_TagCount", }); - // All five carry the device host inside the FullName. + // All six 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. + // PR abcip-4.4 — _RefreshTagDb is the sole writeable entry (Operate); the rest + // remain ViewOnly so a SCADA template can't accidentally clobber the diagnostic + // surface. builder.Variables - .Where(v => v.Info.FullName.StartsWith("_System/")) + .Where(v => v.Info.FullName.StartsWith("_System/") + && !v.Info.FullName.EndsWith("/_RefreshTagDb")) .ShouldAllBe(v => v.Info.SecurityClass == SecurityClassification.ViewOnly); + builder.Variables + .Single(v => v.Info.FullName.EndsWith("/_RefreshTagDb")) + .Info.SecurityClass.ShouldBe(SecurityClassification.Operate); } [Fact] @@ -155,7 +162,8 @@ public sealed class AbCipSystemTagSourceTests 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); + // PR abcip-4.4 — six system variables per device (added _RefreshTagDb). + builder.Variables.Count(v => v.Info.FullName.StartsWith("_System/")).ShouldBe(12); builder.Variables .Where(v => v.Info.FullName.StartsWith("_System/")) .Select(v => v.Info.FullName)