[abcip] AbCip — _RefreshTagDb writeable system tag #384
@@ -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
|
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
|
## Typical workflows
|
||||||
|
|
||||||
- **"Is the PLC reachable?"** → `probe`.
|
- **"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
|
- **"Is this GuardLogix safety tag writable from non-safety?"** → `write` and
|
||||||
read the status code — safety tags surface `BadNotWritable` / CIP errors,
|
read the status code — safety tags surface `BadNotWritable` / CIP errors,
|
||||||
non-safety tags surface `Good`.
|
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
|
## Connection Size
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
address space. The values come straight from the live
|
||||||
`IHostConnectivityProbe` + `DriverHealth` surfaces — reads bypass libplctag
|
`IHostConnectivityProbe` + `DriverHealth` surfaces — reads bypass libplctag
|
||||||
and are served from the in-memory snapshot the probe loop / read loop
|
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
|
### What it ships
|
||||||
|
|
||||||
| Variable | Type | Source | Notes |
|
| Variable | Type | Access | Source | Notes |
|
||||||
|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| `_ConnectionStatus` | String | `HostState` | `Running` / `Stopped` / `Unknown` / `Faulted`. Mirrors what the connectivity probe sees. |
|
| `_ConnectionStatus` | String | ViewOnly | `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. |
|
| `_ScanRate` | Float64 | ViewOnly | `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/*`. |
|
| `_TagCount` | Int32 | ViewOnly | `_tagsByName` | Discovered tag count for this device, excluding `_System/*`. |
|
||||||
| `_DeviceError` | String | `DriverHealth.LastError` | Most recent error message; empty when the device is healthy. |
|
| `_DeviceError` | String | ViewOnly | `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. |
|
| `_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
|
### 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
|
The driver-side reference embeds the device host address (the
|
||||||
`_System/<device>/<name>` form) so the dispatcher can route by device
|
`_System/<device>/<name>` form) so the dispatcher can route by device
|
||||||
without an additional registry. PR abcip-4.4 will turn `_RefreshTagDb` into
|
without an additional registry. PR abcip-4.4 turned `_RefreshTagDb` into
|
||||||
a writeable refresh trigger; everything 4.3 ships is `ViewOnly`.
|
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
|
### Verification
|
||||||
|
|
||||||
- **Unit**: `AbCipSystemTagSourceTests`
|
- **Unit**: `AbCipSystemTagSourceTests`
|
||||||
(`tests/.../AbCip.Tests`) — covers snapshot round-trip, two-device
|
(`tests/.../AbCip.Tests`) — covers snapshot round-trip, two-device
|
||||||
isolation, recognised-name lookup, default-shape on unseeded devices,
|
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.
|
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`
|
- **Integration**: `AbCipSystemTagDiscoveryTests`
|
||||||
(`tests/.../AbCip.IntegrationTests`) — `[AbServerFact]` connects to a
|
(`tests/.../AbCip.IntegrationTests`) — `[AbServerFact]` connects to a
|
||||||
real `ab_server`, browses `_System/`, reads each variable, asserts
|
real `ab_server`, browses `_System/`, reads each variable, asserts
|
||||||
every one returns Good with a non-null value.
|
every one returns Good with a non-null value.
|
||||||
- **E2E**: `scripts/e2e/test-abcip.ps1` — see the *SystemTagBrowse*
|
- **Integration**: `AbCipRefreshTagDbTests`
|
||||||
assertion.
|
(`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.
|
||||||
|
|||||||
@@ -46,6 +46,14 @@
|
|||||||
runs the SystemTagBrowse assertion — reads the value through the OPC UA
|
runs the SystemTagBrowse assertion — reads the value through the OPC UA
|
||||||
server + asserts it surfaces one of the canonical HostState strings.
|
server + asserts it surfaces one of the canonical HostState strings.
|
||||||
NodeId form: ns=<n>;s=AbCip/<gateway>/_System/_ConnectionStatus.
|
NodeId form: ns=<n>;s=AbCip/<gateway>/_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=<n>;s=AbCip/<gateway>/_System/_RefreshTagDb.
|
||||||
#>
|
#>
|
||||||
|
|
||||||
param(
|
param(
|
||||||
@@ -60,7 +68,12 @@ param(
|
|||||||
# discovery emits under each device. Optional — when wired, runs the
|
# discovery emits under each device. Optional — when wired, runs the
|
||||||
# SystemTagBrowse assertion that browses + reads the system folder through the OPC UA
|
# SystemTagBrowse assertion that browses + reads the system folder through the OPC UA
|
||||||
# server. NodeId form: ns=<n>;s=AbCip/<gateway>/_System/_ConnectionStatus.
|
# server. NodeId form: ns=<n>;s=AbCip/<gateway>/_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=<n>;s=AbCip/<gateway>/_System/_RefreshTagDb.
|
||||||
|
[string]$RefreshTagDbNodeId
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$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
|
Write-Summary -Title "AB CIP e2e" -Results $results
|
||||||
if ($results | Where-Object { -not $_.Passed }) { exit 1 }
|
if ($results | Where-Object { -not $_.Passed }) { exit 1 }
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
private readonly SemaphoreSlim _discoverySemaphore = new(1, 1);
|
private readonly SemaphoreSlim _discoverySemaphore = new(1, 1);
|
||||||
private readonly AbCipWriteCoalescer _writeCoalescer = new();
|
private readonly AbCipWriteCoalescer _writeCoalescer = new();
|
||||||
private readonly AbCipSystemTagSource _systemTagSource = 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);
|
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||||
|
|
||||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||||
@@ -1330,22 +1335,60 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
ArgumentNullException.ThrowIfNull(writes);
|
ArgumentNullException.ThrowIfNull(writes);
|
||||||
var results = new WriteResult[writes.Count];
|
var results = new WriteResult[writes.Count];
|
||||||
|
|
||||||
|
// PR abcip-4.4 — intercept _System/<host>/_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<int>(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(
|
var plans = AbCipMultiWritePlanner.Build(
|
||||||
writes, _tagsByName, _devices,
|
nonSystemWrites, _tagsByName, _devices,
|
||||||
reportPreflight: (idx, code) => results[idx] = new WriteResult(code));
|
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)
|
foreach (var plan in plans)
|
||||||
{
|
{
|
||||||
if (!_devices.TryGetValue(plan.DeviceHostAddress, out var device))
|
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.Packable) results[Remap(e.OriginalIndex)] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
|
||||||
foreach (var e in plan.BitRmw) results[e.OriginalIndex] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
|
foreach (var e in plan.BitRmw) results[Remap(e.OriginalIndex)] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bit-RMW writes always serialise per-parent — never packed.
|
// Bit-RMW writes always serialise per-parent — never packed.
|
||||||
foreach (var entry in plan.BitRmw)
|
foreach (var entry in plan.BitRmw)
|
||||||
results[entry.OriginalIndex] = new WriteResult(
|
results[Remap(entry.OriginalIndex)] = new WriteResult(
|
||||||
await ExecuteBitRmwWriteAsync(device, entry, cancellationToken).ConfigureAwait(false));
|
await ExecuteBitRmwWriteAsync(device, entry, cancellationToken).ConfigureAwait(false));
|
||||||
|
|
||||||
if (plan.Packable.Count == 0) continue;
|
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);
|
var outcomes = await Task.WhenAll(tasks).ConfigureAwait(false);
|
||||||
foreach (var (idx, code) in outcomes)
|
foreach (var (idx, code) in outcomes)
|
||||||
results[idx] = new WriteResult(code);
|
results[Remap(idx)] = new WriteResult(code);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -1371,7 +1414,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
{
|
{
|
||||||
var code = await ExecutePackableWriteAsync(device, entry, cancellationToken)
|
var code = await ExecutePackableWriteAsync(device, entry, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.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;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-4.4 — handle a write against a <c>_System/<host>/<name></c>
|
||||||
|
/// reference. Today only <c>_RefreshTagDb</c> is writeable; everything else under
|
||||||
|
/// <c>_System/</c> is <see cref="SecurityClassification.ViewOnly"/> + 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
|
||||||
|
/// <see cref="AbCipStatusMapper.BadNotWritable"/> rather than silently succeeding.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<WriteResult> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Execute one packable write — encode the value into the per-tag runtime, flush, and
|
/// 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
|
/// 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.WritesSuppressed"] = _writeCoalescer.TotalWritesSuppressed,
|
||||||
["AbCip.WritesPassedThrough"] = _writeCoalescer.TotalWritesPassedThrough,
|
["AbCip.WritesPassedThrough"] = _writeCoalescer.TotalWritesPassedThrough,
|
||||||
|
// PR abcip-4.4 — total _RefreshTagDb truthy writes that dispatched to RebrowseAsync.
|
||||||
|
["AbCip.RefreshTriggers"] = _systemTagSource.TotalRefreshTriggers,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -1722,6 +1827,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
|
|
||||||
private async Task DiscoverCoreAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
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");
|
var root = builder.Folder("AbCip", "AbCip");
|
||||||
|
|
||||||
foreach (var device in _options.Devices)
|
foreach (var device in _options.Devices)
|
||||||
@@ -1841,25 +1952,29 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// PR abcip-4.3 — emit the per-device <c>_System</c> folder + its five read-only
|
/// PR abcip-4.3 — emit the per-device <c>_System</c> folder + its diagnostic
|
||||||
/// diagnostic variables. The <c>FullName</c> on each variable encodes the owning
|
/// variables. PR abcip-4.4 added <c>_RefreshTagDb</c> as the sixth writeable entry.
|
||||||
/// device's host address (<c>_System/<host>/<name></c>) so the read path
|
/// The <c>FullName</c> on each variable encodes the owning device's host address
|
||||||
/// can route to <see cref="AbCipSystemTagSource.TryRead"/> without a separate
|
/// (<c>_System/<host>/<name></c>) so the read path can route to
|
||||||
/// registry. Names + types stay in lockstep with
|
/// <see cref="AbCipSystemTagSource.TryRead"/> without a separate registry. Names +
|
||||||
/// <see cref="AbCipSystemTagSource.SystemTagNames"/>.
|
/// types stay in lockstep with <see cref="AbCipSystemTagSource.SystemTagNames"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static void EmitSystemTagFolder(IAddressSpaceBuilder deviceFolder, string deviceHostAddress)
|
private static void EmitSystemTagFolder(IAddressSpaceBuilder deviceFolder, string deviceHostAddress)
|
||||||
{
|
{
|
||||||
var systemFolder = deviceFolder.Folder("_System", "_System");
|
var systemFolder = deviceFolder.Folder("_System", "_System");
|
||||||
EmitSystemVariable(systemFolder, deviceHostAddress, "_ConnectionStatus", DriverDataType.String);
|
EmitSystemVariable(systemFolder, deviceHostAddress, "_ConnectionStatus", DriverDataType.String, writeable: false);
|
||||||
EmitSystemVariable(systemFolder, deviceHostAddress, "_ScanRate", DriverDataType.Float64);
|
EmitSystemVariable(systemFolder, deviceHostAddress, "_ScanRate", DriverDataType.Float64, writeable: false);
|
||||||
EmitSystemVariable(systemFolder, deviceHostAddress, "_TagCount", DriverDataType.Int32);
|
EmitSystemVariable(systemFolder, deviceHostAddress, "_TagCount", DriverDataType.Int32, writeable: false);
|
||||||
EmitSystemVariable(systemFolder, deviceHostAddress, "_DeviceError", DriverDataType.String);
|
EmitSystemVariable(systemFolder, deviceHostAddress, "_DeviceError", DriverDataType.String, writeable: false);
|
||||||
EmitSystemVariable(systemFolder, deviceHostAddress, "_LastScanTimeMs", DriverDataType.Float64);
|
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(
|
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}";
|
var fullName = $"{AbCipSystemTagSource.SystemFolderPrefix}{deviceHostAddress}/{name}";
|
||||||
systemFolder.Variable(name, name, new DriverAttributeInfo(
|
systemFolder.Variable(name, name, new DriverAttributeInfo(
|
||||||
@@ -1867,11 +1982,15 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
DriverDataType: type,
|
DriverDataType: type,
|
||||||
IsArray: false,
|
IsArray: false,
|
||||||
ArrayDim: null,
|
ArrayDim: null,
|
||||||
// Read-only for now — PR abcip-4.4 will flip _RefreshTagDb to Operate when the
|
// PR abcip-4.4 — _RefreshTagDb is the only writeable entry; everything else
|
||||||
// refresh trigger lands. Today the AbCip system folder has no writeable members.
|
// remains ViewOnly so subscribed clients can't accidentally write the
|
||||||
SecurityClass: SecurityClassification.ViewOnly,
|
// diagnostic surface from a misbehaving SCADA template.
|
||||||
|
SecurityClass: writeable ? SecurityClassification.Operate : SecurityClassification.ViewOnly,
|
||||||
IsHistorized: false,
|
IsHistorized: false,
|
||||||
IsAlarm: 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,
|
WriteIdempotent: false,
|
||||||
Description: name switch
|
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.",
|
"_TagCount" => "Count of discovered tags on this device, excluding _System.",
|
||||||
"_DeviceError" => "Most recent driver-error message; empty when the device is healthy.",
|
"_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.",
|
"_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,
|
_ => null,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
|||||||
/// <see cref="IHostConnectivityProbe"/> + <see cref="DriverHealth"/> surfaces.
|
/// <see cref="IHostConnectivityProbe"/> + <see cref="DriverHealth"/> surfaces.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>Design parity with Modbus' <c>ModbusSystemTags</c> — the same five canonical
|
/// <para>Design parity with Modbus' <c>ModbusSystemTags</c> — the same six canonical
|
||||||
/// names are exposed under each device's <c>_System</c> folder so the Admin UI / SCADA
|
/// names are exposed under each device's <c>_System</c> folder so the Admin UI / SCADA
|
||||||
/// clients can pivot from "is the wire up?" to "what's our scan rate / tag count?"
|
/// 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 <c>_RefreshTagDb</c>
|
/// without leaving the OPC UA address space. PR 4.4 turns <c>_RefreshTagDb</c> into a
|
||||||
/// into a writeable refresh trigger; everything 4.3 ships is read-only.</para>
|
/// writeable Kepware-style trigger — reads always return <c>false</c>, writes of any
|
||||||
|
/// truthy value dispatch to <see cref="AbCipDriver.RebrowseAsync"/>.</para>
|
||||||
/// <list type="bullet">
|
/// <list type="bullet">
|
||||||
/// <item><c>_ConnectionStatus</c> — string, mirrors the device's <see cref="HostState"/>.</item>
|
/// <item><c>_ConnectionStatus</c> — string, mirrors the device's <see cref="HostState"/>.</item>
|
||||||
/// <item><c>_ScanRate</c> — double, the configured probe interval in milliseconds
|
/// <item><c>_ScanRate</c> — double, the configured probe interval in milliseconds
|
||||||
@@ -24,10 +25,21 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
|||||||
/// <item><c>_DeviceError</c> — string, the most recent driver-error message or empty.</item>
|
/// <item><c>_DeviceError</c> — string, the most recent driver-error message or empty.</item>
|
||||||
/// <item><c>_LastScanTimeMs</c> — double, wall-clock ms of the last poll-loop
|
/// <item><c>_LastScanTimeMs</c> — double, wall-clock ms of the last poll-loop
|
||||||
/// iteration on this device.</item>
|
/// iteration on this device.</item>
|
||||||
|
/// <item><c>_RefreshTagDb</c> — boolean, writeable Kepware-style trigger. Reads
|
||||||
|
/// always return <c>false</c>; writing any truthy value (true / non-zero / "true"
|
||||||
|
/// / "1") forces a controller-side re-walk via
|
||||||
|
/// <see cref="AbCipDriver.RebrowseAsync"/>.</item>
|
||||||
/// </list>
|
/// </list>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class AbCipSystemTagSource
|
public sealed class AbCipSystemTagSource
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-4.4 — the writeable Kepware-style refresh-trigger system tag. Reads
|
||||||
|
/// always return <c>false</c>; writes of any truthy value cause the driver to
|
||||||
|
/// re-run discovery against the live controller symbol table.
|
||||||
|
/// </summary>
|
||||||
|
public const string RefreshTagDbName = "_RefreshTagDb";
|
||||||
|
|
||||||
/// <summary>Canonical names the system folder exposes — keep in lockstep with discovery.</summary>
|
/// <summary>Canonical names the system folder exposes — keep in lockstep with discovery.</summary>
|
||||||
public static readonly IReadOnlyList<string> SystemTagNames =
|
public static readonly IReadOnlyList<string> SystemTagNames =
|
||||||
[
|
[
|
||||||
@@ -36,6 +48,7 @@ public sealed class AbCipSystemTagSource
|
|||||||
"_TagCount",
|
"_TagCount",
|
||||||
"_DeviceError",
|
"_DeviceError",
|
||||||
"_LastScanTimeMs",
|
"_LastScanTimeMs",
|
||||||
|
RefreshTagDbName,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -48,8 +61,49 @@ public sealed class AbCipSystemTagSource
|
|||||||
|
|
||||||
private readonly Dictionary<string, SystemTagSnapshot> _snapshots =
|
private readonly Dictionary<string, SystemTagSnapshot> _snapshots =
|
||||||
new(StringComparer.OrdinalIgnoreCase);
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly Dictionary<string, long> _refreshTriggers =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private long _totalRefreshTriggers;
|
||||||
private readonly object _lock = new();
|
private readonly object _lock = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-4.4 — total <c>_RefreshTagDb</c> writes across every device managed by
|
||||||
|
/// the driver. Surfaced through <see cref="AbCipDriver.GetHealth"/> as the
|
||||||
|
/// <c>AbCip.RefreshTriggers</c> diagnostic counter.
|
||||||
|
/// </summary>
|
||||||
|
public long TotalRefreshTriggers => Interlocked.Read(ref _totalRefreshTriggers);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-4.4 — number of times <c>_RefreshTagDb</c> has been written for this
|
||||||
|
/// specific device. Returns <c>0</c> when the device has never seen a refresh
|
||||||
|
/// write (or isn't known to the source).
|
||||||
|
/// </summary>
|
||||||
|
public long GetRefreshTriggerCount(string deviceHostAddress)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(deviceHostAddress);
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
return _refreshTriggers.TryGetValue(deviceHostAddress, out var n) ? n : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-4.4 — bump the per-device + global refresh counters for a successful
|
||||||
|
/// <c>_RefreshTagDb</c> write. Called from <see cref="AbCipDriver.WriteAsync"/>
|
||||||
|
/// after the rebrowse dispatch lands so a failed dispatch doesn't pollute the
|
||||||
|
/// counter.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Replace the snapshot for one device. Called on every health transition + every
|
/// Replace the snapshot for one device. Called on every health transition + every
|
||||||
/// successful read iteration so the surfaced values track the live driver loop
|
/// successful read iteration so the surfaced values track the live driver loop
|
||||||
@@ -112,6 +166,15 @@ public sealed class AbCipSystemTagSource
|
|||||||
return false;
|
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);
|
var snapshot = TryGet(deviceHostAddress);
|
||||||
if (snapshot is null)
|
if (snapshot is null)
|
||||||
{
|
{
|
||||||
@@ -139,6 +202,51 @@ public sealed class AbCipSystemTagSource
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-4.4 — recognise <c>_RefreshTagDb</c> writes. Accepts both bare
|
||||||
|
/// (<c>_RefreshTagDb</c>) + prefixed (<c>_System/_RefreshTagDb</c>) shapes so
|
||||||
|
/// callers can pass whichever form the address came in as.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-4.4 — Kepware-style truthy coercion for the <c>_RefreshTagDb</c> trigger.
|
||||||
|
/// Mirrors the same wire-format the OPC UA stack delivers: booleans pass through;
|
||||||
|
/// integers / doubles trigger when non-zero; strings parse as <c>"true"</c> /
|
||||||
|
/// <c>"1"</c> (case-insensitive). Anything else (null, empty, an unparseable string)
|
||||||
|
/// is treated as <c>false</c> + the write becomes a no-op.
|
||||||
|
/// </summary>
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// <c>true</c> when <paramref name="reference"/> targets a node under the synthetic
|
/// <c>true</c> when <paramref name="reference"/> targets a node under the synthetic
|
||||||
/// <c>_System/</c> folder. The driver's read path uses this to bypass the libplctag
|
/// <c>_System/</c> folder. The driver's read path uses this to bypass the libplctag
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-4.4 — end-to-end coverage that writing <c>_RefreshTagDb</c> on the
|
||||||
|
/// synthetic system folder dispatches to <see cref="AbCipDriver.RebrowseAsync"/>
|
||||||
|
/// against a live <c>ab_server</c>. Mirrors <see cref="AbCipSystemTagDiscoveryTests"/>
|
||||||
|
/// but exercises the write entry point so the same outcome (template cache cleared,
|
||||||
|
/// enumerator re-walked) is observable through the OPC UA write surface.
|
||||||
|
/// </summary>
|
||||||
|
[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) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-4.4 — coverage for the writeable <c>_RefreshTagDb</c> system tag. Discovery
|
||||||
|
/// emits the entry as Operate, reads always return <c>false</c>, and writes of any
|
||||||
|
/// truthy value dispatch to <see cref="AbCipDriver.RebrowseAsync"/> while bumping the
|
||||||
|
/// <c>AbCip.RefreshTriggers</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
[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<AbCipDiscoveredTag> EnumerateAsync(
|
||||||
|
AbCipTagCreateParams deviceParams,
|
||||||
|
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
outer.EnumerationCount++;
|
||||||
|
await Task.CompletedTask;
|
||||||
|
foreach (var t in outer._tags) yield return t;
|
||||||
|
}
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -104,7 +104,7 @@ public sealed class AbCipSystemTagSourceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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 builder = new RecordingBuilder();
|
||||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||||
@@ -122,19 +122,26 @@ public sealed class AbCipSystemTagSourceTests
|
|||||||
.Select(v => v.BrowseName)
|
.Select(v => v.BrowseName)
|
||||||
.OrderBy(s => s)
|
.OrderBy(s => s)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
// PR abcip-4.4 — _RefreshTagDb joins the original five.
|
||||||
systemVars.ShouldBe(new[]
|
systemVars.ShouldBe(new[]
|
||||||
{
|
{
|
||||||
"_ConnectionStatus", "_DeviceError", "_LastScanTimeMs",
|
"_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
|
builder.Variables
|
||||||
.Where(v => v.Info.FullName.StartsWith("_System/"))
|
.Where(v => v.Info.FullName.StartsWith("_System/"))
|
||||||
.ShouldAllBe(v => v.Info.FullName.StartsWith("_System/ab://10.0.0.5/1,0/"));
|
.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
|
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);
|
.ShouldAllBe(v => v.Info.SecurityClass == SecurityClassification.ViewOnly);
|
||||||
|
builder.Variables
|
||||||
|
.Single(v => v.Info.FullName.EndsWith("/_RefreshTagDb"))
|
||||||
|
.Info.SecurityClass.ShouldBe(SecurityClassification.Operate);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -155,7 +162,8 @@ public sealed class AbCipSystemTagSourceTests
|
|||||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||||
|
|
||||||
builder.Folders.Count(f => f.BrowseName == "_System").ShouldBe(2);
|
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
|
builder.Variables
|
||||||
.Where(v => v.Info.FullName.StartsWith("_System/"))
|
.Where(v => v.Info.FullName.StartsWith("_System/"))
|
||||||
.Select(v => v.Info.FullName)
|
.Select(v => v.Info.FullName)
|
||||||
|
|||||||
Reference in New Issue
Block a user