mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-25 20:00:31 +08:00
feat(autoid-client): read a device's status without browsing
ReadDeviceAsync browses the children of the device on every call, so polling the status of many readers costs a Browse and a Read per reader and cycle; AutoIdReadOptions.StatusOnly only narrows the Read. Add AutoIdClient.RefreshDeviceAsync(snapshot), which reads DeviceStatus and, when published, LastScanData, LastScanTimestamp and DeviceLocation again through the NodeIds the snapshot carries: one Read, no Browse, no client-side state. A stale snapshot surfaces the status of the Read: a bad DeviceStatus throws, a bad LastScanData is returned in LastScanStatus; the caller then reads the device again. Document the network cost of both calls in the XML docs, docs/AutoID.md and the client NugetREADME, and add strict-mock tests (no Browse issued) and end-to-end tests against the hosted AutoID server.
This commit is contained in:
committed by
Klaus Landsdorf (CEO)
parent
ac24123dde
commit
2118c24391
+27
-4
@@ -402,10 +402,22 @@ reader types. A browse the server rejects throws `ServiceResultException` rather
|
||||
than yielding an empty list, a rejected method call throws with the server's
|
||||
status, and every asynchronous member honours cancellation. The status a device
|
||||
reports for a tag command is returned as data. `AutoIdReadOptions` narrows
|
||||
`ReadDeviceAsync`: `Default` reads everything the device publishes, `StatusOnly`
|
||||
reads two variables. Whatever the options, the snapshot carries the NodeIds of
|
||||
`DeviceStatus`, `LastScanData`, `LastScanTimestamp` and `DeviceLocation` and the
|
||||
browse names of the methods the device publishes, taken from the same browse:
|
||||
the Read of `ReadDeviceAsync`: `Default` reads everything the device publishes,
|
||||
`StatusOnly` reads `DeviceStatus` and `DeviceName`. Every `ReadDeviceAsync` call
|
||||
costs one Browse of the children of the device (and BrowseNext only when the
|
||||
server pages the result) and one Read, whatever the options. The snapshot
|
||||
carries the NodeIds of `DeviceStatus`, `LastScanData`, `LastScanTimestamp` and
|
||||
`DeviceLocation` and the browse names of the methods the device publishes, taken
|
||||
from that browse. `RefreshDeviceAsync` takes such a snapshot and reads
|
||||
`DeviceStatus`, `LastScanData`, `LastScanTimestamp` and `DeviceLocation` again
|
||||
through these NodeIds: one Read of up to four variables and no Browse. The client
|
||||
keeps no state, so a poller reads once and then refreshes the snapshot:
|
||||
|
||||
| Call | Network cost |
|
||||
| --- | --- |
|
||||
| `ReadDeviceAsync(reader)` | One Browse, one Read of every published variable |
|
||||
| `ReadDeviceAsync(reader, AutoIdReadOptions.StatusOnly)` | One Browse, one Read of two variables |
|
||||
| `RefreshDeviceAsync(snapshot)` | One Read of up to four variables, no Browse |
|
||||
|
||||
```csharp
|
||||
AutoIdDeviceSnapshot device = await autoId.ReadDeviceAsync(reader, AutoIdReadOptions.StatusOnly);
|
||||
@@ -413,8 +425,19 @@ if (device.HasMethod(BrowseNames.ScanStart))
|
||||
{
|
||||
// monitor device.DeviceStatusId and device.LastScanDataId, then start the scan
|
||||
}
|
||||
device = await autoId.RefreshDeviceAsync(device); // poll without browsing
|
||||
```
|
||||
|
||||
`RefreshDeviceAsync` copies every other member of the snapshot, including
|
||||
`Methods`, and does not check the snapshot against the server. When the snapshot
|
||||
no longer matches the device (the device or one of its variables was deleted, or
|
||||
the server assigned other NodeIds after a restart), the status of the Read
|
||||
surfaces: a bad status for `DeviceStatus`, for example `BadNodeIdUnknown`, is
|
||||
thrown as `ServiceResultException`, and a bad status for `LastScanData` is
|
||||
returned in `LastScanStatus` with a null value. The caller then reads the device
|
||||
again with `ReadDeviceAsync`, which also picks up optional variables and methods
|
||||
published after the snapshot was taken.
|
||||
|
||||
`Observe*Async` methods (scan, access, presence and log entry events) take a
|
||||
streaming subscription the caller owns; the monitored item lives as long as the
|
||||
enumeration.
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
* http://opcfoundation.org/License/MIT/1.00/
|
||||
* ======================================================================*/
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -39,10 +40,14 @@ namespace Opc.Ua.AutoID.Client
|
||||
/// Reads the state of a reader device in one browse and one read.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The browse also yields the NodeIds of the variables to monitor and the methods the device
|
||||
/// publishes. The optional variables a device does not publish are left at their defaults in the
|
||||
/// snapshot; a bad status for a variable the device does publish is thrown as a
|
||||
/// <see cref="ServiceResultException"/>. <see cref="AutoIdReadOptions"/> narrows the read.
|
||||
/// Every call costs one Browse of the children of the device (and BrowseNext only when the
|
||||
/// server pages the result) and one Read, whatever the options; <see cref="AutoIdReadOptions"/>
|
||||
/// only narrows the variables of the Read. The browse
|
||||
/// yields the NodeIds of the variables to monitor and the methods the device publishes. The
|
||||
/// optional variables a device does not publish are left at their defaults in the snapshot; a
|
||||
/// bad status for a variable the device does publish is thrown as a
|
||||
/// <see cref="ServiceResultException"/>. To poll without browsing, pass the snapshot to
|
||||
/// <see cref="RefreshDeviceAsync"/>.
|
||||
/// </remarks>
|
||||
/// <param name="reader">The reader.</param>
|
||||
/// <param name="options">What to read; <see cref="AutoIdReadOptions.Default"/> when <see langword="null"/>.</param>
|
||||
@@ -204,6 +209,128 @@ namespace Opc.Ua.AutoID.Client
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the variables of a snapshot that change at runtime again, through the NodeIds the
|
||||
/// snapshot carries: <c>DeviceStatus</c> and, when the device publishes them,
|
||||
/// <c>LastScanData</c>, <c>LastScanTimestamp</c> and <c>DeviceLocation</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The call costs exactly one Read of one to four variables and no Browse. The client keeps no
|
||||
/// state: a caller that polls a device reads it once with <see cref="ReadDeviceAsync"/> and
|
||||
/// then passes the returned snapshot here on every cycle. All other members of the snapshot,
|
||||
/// including <see cref="AutoIdDeviceSnapshot.Methods"/> and the NodeIds, are copied unchanged.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The snapshot is not validated against the server. When it no longer matches the device
|
||||
/// (the device or one of its variables was deleted, or the server assigned other NodeIds after
|
||||
/// a restart), the status of the Read surfaces: a bad status for <c>DeviceStatus</c>, for
|
||||
/// example <see cref="StatusCodes.BadNodeIdUnknown"/>, is thrown as a
|
||||
/// <see cref="ServiceResultException"/>, and a bad status for <c>LastScanData</c> is returned
|
||||
/// in <see cref="AutoIdDeviceSnapshot.LastScanStatus"/> with a null value. A bad
|
||||
/// <c>LastScanTimestamp</c> or <c>DeviceLocation</c> leaves that member at its default. In
|
||||
/// each of these cases the caller reads the device again with <see cref="ReadDeviceAsync"/>,
|
||||
/// which also picks up optional variables and methods the device published after the
|
||||
/// snapshot was taken.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="device">A snapshot returned by <see cref="ReadDeviceAsync"/>.</param>
|
||||
/// <param name="cancellationToken">Cancels the operation.</param>
|
||||
/// <returns>A copy of <paramref name="device"/> with the current values.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="device"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="ArgumentException">The snapshot carries no <c>DeviceStatus</c> NodeId.</exception>
|
||||
/// <exception cref="ServiceResultException">
|
||||
/// The server rejected the Read, or reading <c>DeviceStatus</c> returned a bad status.
|
||||
/// </exception>
|
||||
public async ValueTask<AutoIdDeviceSnapshot> RefreshDeviceAsync(
|
||||
AutoIdDeviceSnapshot device,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (device is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(device));
|
||||
}
|
||||
if (device.DeviceStatusId.IsNull)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"The snapshot carries no DeviceStatus NodeId; read the device with ReadDeviceAsync.",
|
||||
nameof(device));
|
||||
}
|
||||
NodeId[] ids =
|
||||
[
|
||||
device.DeviceStatusId,
|
||||
device.LastScanDataId,
|
||||
device.LastScanTimestampId,
|
||||
device.DeviceLocationId
|
||||
];
|
||||
var reads = new List<ReadValueId>(ids.Length);
|
||||
int[] slots = new int[ids.Length];
|
||||
for (int index = 0; index < ids.Length; index++)
|
||||
{
|
||||
slots[index] = -1;
|
||||
if (!ids[index].IsNull)
|
||||
{
|
||||
slots[index] = reads.Count;
|
||||
reads.Add(new ReadValueId { NodeId = ids[index], AttributeId = Attributes.Value });
|
||||
}
|
||||
}
|
||||
ReadResponse response = await Session.ReadAsync(
|
||||
requestHeader: null,
|
||||
maxAge: 0,
|
||||
timestampsToReturn: TimestampsToReturn.Neither,
|
||||
nodesToRead: reads.ToArray().ToArrayOf(),
|
||||
ct: cancellationToken).ConfigureAwait(false);
|
||||
if (response.Results.Count != reads.Count)
|
||||
{
|
||||
throw ServiceResultException.Create(
|
||||
StatusCodes.BadUnexpectedError,
|
||||
"The server returned {0} read results for {1} requested variables.",
|
||||
response.Results.Count,
|
||||
reads.Count);
|
||||
}
|
||||
DataValue Value(int index)
|
||||
{
|
||||
return slots[index] < 0
|
||||
? new DataValue(Variant.Null, StatusCodes.BadNotFound)
|
||||
: response.Results[slots[index]];
|
||||
}
|
||||
|
||||
DataValue status = Value(0);
|
||||
if (StatusCode.IsBad(status.StatusCode))
|
||||
{
|
||||
throw ServiceResultException.Create(
|
||||
status.StatusCode,
|
||||
"Reading {0} of {1} failed.",
|
||||
BrowseNames.DeviceStatus,
|
||||
device.NodeId);
|
||||
}
|
||||
DataValue lastData = Value(1);
|
||||
DataValue lastTimestamp = Value(2);
|
||||
DataValue deviceLocation = Value(3);
|
||||
Location? locationValue = null;
|
||||
#pragma warning disable CS8600 // The generic structure accessor declares its out value as possibly null.
|
||||
if (StatusCode.IsNotBad(deviceLocation.StatusCode) &&
|
||||
deviceLocation.WrappedValue.TryGetValue(out Location decodedLocation, Session.MessageContext) &&
|
||||
decodedLocation is not null)
|
||||
#pragma warning restore CS8600
|
||||
{
|
||||
locationValue = decodedLocation;
|
||||
}
|
||||
return device with
|
||||
{
|
||||
DeviceStatus = status.WrappedValue.TryGetValue(out DeviceStatusEnumeration deviceStatus)
|
||||
? deviceStatus
|
||||
: default,
|
||||
LastScanData = StatusCode.IsBad(lastData.StatusCode) ? Variant.Null : lastData.WrappedValue,
|
||||
LastScanStatus = lastData.StatusCode,
|
||||
LastScanTimestamp = StatusCode.IsNotBad(lastTimestamp.StatusCode) &&
|
||||
lastTimestamp.WrappedValue.TryGetValue(out DateTimeUtc timestamp)
|
||||
? timestamp
|
||||
: default,
|
||||
DeviceLocation = locationValue
|
||||
};
|
||||
}
|
||||
|
||||
private static string TextOf(DataValue value)
|
||||
{
|
||||
return value.WrappedValue.TryGetValue(out string text) ? text ?? string.Empty : string.Empty;
|
||||
|
||||
@@ -41,7 +41,8 @@ namespace Opc.Ua.AutoID.Client
|
||||
/// The NodeIds of the variables that change at runtime and the methods the device publishes
|
||||
/// come from the browse that precedes the read, whatever the options, so a client can monitor
|
||||
/// the device and knows which optional methods (OPC 30010 6.1, 6.5.3, 6.6.3) it may call
|
||||
/// without browsing again.
|
||||
/// without browsing again. <see cref="AutoIdClient.RefreshDeviceAsync"/> reads these
|
||||
/// variables again through the NodeIds, in one Read and without a Browse.
|
||||
/// </remarks>
|
||||
public sealed record AutoIdDeviceSnapshot
|
||||
{
|
||||
|
||||
@@ -36,7 +36,8 @@ namespace Opc.Ua.AutoID.Client
|
||||
/// The defaults read the mandatory variables and everything optional the device publishes: the
|
||||
/// nameplate, the optional device properties and the last scan. <see cref="StatusOnly"/>
|
||||
/// limits the value read to <c>DeviceStatus</c> and <c>DeviceName</c>; each call still
|
||||
/// browses the reader to discover its published variables and methods.
|
||||
/// browses the reader to discover its published variables and methods. To poll a device
|
||||
/// without browsing, pass the snapshot to <see cref="AutoIdClient.RefreshDeviceAsync"/>.
|
||||
/// </remarks>
|
||||
public sealed record AutoIdReadOptions
|
||||
{
|
||||
|
||||
@@ -29,9 +29,11 @@ await foreach (AutoIdScanEventTypeRecord e in
|
||||
`LockTagAsync`, `SetTagPasswordAsync`; the status a device reports for a tag is
|
||||
returned, not thrown.
|
||||
- RTLS `GetLocationAsync` and `GetSupportedLocationTypesAsync`.
|
||||
- `ReadDeviceAsync` with `AutoIdReadOptions` (`Default`, `StatusOnly`); the
|
||||
snapshot also carries the NodeIds of the variables to monitor and the methods
|
||||
the device publishes.
|
||||
- `ReadDeviceAsync` with `AutoIdReadOptions` (`Default`, `StatusOnly`), one
|
||||
Browse and one Read per call; the snapshot also carries the NodeIds of the
|
||||
variables to monitor and the methods the device publishes.
|
||||
`RefreshDeviceAsync` re-reads the status, last scan and location of a snapshot
|
||||
in one Read without browsing.
|
||||
- Asynchronous streams of scan, access, presence and logbook entry events.
|
||||
|
||||
A browse the server rejects throws `ServiceResultException` instead of yielding an
|
||||
|
||||
@@ -711,6 +711,61 @@ namespace Opc.Ua.AutoID.Tests
|
||||
await WaitForStatusAsync(AutoIdHostFixture.OpticalName, DeviceStatusEnumeration.Idle).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ARefreshedSnapshotCarriesTheCurrentStatusAsync()
|
||||
{
|
||||
AutoIdReaderEntry reader = await ReaderAsync(AutoIdHostFixture.OpticalName).ConfigureAwait(false);
|
||||
AutoIdReader hosted = m_host!.Runtime.Readers.ToArray()!
|
||||
.Single(r => r.Name.Name == AutoIdHostFixture.OpticalName);
|
||||
AutoIdDeviceSnapshot idle = await m_client!.ReadDeviceAsync(reader, AutoIdReadOptions.StatusOnly)
|
||||
.ConfigureAwait(false);
|
||||
await hosted.SetFaultedAsync(true).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
AutoIdDeviceSnapshot faulty = await m_client.RefreshDeviceAsync(idle).ConfigureAwait(false);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(idle.DeviceStatus, Is.EqualTo(DeviceStatusEnumeration.Idle));
|
||||
Assert.That(faulty.DeviceStatus, Is.EqualTo(DeviceStatusEnumeration.Error));
|
||||
Assert.That(faulty.LastScanStatus, Is.EqualTo(StatusCodes.UncertainInitialValue));
|
||||
Assert.That(faulty.Methods, Is.EqualTo(idle.Methods));
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
await hosted.SetFaultedAsync(false).ConfigureAwait(false);
|
||||
}
|
||||
await WaitForStatusAsync(AutoIdHostFixture.OpticalName, DeviceStatusEnumeration.Idle).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ARefreshOfAStaleSnapshotSurfacesTheStatusOfTheReadAsync()
|
||||
{
|
||||
AutoIdReaderEntry reader = await ReaderAsync(AutoIdHostFixture.RfidName).ConfigureAwait(false);
|
||||
AutoIdDeviceSnapshot current = await m_client!.ReadDeviceAsync(reader, AutoIdReadOptions.StatusOnly)
|
||||
.ConfigureAwait(false);
|
||||
Assert.That(current.LastScanDataId.IsNull, Is.False);
|
||||
// NodeIds the server does not know, as after the variables were deleted.
|
||||
var unknown = new NodeId("NoSuchVariable", current.DeviceStatusId.NamespaceIndex);
|
||||
|
||||
AutoIdDeviceSnapshot staleScan = await m_client
|
||||
.RefreshDeviceAsync(current with { LastScanDataId = unknown })
|
||||
.ConfigureAwait(false);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(staleScan.DeviceStatus, Is.EqualTo(current.DeviceStatus));
|
||||
Assert.That(staleScan.LastScanStatus, Is.EqualTo(StatusCodes.BadNodeIdUnknown));
|
||||
Assert.That(staleScan.LastScanData.IsNull, Is.True);
|
||||
});
|
||||
await Assert.ThatAsync(
|
||||
async () => await m_client.RefreshDeviceAsync(current with { DeviceStatusId = unknown })
|
||||
.ConfigureAwait(false),
|
||||
Throws.TypeOf<ServiceResultException>().With.Property("StatusCode")
|
||||
.EqualTo(StatusCodes.BadNodeIdUnknown)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task TheServerPublishesTheFacetsOfTheReadersItHostsAsync()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
/* ========================================================================
|
||||
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
|
||||
*
|
||||
* OPC Foundation MIT License 1.00
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person
|
||||
* obtaining a copy of this software and associated documentation
|
||||
* files (the "Software"), to deal in the Software without
|
||||
* restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the
|
||||
* Software is furnished to do so, subject to the following
|
||||
* conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be
|
||||
* included in all copies or substantial portions of the Software.
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
||||
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
* OTHER DEALINGS IN THE SOFTWARE.
|
||||
*
|
||||
* The complete license agreement can be found here:
|
||||
* http://opcfoundation.org/License/MIT/1.00/
|
||||
* ======================================================================*/
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using Opc.Ua.AutoID.Client;
|
||||
using Opc.Ua.Client;
|
||||
using Opc.Ua.Tests;
|
||||
|
||||
namespace Opc.Ua.AutoID.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Pins the service calls of <see cref="AutoIdClient.RefreshDeviceAsync"/> on a strict session
|
||||
/// mock: one Read of the NodeIds the snapshot carries and no Browse.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
[Category("AutoID")]
|
||||
public sealed class AutoIdRefreshDeviceTests
|
||||
{
|
||||
[Test]
|
||||
public async Task OneReadOfTheSnapshotNodeIdsAndNoBrowseAsync()
|
||||
{
|
||||
(Mock<ISession> session, AutoIdClient client) = Create();
|
||||
var timestamp = new DateTimeUtc(new DateTime(2026, 10, 19, 8, 0, 0, DateTimeKind.Utc));
|
||||
var requested = new List<NodeId>();
|
||||
SetupRead(
|
||||
session,
|
||||
requested,
|
||||
new DataValue(new Variant((int)DeviceStatusEnumeration.Scanning)),
|
||||
new DataValue(new Variant("tag-1")),
|
||||
new DataValue(new Variant(timestamp)),
|
||||
new DataValue(Variant.Null));
|
||||
AutoIdDeviceSnapshot snapshot = Snapshot(withOptionalVariables: true);
|
||||
|
||||
AutoIdDeviceSnapshot refreshed = await client.RefreshDeviceAsync(snapshot).ConfigureAwait(false);
|
||||
|
||||
NodeId[] expected = [s_status, s_lastData, s_lastTimestamp, s_location];
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(requested, Is.EqualTo(expected));
|
||||
Assert.That(refreshed.DeviceStatus, Is.EqualTo(DeviceStatusEnumeration.Scanning));
|
||||
Assert.That(refreshed.LastScanData.TryGetValue(out string text) ? text : null, Is.EqualTo("tag-1"));
|
||||
Assert.That(refreshed.LastScanStatus, Is.EqualTo(StatusCodes.Good));
|
||||
Assert.That(refreshed.LastScanTimestamp, Is.EqualTo(timestamp));
|
||||
Assert.That(refreshed.DeviceLocation, Is.Null);
|
||||
Assert.That(refreshed.DeviceName, Is.EqualTo(snapshot.DeviceName));
|
||||
Assert.That(refreshed.Methods, Is.EqualTo(snapshot.Methods));
|
||||
Assert.That(refreshed.LastScanDataId, Is.EqualTo(s_lastData));
|
||||
Assert.That(
|
||||
snapshot.DeviceStatus,
|
||||
Is.EqualTo(DeviceStatusEnumeration.Idle),
|
||||
"the input is not changed");
|
||||
});
|
||||
VerifyOneReadOnly(session);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ADeviceWithoutOptionalVariablesIsRefreshedWithOneVariableAsync()
|
||||
{
|
||||
(Mock<ISession> session, AutoIdClient client) = Create();
|
||||
var requested = new List<NodeId>();
|
||||
SetupRead(session, requested, new DataValue(new Variant((int)DeviceStatusEnumeration.Error)));
|
||||
|
||||
AutoIdDeviceSnapshot refreshed = await client.RefreshDeviceAsync(Snapshot(withOptionalVariables: false))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(requested, Has.Count.EqualTo(1).And.Contains(s_status));
|
||||
Assert.That(refreshed.DeviceStatus, Is.EqualTo(DeviceStatusEnumeration.Error));
|
||||
Assert.That(refreshed.LastScanStatus, Is.EqualTo(StatusCodes.BadNotFound));
|
||||
Assert.That(refreshed.LastScanData.IsNull, Is.True);
|
||||
});
|
||||
VerifyOneReadOnly(session);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AStaleDeviceStatusNodeIdThrowsTheStatusOfTheReadAsync()
|
||||
{
|
||||
(Mock<ISession> session, AutoIdClient client) = Create();
|
||||
SetupRead(
|
||||
session,
|
||||
[],
|
||||
DataValue.FromStatusCode(StatusCodes.BadNodeIdUnknown),
|
||||
DataValue.FromStatusCode(StatusCodes.BadNodeIdUnknown),
|
||||
DataValue.FromStatusCode(StatusCodes.BadNodeIdUnknown),
|
||||
DataValue.FromStatusCode(StatusCodes.BadNodeIdUnknown));
|
||||
|
||||
await Assert.ThatAsync(
|
||||
async () => await client.RefreshDeviceAsync(Snapshot(withOptionalVariables: true))
|
||||
.ConfigureAwait(false),
|
||||
Throws.TypeOf<ServiceResultException>().With.Property("StatusCode")
|
||||
.EqualTo(StatusCodes.BadNodeIdUnknown)).ConfigureAwait(false);
|
||||
VerifyOneReadOnly(session);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AStaleOptionalVariableSurfacesItsStatusWithoutThrowingAsync()
|
||||
{
|
||||
(Mock<ISession> session, AutoIdClient client) = Create();
|
||||
SetupRead(
|
||||
session,
|
||||
[],
|
||||
new DataValue(new Variant((int)DeviceStatusEnumeration.Idle)),
|
||||
DataValue.FromStatusCode(StatusCodes.BadNodeIdUnknown),
|
||||
DataValue.FromStatusCode(StatusCodes.BadNodeIdUnknown),
|
||||
DataValue.FromStatusCode(StatusCodes.BadUserAccessDenied));
|
||||
AutoIdDeviceSnapshot snapshot = Snapshot(withOptionalVariables: true) with
|
||||
{
|
||||
LastScanData = new Variant("old"),
|
||||
LastScanTimestamp = new DateTimeUtc(new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)),
|
||||
DeviceLocation = new Location()
|
||||
};
|
||||
|
||||
AutoIdDeviceSnapshot refreshed = await client.RefreshDeviceAsync(snapshot).ConfigureAwait(false);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(refreshed.DeviceStatus, Is.EqualTo(DeviceStatusEnumeration.Idle));
|
||||
Assert.That(refreshed.LastScanStatus, Is.EqualTo(StatusCodes.BadNodeIdUnknown));
|
||||
Assert.That(refreshed.LastScanData.IsNull, Is.True);
|
||||
Assert.That(refreshed.LastScanTimestamp, Is.Default);
|
||||
Assert.That(refreshed.DeviceLocation, Is.Null);
|
||||
});
|
||||
VerifyOneReadOnly(session);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AResultCountMismatchIsRejectedAsync()
|
||||
{
|
||||
(Mock<ISession> session, AutoIdClient client) = Create();
|
||||
SetupRead(session, [], new DataValue(new Variant((int)DeviceStatusEnumeration.Idle)));
|
||||
|
||||
await Assert.ThatAsync(
|
||||
async () => await client.RefreshDeviceAsync(Snapshot(withOptionalVariables: true))
|
||||
.ConfigureAwait(false),
|
||||
Throws.TypeOf<ServiceResultException>().With.Property("StatusCode")
|
||||
.EqualTo(StatusCodes.BadUnexpectedError)).ConfigureAwait(false);
|
||||
VerifyOneReadOnly(session);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task InvalidSnapshotsAreRejectedWithoutAServiceCallAsync()
|
||||
{
|
||||
(Mock<ISession> session, AutoIdClient client) = Create();
|
||||
|
||||
await Assert.MultipleAsync(async () =>
|
||||
{
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(
|
||||
async () => await client.RefreshDeviceAsync(null!).ConfigureAwait(false)).ConfigureAwait(false);
|
||||
await Assert.ThrowsAsync<ArgumentException>(
|
||||
async () => await client.RefreshDeviceAsync(new AutoIdDeviceSnapshot { NodeId = s_device })
|
||||
.ConfigureAwait(false)).ConfigureAwait(false);
|
||||
}).ConfigureAwait(false);
|
||||
session.VerifyGet(value => value.MessageContext, Times.Once);
|
||||
session.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CancellationReachesTheReadAsync()
|
||||
{
|
||||
(Mock<ISession> session, AutoIdClient client) = Create();
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
CancellationToken observed = default;
|
||||
session.Setup(
|
||||
value => value.ReadAsync(
|
||||
It.IsAny<RequestHeader?>(),
|
||||
It.IsAny<double>(),
|
||||
It.IsAny<TimestampsToReturn>(),
|
||||
It.IsAny<ArrayOf<ReadValueId>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(
|
||||
(RequestHeader? _,
|
||||
double _,
|
||||
TimestampsToReturn _,
|
||||
ArrayOf<ReadValueId> _,
|
||||
CancellationToken token) =>
|
||||
{
|
||||
observed = token;
|
||||
return new ValueTask<ReadResponse>(
|
||||
Task.FromCanceled<ReadResponse>(new CancellationToken(true)));
|
||||
});
|
||||
await cancellation.CancelAsync().ConfigureAwait(false);
|
||||
|
||||
await Assert.ThatAsync(
|
||||
async () => await client.RefreshDeviceAsync(Snapshot(withOptionalVariables: false), cancellation.Token)
|
||||
.ConfigureAwait(false),
|
||||
Throws.InstanceOf<OperationCanceledException>()).ConfigureAwait(false);
|
||||
Assert.That(observed, Is.EqualTo(cancellation.Token));
|
||||
}
|
||||
|
||||
private static (Mock<ISession> Session, AutoIdClient Client) Create()
|
||||
{
|
||||
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
||||
var context = ServiceMessageContext.Create(telemetry);
|
||||
var session = new Mock<ISession>(MockBehavior.Strict);
|
||||
session.SetupGet(value => value.MessageContext).Returns(context);
|
||||
return (session, new AutoIdClient(session.Object, telemetry));
|
||||
}
|
||||
|
||||
private static AutoIdDeviceSnapshot Snapshot(bool withOptionalVariables)
|
||||
{
|
||||
return new AutoIdDeviceSnapshot
|
||||
{
|
||||
NodeId = s_device,
|
||||
DeviceStatusId = s_status,
|
||||
LastScanDataId = withOptionalVariables ? s_lastData : NodeId.Null,
|
||||
LastScanTimestampId = withOptionalVariables ? s_lastTimestamp : NodeId.Null,
|
||||
DeviceLocationId = withOptionalVariables ? s_location : NodeId.Null,
|
||||
Methods = [new QualifiedName(BrowseNames.Scan, 2)],
|
||||
DeviceName = "Reader",
|
||||
DeviceStatus = DeviceStatusEnumeration.Idle,
|
||||
LastScanStatus = withOptionalVariables ? StatusCodes.UncertainInitialValue : StatusCodes.BadNotFound
|
||||
};
|
||||
}
|
||||
|
||||
private static void SetupRead(Mock<ISession> session, List<NodeId> requested, params DataValue[] results)
|
||||
{
|
||||
session.Setup(
|
||||
value => value.ReadAsync(
|
||||
It.IsAny<RequestHeader?>(),
|
||||
0,
|
||||
TimestampsToReturn.Neither,
|
||||
It.IsAny<ArrayOf<ReadValueId>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(
|
||||
(RequestHeader? _,
|
||||
double _,
|
||||
TimestampsToReturn _,
|
||||
ArrayOf<ReadValueId> nodes,
|
||||
CancellationToken _) =>
|
||||
{
|
||||
foreach (ReadValueId node in nodes)
|
||||
{
|
||||
Assert.That(node.AttributeId, Is.EqualTo(Attributes.Value));
|
||||
requested.Add(node.NodeId);
|
||||
}
|
||||
return new ValueTask<ReadResponse>(
|
||||
new ReadResponse { ResponseHeader = new ResponseHeader(), Results = results.ToArrayOf() });
|
||||
});
|
||||
}
|
||||
|
||||
private static void VerifyOneReadOnly(Mock<ISession> session)
|
||||
{
|
||||
session.Verify(
|
||||
value => value.ReadAsync(
|
||||
It.IsAny<RequestHeader?>(),
|
||||
It.IsAny<double>(),
|
||||
It.IsAny<TimestampsToReturn>(),
|
||||
It.IsAny<ArrayOf<ReadValueId>>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
session.VerifyGet(value => value.MessageContext, Times.AtLeastOnce);
|
||||
// A strict mock already fails any Browse; this also rules out every other service call.
|
||||
session.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
private static readonly NodeId s_device = new("Reader", 2);
|
||||
private static readonly NodeId s_status = new("Reader.DeviceStatus", 2);
|
||||
private static readonly NodeId s_lastData = new("Reader.LastScanData", 2);
|
||||
private static readonly NodeId s_lastTimestamp = new("Reader.LastScanTimestamp", 2);
|
||||
private static readonly NodeId s_location = new("Reader.DeviceLocation", 2);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user