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