Files
UA-.NETStandard/tests/Opc.Ua.Robotics.Tests/RoboticsClientFacadeTests.cs
Klaus Landsdorf dbee7b9988 Robotics client: fill the engineering units and ranges of the axis values and the load mass, and the gear ratio (#4661)
# Description

Follow-up to #4619. It was prepared stacked on #4619 and is now one
commit on top of master, with #4619's review changes in place.

`AxisSnapshot.Engineering`, `LoadSnapshot.MassEngineering` and
`GearSnapshot.GearRatio` are part of the snapshot contracts and
documented in `docs/Robotics.md` (the URML capability manifest takes the
joint limits from them), but `RoboticsClient` never set them: every axis
came back without units or limits, every load without the unit of its
mass and every gear without its ratio.

OPC 40010-1 declares `ActualPosition` (mandatory), `ActualSpeed` and
`ActualAcceleration` (optional) below the axis's DI `ParameterSet`, and
the load's `Mass`, as `AnalogUnitType` variables with a mandatory
`EngineeringUnits` and an optional `EURange` property, and `GearRatio`
as a mandatory `RationalNumber` variable of the gear. The client now
reads them:

- **Axis:** `ReadAxisAsync`, and with it `ReadSystemAsync`, resolves the
`EngineeringUnits` and `EURange` of the three axis values with one
TranslateBrowsePathsToNodeIds and reads them with one Read per axis
(none when nothing resolves) into `PositionUnit`, `SpeedUnit`,
`AccelerationUnit` and `Limits.Position/Speed/Acceleration`.
- **Load:** `Mass` is resolved and read together with its
`EngineeringUnits` and `EURange` (`MassEngineering`), replacing the
separate resolve and read.
- **Gear:** `GearRatio` is read in the same request as `Pitch`.
- Loads and gears cost no extra request.
- A property the server does not publish, whose read fails or whose
value has a bad status stays `null`. The structures go through the
existing `ToStructure<T>`, which checks `StatusCode.IsGood` as for
`MotorTemperatureEngineering`. The rest of the snapshot is still
returned.
- Because `Mass` and `Pitch` are now part of that request, a failed
`Mass` or `Pitch` read keeps the server's status in the `DataValue`,
like the axis values and `MotorTemperature`. Before,
`Session.ReadValueAsync` threw and failed the whole snapshot read.

The public API does not change.

## Changes

- `src/Opc.Ua.Robotics.Client/RoboticsClient.Topology.cs`:
  - new `ReadAxisEngineeringAsync`, called from `ReadAxisAsync`;
- `ReadLoadAsync` and `ReadGearAsync` use the existing
`ReadChildrenAsync`.
- `docs/Robotics.md`:
  - describes the members and their null cases;
  - adds them to the values that carry no NodeId;
- names `AxisSnapshot.Engineering` instead of the non-existent
`AxisSnapshot.Limits` in the URML section.

## Tests

- **New, unit** (`Opc.Ua.Robotics.Tests`, `RoboticsClientFacadeTests`):
- `EngineeringValuesAndTheGearRatioComeFromTheVariablesTheyDescribe`:
the values are checked against same-named decoys below the axis and a
`GearRatio` in namespace 0.
- `AbsentOrUnreadableEngineeringValuesAndGearRatiosStayNull`: a missing
`EURange`, a load without engineering properties, a failing unit read
and a failing ratio read.
- `BadQualityEngineeringValuesAndGearRatiosReadAsNoStructure`: a range,
a unit and a ratio with a bad status read as `null`.
  - `AFailedMassOrPitchReadKeepsTheStatusAndTheSnapshot`.
  - `AnAxisWithoutAParameterSetHasNoEngineeringValues`.
- **New, end-to-end**
(`RoboticsClientServerE2eTests.EngineeringValuesAndGearRatiosAreReadAsync`):
- the test cell now publishes a unit and range for `ActualSpeed`, a unit
without a range for `ActualAcceleration`, a range for the flange load's
mass and a tool load with a mass only;
- the test reads the units, ranges and gear ratios back through
`ReadSystemAsync` and `ReadAxisAsync`.
- **Fail without the fix:** with `src/Opc.Ua.Robotics.Client` from
master, five of the six new tests fail. Four get `null` units, ranges or
ratios. `AFailedMassOrPitchReadKeepsTheStatusAndTheSnapshot` fails with
`ServiceResultException` `Bad_NodeIdUnknown`.
`AnAxisWithoutAParameterSetHasNoEngineeringValues` passes there by
design: it pins that an axis without a `ParameterSet` still reads
without error.
- `Opc.Ua.Robotics.Tests` passed 661/661 on net10.0 and 601/601 on
net8.0 (macOS).
- `Opc.Ua.Robotics.Client` builds for net48, net8.0, net9.0 and net10.0
with 0 warnings.
- `dotnet format` (whitespace, style, analyzers) on the changed files:
nothing on the changed lines beyond the `IDE0002` info that the existing
`RoboticsSessionHarness.Ua(Opc.Ua.BrowseNames.…)` calls in the same test
class also get.
- Changed lines covered: 52/52 (100 %).
- Fork CI (full matrix incl. macOS) and CodeQL: [CI run
37147764118](https://github.com/biancode/UA-.NETStandard/actions/runs/37147764118)
and [CodeQL run
37147766527](https://github.com/biancode/UA-.NETStandard/actions/runs/37147766527).
CodeQL is green; CI is green except `Opc.Ua.Interop.Tests`
(`LegacyClientExtendedCheckAsync("ComplexTypes")` /
`LoadAndDecodeLegacyComplexTypesAsync`), which is flaky on master since
#4630 (details in #4617), and except one Windows run of
`StartIsIdempotentAndDisposeStopsListenerAsync` in
`Opc.Ua.Redundancy.Kubernetes.Tests` (HttpListener prefix already
registered), which this change does not touch either.

## Notes for review

- `ReadAxisAsync` costs one TranslateBrowsePathsToNodeIds and at most
one Read more per axis. The other snapshots keep their request count.
- `AxisSnapshot.Engineering`, `LoadSnapshot.MassEngineering` and
`GearSnapshot.GearRatio` are read once and carry no NodeId, like
`MotorTemperatureEngineering`.

## Related Issues

- Fixes #4660
- Follows up #4619

## Checklist

- [x] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed. (Affected
suite on net10.0 and net8.0 locally on macOS; .NET Framework is covered
by the fork CI's Windows net48 legs.)
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.

Co-authored-by: Marc Schier <marcschier@users.noreply.github.com>
2026-10-05 06:27:12 +02:00

1635 lines
83 KiB
C#

/* ========================================================================
* 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.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using NUnit.Framework;
using Opc.Ua.Client;
using Opc.Ua.Client.FileSystem;
using Opc.Ua.Client.Subscriptions;
using Opc.Ua.Client.Subscriptions.Streaming;
using DiBrowseNames = Opc.Ua.Di.BrowseNames;
using RoboticsBrowseNames = Opc.Ua.Robotics.BrowseNames;
namespace Opc.Ua.Robotics.Client.Tests
{
/// <summary>
/// Tests for the high-level Robotics facade added for topology, operations, streaming, and DI.
/// </summary>
[TestFixture]
[Category("Robotics")]
public sealed class RoboticsClientFacadeTests
{
[Test]
public async Task EnumerateMotionDeviceSystemsStreamsMatchesAndUsesBrowseNext()
{
RoboticsSessionHarness harness = new();
harness.AddBrowse(
harness.DeviceSetId,
[harness.Ref(harness.SystemId, "System", RoboticsModel.MotionDeviceSystemType)]);
harness.EnableBrowseContinuationFor(harness.DeviceSetId);
harness.NodeCache.Setup(c => c.IsTypeOfAsync(
It.IsAny<NodeId>(), It.IsAny<NodeId>(), It.IsAny<CancellationToken>()))
.Returns(new ValueTask<bool>(true));
List<MotionDeviceSystemEntry> entries = [];
await foreach (MotionDeviceSystemEntry entry in harness.Client.EnumerateMotionDeviceSystemsAsync())
{
entries.Add(entry);
}
Assert.That(entries, Has.Count.EqualTo(1));
Assert.That(entries[0].NodeId, Is.EqualTo(harness.SystemId));
harness.Session.Verify(s => s.BrowseNextAsync(
It.IsAny<RequestHeader>(), false, It.IsAny<ArrayOf<ByteString>>(), It.IsAny<CancellationToken>()),
Times.Once);
}
[Test]
public async Task EnumerateMotionDeviceSystemsReturnsEmptyWhenNamespaceAbsent()
{
var session = new Mock<ISession>();
session.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
var client = new RoboticsClient(session.Object, new Mock<ITelemetryContext>().Object);
List<MotionDeviceSystemEntry> entries = [];
await foreach (MotionDeviceSystemEntry entry in client.EnumerateMotionDeviceSystemsAsync())
{
entries.Add(entry);
}
Assert.That(entries, Is.Empty);
}
[Test]
public async Task ReadSystemPopulatesTopologySnapshotsAndRelationships()
{
RoboticsSessionHarness h = new();
h.ConfigureCompleteTopology();
RoboticsTopologySnapshot snapshot = await h.Client.ReadSystemAsync(h.SystemId).ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(snapshot.Systems[0].Identification.ComponentName.Text, Is.EqualTo("System"));
Assert.That(snapshot.Systems[0].Identification.SerialNumber, Is.EqualTo("SystemSerial"),
"identification comes from the DI namespace, not the namespace-0 decoy");
Assert.That(snapshot.Systems[0].Identification.Manufacturer.Text, Is.EqualTo("OPC"));
Assert.That(
snapshot.MotionDevices[0].Category,
Is.EqualTo(MotionDeviceCategoryEnumeration.ARTICULATED_ROBOT));
Assert.That(
snapshot.MotionDevices[0].SpeedOverride.WrappedValue.TryGetValue(out double speed),
Is.True);
Assert.That(speed, Is.EqualTo(0.75d), "SpeedOverride comes from the ParameterSet");
Assert.That(snapshot.Axes[0].MotionProfile, Is.EqualTo(AxisMotionProfileEnumeration.ROTARY));
Assert.That(snapshot.Gears[0].Pitch.WrappedValue.TryGetValue(out double pitch), Is.True);
Assert.That(pitch, Is.EqualTo(1.0d));
Assert.That(snapshot.SafetyStates[0].EmergencyStop.WrappedValue.TryGetValue(out bool estop), Is.True);
Assert.That(estop, Is.False);
Assert.That(
snapshot.TaskControls[0].TaskProgramName.WrappedValue.TryGetValue(out string program),
Is.True);
Assert.That(program, Is.EqualTo("Program"));
Assert.That(snapshot.TaskModules[0].Version, Is.EqualTo("1.0"));
Assert.That(snapshot.Controllers[0].TaskControlIds.ToList(), Does.Contain(h.TaskControlId));
Assert.That(snapshot.MotionDevices[0].AxisIds.ToList(), Does.Contain(h.AxisId));
Assert.That(snapshot.MotionDevices[0].FlangeLoadId, Is.EqualTo(h.FlangeLoadId));
Assert.That(
snapshot.Axes[0].State.ActualPosition.WrappedValue.TryGetValue(out double position),
Is.True);
Assert.That(position, Is.EqualTo(12.5d));
Assert.That(snapshot.Loads.ToList().Any(l => l.NodeId == h.FlangeLoadId), Is.True);
// Motors, gears and drives are placeholder instances found by type
// (motors/gears) and by the IsDrivenBy reference (drives).
Assert.That(snapshot.PowerTrains[0].MotorIds.ToList(), Is.EqualTo(new[] { h.MotorId }));
Assert.That(snapshot.PowerTrains[0].GearIds.ToList(), Is.EqualTo(new[] { h.GearId }));
Assert.That(snapshot.Motors, Has.Count.EqualTo(1));
Assert.That(snapshot.Motors[0].Identification.NodeId, Is.EqualTo(h.MotorId));
Assert.That(snapshot.Gears, Has.Count.EqualTo(1));
Assert.That(snapshot.Gears[0].Identification.NodeId, Is.EqualTo(h.GearId));
Assert.That(snapshot.Drives, Has.Count.EqualTo(1));
Assert.That(snapshot.Drives[0].Identification.NodeId, Is.EqualTo(h.DriveId));
Assert.That(snapshot.SafetyStates[0].EmergencyStopFunctions[0].Name, Is.EqualTo("EStop"));
Assert.That(snapshot.TaskControls[0].TaskModuleIds.ToList(), Does.Contain(h.TaskModuleId));
Assert.That(snapshot.TaskModules[0].Name, Is.EqualTo("Module"));
Assert.That(snapshot.Relationships.Controls, Has.Count.EqualTo(1));
Assert.That(snapshot.Relationships.HasSafetyStates, Has.Count.EqualTo(1));
Assert.That(snapshot.Relationships.Moves, Has.Count.EqualTo(1));
Assert.That(snapshot.Relationships.Requires, Has.Count.EqualTo(1));
Assert.That(snapshot.Relationships.HasSlave, Has.Count.EqualTo(1));
Assert.That(snapshot.Relationships.IsDrivenBy, Has.Count.EqualTo(1));
Assert.That(snapshot.Relationships.IsConnectedTo, Has.Count.EqualTo(1));
});
}
[Test]
public async Task PerNodeSnapshotReadersPopulateExpectedFields()
{
RoboticsSessionHarness h = new();
h.ConfigureCompleteTopology();
ControllerSnapshot controller = await h.Client.ReadControllerAsync(h.ControllerId).ConfigureAwait(false);
MotionDeviceSnapshot motion = await h.Client.ReadMotionDeviceAsync(h.MotionDeviceId).ConfigureAwait(false);
AxisSnapshot axis = await h.Client.ReadAxisAsync(h.AxisId).ConfigureAwait(false);
SafetyStateSnapshot safety = await h.Client.ReadSafetyStateAsync(h.SafetyId).ConfigureAwait(false);
TaskControlSnapshot task = await h.Client.ReadTaskControlAsync(h.TaskControlId).ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(controller.ComponentIds.ToList(), Does.Contain(h.ControllerComponentId));
Assert.That(motion.SpeedOverride.WrappedValue.TryGetValue(out double speed), Is.True);
Assert.That(speed, Is.EqualTo(0.75d));
Assert.That(axis.MotionProfile, Is.EqualTo(AxisMotionProfileEnumeration.ROTARY));
Assert.That(safety.ProtectiveStopFunctions[0].Name, Is.EqualTo("PStop"));
Assert.That(task.TaskControlOperationId, Is.EqualTo(h.TaskControlOperationId));
});
}
[Test]
public async Task SnapshotsCarryTheNodeIdsOfTheVariablesTheyRead()
{
RoboticsSessionHarness h = new();
h.ConfigureCompleteTopology();
QualifiedName parameterSet = h.Di(DiBrowseNames.ParameterSet);
NodeId speedOverrideDecoy = h.Resolve(
h.MotionDeviceId, RoboticsSessionHarness.Ua(RoboticsBrowseNames.SpeedOverride));
RoboticsTopologySnapshot snapshot = await h.Client.ReadSystemAsync(h.SystemId).ConfigureAwait(false);
SafetyStateSnapshot safety = snapshot.SafetyStates[0];
TaskControlSnapshot task = snapshot.TaskControls[0];
SafetyFunctionSnapshot estop = safety.EmergencyStopFunctions[0];
Assert.Multiple(() =>
{
// The ParameterSet variables, not the decoys of the same name
// directly below the axis and the motion device.
Assert.That(snapshot.Axes[0].State.ActualPositionId, Is.EqualTo(h.AxisPositionId));
Assert.That(snapshot.Axes[0].State.ActualSpeedId, Is.EqualTo(h.AxisSpeedId));
Assert.That(snapshot.Axes[0].State.ActualAccelerationId, Is.EqualTo(h.AxisAccelerationId));
Assert.That(
snapshot.MotionDevices[0].SpeedOverrideId,
Is.EqualTo(h.Resolve(h.MotionDeviceId, parameterSet, h.Rob(RoboticsBrowseNames.SpeedOverride))));
Assert.That(snapshot.MotionDevices[0].SpeedOverrideId, Is.Not.EqualTo(speedOverrideDecoy));
Assert.That(
safety.EmergencyStopId,
Is.EqualTo(h.Resolve(h.SafetyId, parameterSet, h.Rob(RoboticsBrowseNames.EmergencyStop))));
Assert.That(
safety.OperationalModeId,
Is.EqualTo(h.Resolve(h.SafetyId, parameterSet, h.Rob(RoboticsBrowseNames.OperationalMode))));
Assert.That(
safety.ProtectiveStopId,
Is.EqualTo(h.Resolve(h.SafetyId, parameterSet, h.Rob(RoboticsBrowseNames.ProtectiveStop))));
Assert.That(
estop.ActiveId,
Is.EqualTo(h.Resolve(h.EmergencyFunctionId, h.Rob(RoboticsBrowseNames.Active))));
Assert.That(
estop.EnabledId,
Is.EqualTo(h.Resolve(h.EmergencyFunctionId, h.Rob(RoboticsBrowseNames.Enabled))));
Assert.That(
task.ExecutionModeId,
Is.EqualTo(h.Resolve(h.TaskControlId, parameterSet, h.Rob(RoboticsBrowseNames.ExecutionMode))));
Assert.That(
task.TaskProgramLoadedId,
Is.EqualTo(h.Resolve(
h.TaskControlId, parameterSet, h.Rob(RoboticsBrowseNames.TaskProgramLoaded))));
Assert.That(
task.TaskProgramNameId,
Is.EqualTo(h.Resolve(h.TaskControlId, parameterSet, h.Rob(RoboticsBrowseNames.TaskProgramName))));
Assert.That(
snapshot.TaskModules[0].IsReferencedId,
Is.EqualTo(h.Resolve(h.TaskModuleId, h.Rob(RoboticsBrowseNames.IsReferenced))));
Assert.That(
snapshot.Loads.ToList().Single(l => l.NodeId == h.FlangeLoadId).MassId,
Is.EqualTo(h.Resolve(h.FlangeLoadId, h.Rob(RoboticsBrowseNames.Mass))));
Assert.That(
snapshot.Gears[0].PitchId,
Is.EqualTo(h.Resolve(h.GearId, h.Rob(RoboticsBrowseNames.Pitch))));
});
}
[Test]
public async Task SnapshotsReadTheStateOfTheOperationStateMachines()
{
RoboticsSessionHarness h = new();
h.ConfigureCompleteTopology();
ControllerSnapshot controller = await h.Client.ReadControllerAsync(h.ControllerId).ConfigureAwait(false);
TaskControlSnapshot task = await h.Client.ReadTaskControlAsync(h.TaskControlId).ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(controller.SystemOperationId, Is.EqualTo(h.SystemOperationId));
// The CurrentState variable itself: not its Id child, not the machine.
Assert.That(controller.CurrentStateId, Is.EqualTo(h.SystemCurrentStateId));
Assert.That(controller.CurrentState, Is.EqualTo(RoboticsOperationState.Executing));
Assert.That(task.TaskControlOperationId, Is.EqualTo(h.TaskControlOperationId));
Assert.That(task.CurrentStateId, Is.EqualTo(h.TaskCurrentStateId));
Assert.That(task.CurrentState, Is.EqualTo(RoboticsOperationState.Ready));
});
}
[Test]
public async Task TheOperationStateIsMatchedByStateNodeIdNotByLocalizedText()
{
RoboticsSessionHarness h = new();
h.ConfigureCompleteTopology();
NodeId taskIdle = new(1910, 2);
NodeId taskExecuting = new(1911, 2);
NodeId systemIdle = new(1920, 2);
NodeId systemReady = new(1921, 2);
NodeId systemExecuting = new(1922, 2);
// A German server: the state names are localized, the state nodes are not.
h.AddOperationState(
h.SystemOperationId,
RoboticsBrowseNames.SystemOperationStateMachine,
h.SystemStateMachineId,
h.SystemCurrentStateId,
"Ausf\u00fchrung");
h.AddOperationState(
h.TaskControlOperationId,
RoboticsBrowseNames.TaskControlStateMachine,
h.TaskControlStateMachineId,
h.TaskCurrentStateId,
"Bereit");
h.AddStateNodes(
h.SystemStateMachineId, h.SystemCurrentStateId, systemIdle, systemReady, systemExecuting,
systemExecuting);
h.AddStateNodes(
h.TaskControlStateMachineId, h.TaskCurrentStateId, taskIdle, h.ReadyStateId, taskExecuting,
h.ReadyStateId);
ControllerSnapshot controller = await h.Client.ReadControllerAsync(h.ControllerId).ConfigureAwait(false);
TaskControlSnapshot task = await h.Client.ReadTaskControlAsync(h.TaskControlId).ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(controller.CurrentState, Is.EqualTo(RoboticsOperationState.Executing));
Assert.That(task.CurrentState, Is.EqualTo(RoboticsOperationState.Ready));
});
}
[Test]
public async Task TheOperationStateIsMatchedByTheStateIdsTheModelDeclaresOnTheMachineType()
{
RoboticsSessionHarness h = new();
h.ConfigureCompleteTopology();
// The Robotics model declares Idle, Ready and Executing on the state machine
// types; a server that does not instantiate them publishes the type states'
// NodeIds, mapped to its own index of the Robotics namespace.
h.AddOperationState(
h.SystemOperationId,
RoboticsBrowseNames.SystemOperationStateMachine,
h.SystemStateMachineId,
h.SystemCurrentStateId,
"Bereit");
h.AddOperationState(
h.TaskControlOperationId,
RoboticsBrowseNames.TaskControlStateMachine,
h.TaskControlStateMachineId,
h.TaskCurrentStateId,
"Ausf\u00fchrung");
h.SetCurrentStateId(
h.SystemCurrentStateId,
NodeId.Create(
SystemOperationStateMachineTypeIds.StateIds.Ready,
global::Opc.Ua.Robotics.Namespaces.Robotics,
h.NamespaceUris));
h.SetCurrentStateId(
h.TaskCurrentStateId,
NodeId.Create(
TaskControlStateMachineTypeIds.StateIds.Executing,
global::Opc.Ua.Robotics.Namespaces.Robotics,
h.NamespaceUris));
ControllerSnapshot controller = await h.Client.ReadControllerAsync(h.ControllerId).ConfigureAwait(false);
TaskControlSnapshot task = await h.Client.ReadTaskControlAsync(h.TaskControlId).ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(controller.CurrentState, Is.EqualTo(RoboticsOperationState.Ready));
Assert.That(task.CurrentState, Is.EqualTo(RoboticsOperationState.Executing));
});
}
[Test]
public async Task ABadCurrentStateReadsAsNoStateEvenWithAMatchingStateId()
{
RoboticsSessionHarness h = new();
h.ConfigureCompleteTopology();
h.AddStateNodes(
h.SystemStateMachineId, h.SystemCurrentStateId,
new NodeId(1920, 2), new NodeId(1921, 2), new NodeId(1922, 2),
new NodeId(1922, 2));
h.SetBadQuality(h.SystemCurrentStateId);
ControllerSnapshot controller = await h.Client.ReadControllerAsync(h.ControllerId).ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(controller.CurrentStateId, Is.EqualTo(h.SystemCurrentStateId));
Assert.That(controller.CurrentState.HasValue, Is.False, "a bad CurrentState is no state");
});
}
[Test]
public async Task APublishedStateIdThatNamesNoKnownStateIsNotOverriddenByTheStateName()
{
RoboticsSessionHarness h = new();
h.ConfigureCompleteTopology();
// Both name a state in their text, but the Id the server publishes is
// unknown for the controller and unreadable for the task control.
h.SetCurrentStateId(h.SystemCurrentStateId, new NodeId(4711, 2));
h.SetCurrentStateId(h.TaskCurrentStateId, new NodeId(1922, 2));
h.SetBadQuality(h.Resolve(h.TaskCurrentStateId, RoboticsSessionHarness.Ua(Opc.Ua.BrowseNames.Id)));
ControllerSnapshot controller = await h.Client.ReadControllerAsync(h.ControllerId).ConfigureAwait(false);
TaskControlSnapshot task = await h.Client.ReadTaskControlAsync(h.TaskControlId).ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(controller.CurrentStateId, Is.EqualTo(h.SystemCurrentStateId));
Assert.That(controller.CurrentState.HasValue, Is.False, "an unknown state Id is no state");
Assert.That(task.CurrentStateId, Is.EqualTo(h.TaskCurrentStateId));
Assert.That(task.CurrentState.HasValue, Is.False, "an unreadable state Id is no state");
});
}
[Test]
public async Task ABadQualityEngineeringValueReadsAsNoStructure()
{
RoboticsSessionHarness h = new();
h.ConfigureCompleteTopology();
h.SetBadQuality(h.Resolve(h.MotorTemperatureId, RoboticsSessionHarness.Ua(Opc.Ua.BrowseNames.EURange)));
RoboticsTopologySnapshot snapshot = await h.Client.ReadSystemAsync(h.SystemId).ConfigureAwait(false);
MotorSnapshot motor = snapshot.Motors[0];
Assert.Multiple(() =>
{
Assert.That(motor.MotorTemperatureEngineering.Range, Is.Null, "a bad read is not a range");
Assert.That(motor.MotorTemperatureEngineering.EngineeringUnits?.UnitId, Is.EqualTo(h.Celsius.UnitId));
});
}
[Test]
public async Task AnUnknownOrUnreadableOperationStateReadsAsNull()
{
RoboticsSessionHarness h = new();
h.ConfigureCompleteTopology();
// A state the client does not know, and a CurrentState whose read fails.
h.AddOperationState(
h.TaskControlOperationId,
RoboticsBrowseNames.TaskControlStateMachine,
h.TaskControlStateMachineId,
h.TaskCurrentStateId,
"Halted");
h.AddOperationState(
h.SystemOperationId,
RoboticsBrowseNames.SystemOperationStateMachine,
h.SystemStateMachineId,
h.SystemCurrentStateId,
null);
TaskControlSnapshot task = await h.Client.ReadTaskControlAsync(h.TaskControlId).ConfigureAwait(false);
ControllerSnapshot controller = await h.Client.ReadControllerAsync(h.ControllerId).ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(task.CurrentStateId, Is.EqualTo(h.TaskCurrentStateId));
Assert.That(task.CurrentState.HasValue, Is.False, "Halted is not an OPC 40010-1 operation state");
Assert.That(controller.CurrentStateId, Is.EqualTo(h.SystemCurrentStateId));
Assert.That(controller.CurrentState.HasValue, Is.False, "a failed read is no state, not an exception");
Assert.That(controller.Identification.SerialNumber, Is.EqualTo("ControllerSerial"));
});
}
[Test]
public async Task SnapshotsWithoutOperationsHaveNoStateIds()
{
RoboticsSessionHarness h = new();
ControllerSnapshot controller = await h.Client.ReadControllerAsync(h.ControllerId).ConfigureAwait(false);
TaskControlSnapshot task = await h.Client.ReadTaskControlAsync(h.TaskControlId).ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(controller.SystemOperationId.IsNull, Is.True);
Assert.That(controller.CurrentStateId.IsNull, Is.True);
Assert.That(controller.CurrentState.HasValue, Is.False);
Assert.That(task.TaskControlOperationId.IsNull, Is.True);
Assert.That(task.CurrentStateId.IsNull, Is.True);
Assert.That(task.CurrentState.HasValue, Is.False);
Assert.That(task.ExecutionModeId.IsNull, Is.True);
Assert.That(task.ExecutionMode.IsNull, Is.True);
});
}
[Test]
public async Task IdentificationAndMotorValuesComeFromTheDiPropertiesAndTheParameterSet()
{
RoboticsSessionHarness h = new();
h.ConfigureCompleteTopology();
RoboticsTopologySnapshot snapshot = await h.Client.ReadSystemAsync(h.SystemId).ConfigureAwait(false);
RoboticsComponentIdentification robot = snapshot.MotionDevices[0].Identification;
MotorSnapshot motor = snapshot.Motors[0];
Assert.Multiple(() =>
{
Assert.That(robot.HardwareRevision, Is.EqualTo("MotionHardware"));
Assert.That(robot.SoftwareRevision, Is.EqualTo("MotionSoftware"), "DI namespace, not the decoy");
Assert.That(robot.ManufacturerUri, Is.EqualTo("urn:opc:Motion"));
Assert.That(robot.ProductInstanceUri, Is.EqualTo("urn:opc:Motion:1"));
Assert.That(motor.Identification.SoftwareRevision, Is.EqualTo("MotorSoftware"));
Assert.That(motor.MotorTemperatureId, Is.EqualTo(h.MotorTemperatureId));
Assert.That(motor.MotorTemperature.WrappedValue.TryGetValue(out double temperature), Is.True);
Assert.That(temperature, Is.EqualTo(41.5d), "the ParameterSet variable, not the decoy");
Assert.That(motor.MotorTemperatureEngineering.EngineeringUnits?.UnitId, Is.EqualTo(h.Celsius.UnitId));
Assert.That(motor.MotorTemperatureEngineering.Range?.Low, Is.EqualTo(-20d));
Assert.That(motor.MotorTemperatureEngineering.Range?.High, Is.EqualTo(120d));
Assert.That(motor.BrakeReleased.WrappedValue.TryGetValue(out bool released), Is.True);
Assert.That(released, Is.True);
Assert.That(
motor.BrakeReleasedId,
Is.EqualTo(h.Resolve(
h.MotorId, h.Di(DiBrowseNames.ParameterSet), h.Rob(RoboticsBrowseNames.BrakeReleased))));
// EffectiveLoadRate is optional and not published here.
Assert.That(motor.EffectiveLoadRateId.IsNull, Is.True);
Assert.That(motor.EffectiveLoadRate.IsNull, Is.True);
});
}
[Test]
public async Task EngineeringValuesAndTheGearRatioComeFromTheVariablesTheyDescribe()
{
RoboticsSessionHarness h = new();
h.ConfigureCompleteTopology();
RoboticsTopologySnapshot snapshot = await h.Client.ReadSystemAsync(h.SystemId).ConfigureAwait(false);
AxisSnapshot single = await h.Client.ReadAxisAsync(h.AxisId).ConfigureAwait(false);
AxisEngineeringOptions axis = snapshot.Axes[0].Engineering;
LoadSnapshot flange = snapshot.Loads.ToList().Single(l => l.NodeId == h.FlangeLoadId);
Assert.Multiple(() =>
{
// The properties of the ParameterSet variables, not of the decoy below the axis.
Assert.That(axis.PositionUnit?.UnitId, Is.EqualTo(h.Degree.UnitId));
Assert.That(axis.Limits.Position?.Low, Is.EqualTo(-170d));
Assert.That(axis.Limits.Position?.High, Is.EqualTo(170d));
Assert.That(axis.SpeedUnit?.UnitId, Is.EqualTo(h.DegreePerSecond.UnitId));
Assert.That(axis.Limits.Speed?.Low, Is.EqualTo(-250d));
Assert.That(axis.Limits.Speed?.High, Is.EqualTo(250d));
Assert.That(axis.AccelerationUnit?.UnitId, Is.EqualTo(h.DegreePerSecondSquared.UnitId));
Assert.That(axis.Limits.Acceleration, Is.Null, "ActualAcceleration publishes no EURange");
Assert.That(single.Engineering.PositionUnit?.UnitId, Is.EqualTo(h.Degree.UnitId));
Assert.That(single.Engineering.Limits.Speed?.High, Is.EqualTo(250d));
Assert.That(flange.MassEngineering.EngineeringUnits?.UnitId, Is.EqualTo(h.Kilogram.UnitId));
Assert.That(flange.MassEngineering.Range?.Low, Is.Zero);
Assert.That(flange.MassEngineering.Range?.High, Is.EqualTo(20d));
Assert.That(flange.Mass.WrappedValue.TryGetValue(out double mass), Is.True);
Assert.That(mass, Is.EqualTo(10d));
Assert.That(flange.MassId, Is.EqualTo(h.Resolve(h.FlangeLoadId, h.Rob(RoboticsBrowseNames.Mass))));
// The Robotics GearRatio, not the namespace-0 decoy.
Assert.That(snapshot.Gears[0].GearRatio?.Numerator, Is.EqualTo(160));
Assert.That(snapshot.Gears[0].GearRatio?.Denominator, Is.EqualTo(1u));
Assert.That(snapshot.Gears[0].Pitch.WrappedValue.TryGetValue(out double pitch), Is.True);
Assert.That(pitch, Is.EqualTo(1.0d));
});
}
[Test]
public async Task AbsentOrUnreadableEngineeringValuesAndGearRatiosStayNull()
{
RoboticsSessionHarness h = new();
h.ConfigureCompleteTopology();
// Both resolve, but their reads fail.
h.FailRead(h.AxisPositionUnitsId);
h.FailRead(h.GearRatioId);
RoboticsTopologySnapshot snapshot = await h.Client.ReadSystemAsync(h.SystemId).ConfigureAwait(false);
AxisEngineeringOptions axis = snapshot.Axes[0].Engineering;
LoadSnapshot additional = snapshot.Loads.ToList().Single(l => l.NodeId == h.AxisLoadId);
GearSnapshot gear = snapshot.Gears[0];
Assert.Multiple(() =>
{
Assert.That(axis.PositionUnit, Is.Null, "a failed read is no unit, not an exception");
Assert.That(axis.Limits.Position?.High, Is.EqualTo(170d), "the other properties are still read");
Assert.That(axis.SpeedUnit?.UnitId, Is.EqualTo(h.DegreePerSecond.UnitId));
// The axis load's Mass publishes neither EngineeringUnits nor EURange.
Assert.That(additional.MassEngineering.EngineeringUnits, Is.Null);
Assert.That(additional.MassEngineering.Range, Is.Null);
Assert.That(additional.Mass.WrappedValue.TryGetValue(out double mass), Is.True);
Assert.That(mass, Is.EqualTo(10d));
Assert.That(gear.GearRatio, Is.Null, "a failed read is no ratio, not an exception");
Assert.That(gear.Pitch.WrappedValue.TryGetValue(out double pitch), Is.True);
Assert.That(pitch, Is.EqualTo(1.0d));
Assert.That(gear.Identification.SerialNumber, Is.EqualTo("GearSerial"));
});
}
[Test]
public async Task BadQualityEngineeringValuesAndGearRatiosReadAsNoStructure()
{
RoboticsSessionHarness h = new();
h.ConfigureCompleteTopology();
// The values are still there, but the server reports them with a bad status.
h.SetBadQuality(h.Resolve(h.AxisSpeedId, RoboticsSessionHarness.Ua(Opc.Ua.BrowseNames.EURange)));
NodeId massId = h.Resolve(h.FlangeLoadId, h.Rob(RoboticsBrowseNames.Mass));
h.SetBadQuality(h.Resolve(massId, RoboticsSessionHarness.Ua(Opc.Ua.BrowseNames.EngineeringUnits)));
h.SetBadQuality(h.GearRatioId);
RoboticsTopologySnapshot snapshot = await h.Client.ReadSystemAsync(h.SystemId).ConfigureAwait(false);
AxisEngineeringOptions axis = snapshot.Axes[0].Engineering;
LoadSnapshot flange = snapshot.Loads.ToList().Single(l => l.NodeId == h.FlangeLoadId);
GearSnapshot gear = snapshot.Gears[0];
Assert.Multiple(() =>
{
Assert.That(axis.Limits.Speed, Is.Null, "a bad read is not a range");
Assert.That(axis.SpeedUnit?.UnitId, Is.EqualTo(h.DegreePerSecond.UnitId));
Assert.That(flange.MassEngineering.EngineeringUnits, Is.Null, "a bad read is not a unit");
Assert.That(flange.MassEngineering.Range?.High, Is.EqualTo(20d));
Assert.That(gear.GearRatio, Is.Null, "a bad read is not a ratio");
Assert.That(gear.Pitch.WrappedValue.TryGetValue(out double pitch), Is.True);
Assert.That(pitch, Is.EqualTo(1.0d));
});
}
[Test]
public async Task AFailedMassOrPitchReadKeepsTheStatusAndTheSnapshot()
{
RoboticsSessionHarness h = new();
h.ConfigureCompleteTopology();
NodeId massId = h.Resolve(h.FlangeLoadId, h.Rob(RoboticsBrowseNames.Mass));
NodeId pitchId = h.Resolve(h.GearId, h.Rob(RoboticsBrowseNames.Pitch));
h.FailRead(massId);
h.FailRead(pitchId);
RoboticsTopologySnapshot snapshot = await h.Client.ReadSystemAsync(h.SystemId).ConfigureAwait(false);
LoadSnapshot flange = snapshot.Loads.ToList().Single(l => l.NodeId == h.FlangeLoadId);
GearSnapshot gear = snapshot.Gears[0];
Assert.Multiple(() =>
{
Assert.That(flange.Mass.StatusCode, Is.EqualTo(StatusCodes.BadNodeIdUnknown));
Assert.That(flange.MassId, Is.EqualTo(massId));
Assert.That(flange.MassEngineering.EngineeringUnits?.UnitId, Is.EqualTo(h.Kilogram.UnitId));
Assert.That(gear.Pitch.StatusCode, Is.EqualTo(StatusCodes.BadNodeIdUnknown));
Assert.That(gear.PitchId, Is.EqualTo(pitchId));
Assert.That(gear.GearRatio?.Numerator, Is.EqualTo(160));
});
}
[Test]
public async Task AnAxisWithoutAParameterSetHasNoEngineeringValues()
{
RoboticsSessionHarness h = new();
AxisSnapshot axis = await h.Client.ReadAxisAsync(h.AxisId).ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(axis.Engineering.PositionUnit, Is.Null);
Assert.That(axis.Engineering.SpeedUnit, Is.Null);
Assert.That(axis.Engineering.AccelerationUnit, Is.Null);
Assert.That(axis.Engineering.Limits.Position, Is.Null);
Assert.That(axis.Engineering.Limits.Speed, Is.Null);
Assert.That(axis.Engineering.Limits.Acceleration, Is.Null);
});
}
[Test]
public async Task ProgramsAsyncReturnsFileSystemClientAndFailsWhenAbsent()
{
RoboticsSessionHarness h = new();
h.AddChild(h.ControllerId, h.Rob(RoboticsBrowseNames.Programs), h.ProgramsId);
FileSystemClient fileSystem = await h.Client.ProgramsAsync(h.ControllerId).ConfigureAwait(false);
Assert.That(fileSystem.Root.NodeId, Is.EqualTo(h.ProgramsId));
RoboticsSessionHarness missing = new();
ServiceResultException ex = Assert.ThrowsAsync<ServiceResultException>(
async () => await missing.Client.ProgramsAsync(missing.ControllerId).ConfigureAwait(false))!;
Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadNotFound));
}
[TestCase("GetReady")]
[TestCase("Start")]
[TestCase("Stop")]
[TestCase("StandDown")]
public async Task SystemOperationVerbsReturnStatusAndBadResultsThrow(string verb)
{
RoboticsSessionHarness good = new();
good.ConfigureSystemOperation(StatusCodes.Good, 17);
SystemOperationClient goodClient = new(good.Session.Object, good.SystemOperationId, good.Telemetry);
int status = await InvokeSystemVerbAsync(goodClient, verb).ConfigureAwait(false);
Assert.That(status, Is.EqualTo(17));
RoboticsSessionHarness bad = new();
bad.ConfigureSystemOperation(StatusCodes.BadInvalidState, 0);
SystemOperationClient badClient = new(bad.Session.Object, bad.SystemOperationId, bad.Telemetry);
ServiceResultException ex = Assert.ThrowsAsync<ServiceResultException>(
async () => await InvokeSystemVerbAsync(badClient, verb).ConfigureAwait(false))!;
Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadInvalidState));
}
[TestCase("LoadByName")]
[TestCase("LoadByNodeId")]
[TestCase("UnloadByName")]
[TestCase("UnloadByNodeId")]
[TestCase("UnloadProgram")]
[TestCase("ResetToProgramStart")]
[TestCase("Start")]
[TestCase("Stop")]
public async Task TaskControlMembersReturnStatusAndBadResultsThrow(string verb)
{
RoboticsSessionHarness good = new();
good.ConfigureTaskControl(StatusCodes.Good, 23);
TaskControlClient goodClient = new(good.Session.Object, good.TaskControlId, good.Telemetry);
int status = await InvokeTaskVerbAsync(goodClient, verb).ConfigureAwait(false);
Assert.That(status, Is.EqualTo(23));
RoboticsSessionHarness bad = new();
bad.ConfigureTaskControl(StatusCodes.BadInvalidState, 0);
TaskControlClient badClient = new(bad.Session.Object, bad.TaskControlId, bad.Telemetry);
ServiceResultException ex = Assert.ThrowsAsync<ServiceResultException>(
async () => await InvokeTaskVerbAsync(badClient, verb).ConfigureAwait(false))!;
Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadInvalidState));
}
[Test]
public async Task OperationClientsReadAndObserveState()
{
RoboticsSessionHarness h = new();
h.ConfigureSystemOperation(StatusCodes.Good, 1);
h.AddStateReads(h.SystemStateMachineId, RoboticsBrowseNames.Executing);
Mock<IStreamingSubscription> streaming = h.Streaming(h.CurrentStateIdNode);
SystemOperationClient system = new(h.Session.Object, h.SystemOperationId, h.Telemetry);
RoboticsOperationState read = await system.ReadStateAsync().ConfigureAwait(false);
RoboticsOperationState observed = await FirstAsync(
system.ObserveStateAsync(streaming.Object)).ConfigureAwait(false);
Assert.That(read, Is.EqualTo(RoboticsOperationState.Executing));
Assert.That(observed, Is.EqualTo(RoboticsOperationState.Executing));
}
[Test]
public async Task ObserveAxisAndSafetyYieldSnapshotsAndCancellationStopsEnumeration()
{
RoboticsSessionHarness h = new();
h.ConfigureCompleteTopology();
Mock<IStreamingSubscription> streaming = h.Streaming(h.AxisPositionId);
AxisStateSnapshot axis = await FirstAsync(
h.Client.ObserveAxisAsync(h.AxisId, streaming.Object)).ConfigureAwait(false);
SafetyStateSnapshot safety = await FirstAsync(
h.Client.ObserveSafetyAsync(h.SafetyId, streaming.Object)).ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(axis.ActualPosition.WrappedValue.TryGetValue(out double position), Is.True);
Assert.That(position, Is.EqualTo(12.5d));
Assert.That(axis.ActualPositionId, Is.EqualTo(h.AxisPositionId));
Assert.That(safety.EmergencyStop.WrappedValue.TryGetValue(out bool emergency), Is.True);
Assert.That(emergency, Is.False);
Assert.That(safety.EmergencyStopId.IsNull, Is.False);
});
}
[Test]
public async Task ResetToProgramStartCallsTheReadySubstateMachine()
{
// OPC 40010-1 declares ResetToProgramStart on the ReadySubstateMachine,
// a sibling of the Ready state below the TaskControlStateMachine.
RoboticsSessionHarness h = new();
h.ConfigureTaskControl(StatusCodes.Good, 5);
TaskControlClient client = new(h.Session.Object, h.TaskControlId, h.Telemetry);
int status = await client.ResetToProgramStartAsync().ConfigureAwait(false);
Assert.That(status, Is.EqualTo(5));
Assert.That(h.Calls, Has.Count.EqualTo(1));
Assert.That(h.Calls[0].ObjectId, Is.EqualTo(h.ReadySubstateMachineId));
}
[Test]
public void ResetToProgramStartFailsWithoutAReadySubstateMachine()
{
RoboticsSessionHarness h = new();
h.ConfigureTaskControl(StatusCodes.Good, 5, readySubstateMachine: false);
TaskControlClient client = new(h.Session.Object, h.TaskControlId, h.Telemetry);
ServiceResultException ex = Assert.ThrowsAsync<ServiceResultException>(
async () => await client.ResetToProgramStartAsync().ConfigureAwait(false))!;
Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadNotFound));
Assert.That(h.Calls, Is.Empty, "the Ready state has no ResetToProgramStart to call");
}
[Test]
public void OperationCallsFailExplicitlyWhenRequiredNodesAreAbsent()
{
RoboticsSessionHarness h = new();
SystemOperationClient system = new(h.Session.Object, h.SystemOperationId, h.Telemetry);
TaskControlClient task = new(h.Session.Object, h.TaskControlId, h.Telemetry);
Assert.ThrowsAsync<ServiceResultException>(
async () => await system.StartAsync().ConfigureAwait(false));
Assert.ThrowsAsync<ServiceResultException>(
async () => await task.StartAsync().ConfigureAwait(false));
}
[Test]
public async Task SessionExtensionFactoryAndRegistrationWorkAndValidateNulls()
{
RoboticsSessionHarness h = new();
RoboticsClient fromExtension = h.Session.Object.Robotics(h.Telemetry);
var factory = new RoboticsClientFactory(
_ => Task.FromResult((ManagedSession)h.Session.Object), h.Telemetry);
Assert.That(fromExtension.Session, Is.SameAs(h.Session.Object));
Assert.Throws<ArgumentNullException>(() => SessionRoboticsExtensions.Robotics(null!, h.Telemetry));
Assert.Throws<ArgumentNullException>(() => h.Session.Object.Robotics(null!));
Assert.Throws<ArgumentNullException>(() => new RoboticsClientFactory(null!, h.Telemetry));
Assert.Throws<ArgumentNullException>(() => new RoboticsClientFactory(_ => null!, null!));
var services = new ServiceCollection();
var builder = new TestClientBuilder(services);
builder.AddRoboticsClient();
Assert.That(services.Any(s => s.ServiceType == typeof(RoboticsClientFactory)), Is.True);
await Task.CompletedTask.ConfigureAwait(false);
}
private static async Task<int> InvokeSystemVerbAsync(SystemOperationClient client, string verb)
{
return verb switch
{
"GetReady" => await client.GetReadyAsync().ConfigureAwait(false),
"Start" => await client.StartAsync().ConfigureAwait(false),
"Stop" => await client.StopAsync(RoboticsStopMode.Controlled).ConfigureAwait(false),
_ => await client.StandDownAsync().ConfigureAwait(false)
};
}
private static async Task<int> InvokeTaskVerbAsync(TaskControlClient client, string verb)
{
return verb switch
{
"LoadByName" => (await client.LoadByNameAsync("p").ConfigureAwait(false)).Status,
"LoadByNodeId" => (await client.LoadByNodeIdAsync(new NodeId(1)).ConfigureAwait(false)).Status,
"UnloadByName" => (await client.UnloadByNameAsync("p").ConfigureAwait(false)).Status,
"UnloadByNodeId" => (await client.UnloadByNodeIdAsync(new NodeId(1)).ConfigureAwait(false)).Status,
"UnloadProgram" => (await client.UnloadProgramAsync().ConfigureAwait(false)).Status,
"ResetToProgramStart" => await client.ResetToProgramStartAsync().ConfigureAwait(false),
"Start" => await client.StartAsync().ConfigureAwait(false),
_ => await client.StopAsync(RoboticsStopMode.Quick).ConfigureAwait(false)
};
}
private static async Task<T> FirstAsync<T>(IAsyncEnumerable<T> source)
{
await foreach (T item in source.ConfigureAwait(false))
{
return item;
}
throw new InvalidOperationException("The sequence did not produce a value.");
}
private sealed class TestClientBuilder(IServiceCollection services) : IOpcUaClientBuilder
{
public IServiceCollection Services { get; } = services;
}
private sealed class RoboticsSessionHarness
{
private readonly Dictionary<(NodeId Parent, QualifiedName BrowseName), NodeId> m_children = [];
private readonly Dictionary<NodeId, NodeId> m_parameterSets = [];
private readonly Dictionary<NodeId, List<ReferenceDescription>> m_browse = [];
private readonly Dictionary<NodeId, Variant> m_values = [];
private readonly HashSet<NodeId> m_continuationNodes = [];
private readonly HashSet<NodeId> m_badQualityNodes = [];
private StatusCode m_callStatus = StatusCodes.Good;
private int m_callOutput;
public RoboticsSessionHarness()
{
Telemetry = new Mock<ITelemetryContext>().Object;
NamespaceUris.GetIndexOrAppend(global::Opc.Ua.Robotics.Namespaces.Robotics);
NamespaceUris.GetIndexOrAppend(Opc.Ua.Di.Namespaces.OpcUaDi);
MessageContext = ServiceMessageContext.Create(Telemetry);
MessageContext.NamespaceUris.GetIndexOrAppend(global::Opc.Ua.Robotics.Namespaces.Robotics);
MessageContext.NamespaceUris.GetIndexOrAppend(Opc.Ua.Di.Namespaces.OpcUaDi);
Session.SetupGet(s => s.NamespaceUris).Returns(NamespaceUris);
Session.SetupGet(s => s.MessageContext).Returns(MessageContext);
Session.SetupGet(s => s.Factory).Returns(MessageContext.Factory);
Session.SetupGet(s => s.OperationLimits).Returns(new OperationLimits());
Session.SetupGet(s => s.ServerCapabilities).Returns(new ServerCapabilities());
Session.SetupGet(s => s.ContinuationPointPolicy).Returns(ContinuationPointPolicy.Default);
Session.SetupGet(s => s.NodeCache).Returns(NodeCache.Object);
// Exact type match: every reference in the harness carries its
// concrete TypeDefinition, and a permissive answer would hide
// type filtering (a gear classified as a motor).
NodeCache.Setup(c => c.IsTypeOfAsync(
It.IsAny<NodeId>(), It.IsAny<NodeId>(), It.IsAny<CancellationToken>()))
.Returns<NodeId, NodeId, CancellationToken>(
(subType, superType, _) => new ValueTask<bool>(subType == superType));
SetupTranslate();
SetupBrowse();
SetupRead();
SetupCall();
Client = new RoboticsClient(Session.Object, Telemetry);
}
public Mock<ISession> Session { get; } = new(MockBehavior.Loose);
public Mock<INodeCache> NodeCache { get; } = new(MockBehavior.Loose);
public ITelemetryContext Telemetry { get; }
public NamespaceTable NamespaceUris { get; } = new();
public ServiceMessageContext MessageContext { get; }
public RoboticsClient Client { get; }
public NodeId DeviceSetId => NodeId.Create(
Opc.Ua.Di.Objects.DeviceSet, Opc.Ua.Di.Namespaces.OpcUaDi, NamespaceUris);
public NodeId SystemId { get; } = new(1001, 2);
public NodeId ControllersFolderId { get; } = new(1002, 2);
public NodeId MotionDevicesFolderId { get; } = new(1003, 2);
public NodeId SafetyStatesFolderId { get; } = new(1004, 2);
public NodeId ControllerId { get; } = new(1100, 2);
public NodeId ControllerComponentId { get; } = new(1101, 2);
public NodeId ControllerComponentsFolderId { get; } = new(1102, 2);
public NodeId TaskControlsFolderId { get; } = new(1103, 2);
public NodeId SystemOperationId { get; } = new(1104, 2);
public NodeId SystemStateMachineId { get; } = new(1105, 2);
public NodeId ProgramsId { get; } = new(1106, 2);
public NodeId MotionDeviceId { get; } = new(1200, 2);
public NodeId AxesFolderId { get; } = new(1201, 2);
public NodeId PowerTrainsFolderId { get; } = new(1202, 2);
public NodeId AdditionalComponentsFolderId { get; } = new(1203, 2);
public NodeId AxisId { get; } = new(1300, 2);
public NodeId AxisPositionId { get; } = new(1301, 2);
public NodeId AxisSpeedId { get; } = new(1302, 2);
public NodeId AxisAccelerationId { get; } = new(1303, 2);
public NodeId AxisMotionProfileId { get; } = new(1304, 2);
public NodeId AxisLoadId { get; } = new(1305, 2);
public NodeId FlangeLoadId { get; } = new(1306, 2);
public NodeId PowerTrainId { get; } = new(1400, 2);
public NodeId MotorId { get; } = new(1401, 2);
public NodeId GearId { get; } = new(1402, 2);
public NodeId DriveId { get; } = new(1403, 2);
public NodeId SafetyId { get; } = new(1500, 2);
public NodeId EmergencyFunctionsFolderId { get; } = new(1501, 2);
public NodeId ProtectiveFunctionsFolderId { get; } = new(1502, 2);
public NodeId EmergencyFunctionId { get; } = new(1503, 2);
public NodeId ProtectiveFunctionId { get; } = new(1504, 2);
public NodeId TaskControlId { get; } = new(1600, 2);
public NodeId TaskControlOperationId { get; } = new(1601, 2);
public NodeId TaskControlStateMachineId { get; } = new(1602, 2);
public NodeId TaskModulesFolderId { get; } = new(1603, 2);
public NodeId TaskModuleId { get; } = new(1604, 2);
public NodeId ReadyStateId { get; } = new(1605, 2);
public NodeId ReadySubstateMachineId { get; } = new(1606, 2);
public List<CallMethodRequest> Calls { get; } = [];
public NodeId CurrentStateNode { get; } = new(1700, 2);
public NodeId CurrentStateIdNode { get; } = new(1701, 2);
public NodeId SystemCurrentStateId { get; } = new(1107, 2);
public NodeId TaskCurrentStateId { get; } = new(1607, 2);
public NodeId MotorTemperatureId { get; } = new(1404, 2);
public NodeId GearRatioId { get; } = new(1405, 2);
public NodeId AxisPositionUnitsId { get; } = new(1307, 2);
public EUInformation Celsius { get; } = Unit(4408652, "°C", "degree Celsius");
public EUInformation Degree { get; } = Unit(17476, "°", "degree");
public EUInformation DegreePerSecond { get; } = Unit(4536630, "°/s", "degree per second");
public EUInformation DegreePerSecondSquared { get; } = Unit(5059637, "°/s²", "degree per second squared");
public EUInformation Kilogram { get; } = Unit(4933453, "kg", "kilogram");
public void ConfigureCompleteTopology()
{
// The layout of OPC 40010-1: folders and non-parameter members with
// Robotics browse names, identification with DI browse names, process
// values below DI:ParameterSet. Decoys with the same name in namespace 0
// or on the wrong level catch a client that ignores either.
AddChild(SystemId, Rob(RoboticsBrowseNames.Controllers), ControllersFolderId);
AddChild(SystemId, Rob(RoboticsBrowseNames.MotionDevices), MotionDevicesFolderId);
AddChild(SystemId, Rob(RoboticsBrowseNames.SafetyStates), SafetyStatesFolderId);
AddBrowse(ControllersFolderId, [Ref(ControllerId, "Controller", RoboticsModel.ControllerType)]);
AddBrowse(MotionDevicesFolderId, [Ref(MotionDeviceId, "Motion", RoboticsModel.MotionDeviceType)]);
AddBrowse(SafetyStatesFolderId, [Ref(SafetyId, "Safety", ObjectTypes.SafetyStateType)]);
AddIdentification(SystemId, "System");
AddIdentification(ControllerId, "Controller");
AddChild(ControllerId, Rob(RoboticsBrowseNames.TaskControls), TaskControlsFolderId);
AddChild(ControllerId, Rob(RoboticsBrowseNames.Components), ControllerComponentsFolderId);
AddChild(ControllerId, Rob(RoboticsBrowseNames.Programs), ProgramsId);
AddBrowse(TaskControlsFolderId, [Ref(TaskControlId, "Task", ObjectTypes.TaskControlType)]);
AddBrowse(
ControllerComponentsFolderId,
[Ref(ControllerComponentId, "Component", ObjectTypes.AuxiliaryComponentType)]);
AddIdentification(MotionDeviceId, "Motion");
AddChild(MotionDeviceId, Rob(RoboticsBrowseNames.Axes), AxesFolderId);
AddChild(MotionDeviceId, Rob(RoboticsBrowseNames.PowerTrains), PowerTrainsFolderId);
AddChild(MotionDeviceId, Rob(RoboticsBrowseNames.AdditionalComponents), AdditionalComponentsFolderId);
AddChild(MotionDeviceId, Rob(RoboticsBrowseNames.FlangeLoad), FlangeLoadId);
AddValueChild(MotionDeviceId, Rob(RoboticsBrowseNames.MotionDeviceCategory),
(int)MotionDeviceCategoryEnumeration.ARTICULATED_ROBOT);
AddParameter(MotionDeviceId, RoboticsBrowseNames.SpeedOverride, 0.75d);
AddValueChild(MotionDeviceId, Ua(RoboticsBrowseNames.SpeedOverride), -1d);
AddBrowse(AxesFolderId, [Ref(AxisId, "Axis", RoboticsModel.AxisType)]);
AddBrowse(PowerTrainsFolderId, [Ref(PowerTrainId, "PowerTrain", ObjectTypes.PowerTrainType)]);
AddBrowse(AdditionalComponentsFolderId, []);
AddIdentification(AxisId, "Axis");
AddChild(AxisId, Rob(RoboticsBrowseNames.AdditionalLoad), AxisLoadId);
AddParameter(AxisId, RoboticsBrowseNames.ActualPosition, 12.5d, AxisPositionId);
AddParameter(AxisId, RoboticsBrowseNames.ActualSpeed, 3.0d, AxisSpeedId);
AddParameter(AxisId, RoboticsBrowseNames.ActualAcceleration, 1.5d, AxisAccelerationId);
AddValueChild(AxisId, Rob(RoboticsBrowseNames.ActualPosition), -1d);
AddValueChild(AxisId, Rob(RoboticsBrowseNames.MotionProfile), (int)AxisMotionProfileEnumeration.ROTARY,
AxisMotionProfileId);
// A unit per axis value, a range for position and speed only. The
// decoy below the axis has a unit of its own.
AddValueChild(AxisPositionId, Ua(Opc.Ua.BrowseNames.EngineeringUnits), Degree, AxisPositionUnitsId);
AddValueChild(AxisPositionId, Ua(Opc.Ua.BrowseNames.EURange), new Range(170, -170));
AddValueChild(AxisSpeedId, Ua(Opc.Ua.BrowseNames.EngineeringUnits), DegreePerSecond);
AddValueChild(AxisSpeedId, Ua(Opc.Ua.BrowseNames.EURange), new Range(250, -250));
AddValueChild(AxisAccelerationId, Ua(Opc.Ua.BrowseNames.EngineeringUnits), DegreePerSecondSquared);
AddValueChild(
Resolve(AxisId, Rob(RoboticsBrowseNames.ActualPosition)),
Ua(Opc.Ua.BrowseNames.EngineeringUnits),
Kilogram);
// The flange load's Mass has a unit and a range, the axis load's none.
AddLoad(AxisLoadId);
AddLoad(FlangeLoadId, Kilogram, new Range(20, 0));
AddIdentification(PowerTrainId, "PowerTrain");
// Motors and gears instantiate the <MotorIdentifier>/<GearIdentifier>
// placeholders, so a real server exposes them under their own browse
// names; nothing is reachable under the placeholder names.
AddComponent(PowerTrainId, Ref(MotorId, "Motor1", ObjectTypes.MotorType));
AddComponent(PowerTrainId, Ref(GearId, "Gear1", ObjectTypes.GearType));
AddIdentification(MotorId, "Motor");
// MotorTemperature with its unit and range, BrakeReleased; no
// EffectiveLoadRate. The same name directly below the motor is a decoy.
AddParameter(MotorId, RoboticsBrowseNames.MotorTemperature, 41.5d, MotorTemperatureId);
AddValueChild(MotorTemperatureId, Ua(Opc.Ua.BrowseNames.EngineeringUnits), Celsius);
AddValueChild(MotorTemperatureId, Ua(Opc.Ua.BrowseNames.EURange), new Range(120, -20));
AddParameter(MotorId, RoboticsBrowseNames.BrakeReleased, true);
AddValueChild(MotorId, Rob(RoboticsBrowseNames.MotorTemperature), -1d);
AddIdentification(GearId, "Gear");
AddValueChild(GearId, Rob(RoboticsBrowseNames.Pitch), 1.0d);
AddValueChild(
GearId,
Rob(RoboticsBrowseNames.GearRatio),
new RationalNumber { Numerator = 160, Denominator = 1 },
GearRatioId);
AddValueChild(
GearId,
Ua(RoboticsBrowseNames.GearRatio),
new RationalNumber { Numerator = 1, Denominator = 1 });
AddIdentification(DriveId, "Drive");
AddIdentification(SafetyId, "Safety");
AddChild(SafetyId, Rob(RoboticsBrowseNames.EmergencyStopFunctions), EmergencyFunctionsFolderId);
AddChild(SafetyId, Rob(RoboticsBrowseNames.ProtectiveStopFunctions), ProtectiveFunctionsFolderId);
AddParameter(SafetyId, RoboticsBrowseNames.EmergencyStop, false);
AddParameter(SafetyId, RoboticsBrowseNames.OperationalMode, 1);
AddParameter(SafetyId, RoboticsBrowseNames.ProtectiveStop, true);
AddBrowse(
EmergencyFunctionsFolderId,
[Ref(EmergencyFunctionId, "EStop", ObjectTypes.EmergencyStopFunctionType)]);
AddBrowse(
ProtectiveFunctionsFolderId,
[Ref(ProtectiveFunctionId, "PStop", ObjectTypes.ProtectiveStopFunctionType)]);
AddSafetyFunction(EmergencyFunctionId, "EStop");
AddSafetyFunction(ProtectiveFunctionId, "PStop");
AddIdentification(TaskControlId, "TaskControl");
AddChild(TaskControlId, Rob(RoboticsBrowseNames.TaskControlOperation), TaskControlOperationId);
AddChild(TaskControlId, Rob(RoboticsBrowseNames.TaskModules), TaskModulesFolderId);
AddParameter(TaskControlId, RoboticsBrowseNames.ExecutionMode, 1);
AddParameter(TaskControlId, RoboticsBrowseNames.TaskProgramLoaded, true);
AddParameter(TaskControlId, RoboticsBrowseNames.TaskProgramName, "Program");
AddBrowse(TaskModulesFolderId, [Ref(TaskModuleId, "Module", ObjectTypes.TaskModuleType)]);
AddValueChild(TaskModuleId, Rob(RoboticsBrowseNames.Name), "Module");
AddValueChild(TaskModuleId, Rob(RoboticsBrowseNames.Version), "1.0");
AddValueChild(TaskModuleId, Rob(RoboticsBrowseNames.IsReferenced), true);
AddChild(ControllerId, Rob(RoboticsBrowseNames.SystemOperation), SystemOperationId);
AddOperationState(
SystemOperationId,
RoboticsBrowseNames.SystemOperationStateMachine,
SystemStateMachineId,
SystemCurrentStateId,
RoboticsBrowseNames.Executing);
AddOperationState(
TaskControlOperationId,
RoboticsBrowseNames.TaskControlStateMachine,
TaskControlStateMachineId,
TaskCurrentStateId,
RoboticsBrowseNames.Ready);
AddRelationship(ControllerId, ReferenceTypes.Controls, MotionDeviceId);
AddRelationship(ControllerId, ReferenceTypes.HasSafetyStates, SafetyId);
AddRelationship(PowerTrainId, ReferenceTypes.Moves, AxisId);
AddRelationship(AxisId, ReferenceTypes.Requires, PowerTrainId);
AddRelationship(PowerTrainId, ReferenceTypes.HasSlave, PowerTrainId);
AddRelationship(MotorId, ReferenceTypes.IsDrivenBy, DriveId);
AddRelationship(MotionDeviceId, ReferenceTypes.IsConnectedTo, ControllerId);
}
public void ConfigureSystemOperation(StatusCode statusCode, int output)
{
m_callStatus = statusCode;
m_callOutput = output;
AddChild(SystemOperationId, Rob(RoboticsBrowseNames.SystemOperationStateMachine), SystemStateMachineId);
AddStateReads(SystemStateMachineId, RoboticsBrowseNames.Ready);
}
public void ConfigureTaskControl(StatusCode statusCode, int output, bool readySubstateMachine = true)
{
m_callStatus = statusCode;
m_callOutput = output;
AddChild(TaskControlId, Rob(RoboticsBrowseNames.TaskControlOperation), TaskControlOperationId);
AddChild(
TaskControlOperationId,
Rob(RoboticsBrowseNames.TaskControlStateMachine),
TaskControlStateMachineId);
AddChild(TaskControlStateMachineId, Rob(RoboticsBrowseNames.Ready), ReadyStateId);
if (readySubstateMachine)
{
AddChild(
TaskControlStateMachineId,
Rob(RoboticsBrowseNames.ReadySubstateMachine),
ReadySubstateMachineId);
}
AddStateReads(TaskControlStateMachineId, RoboticsBrowseNames.Ready);
}
/// <summary>
/// Registers an operation state machine below its operation object with
/// a CurrentState variable that names <paramref name="stateName"/>; no
/// value when <paramref name="stateName"/> is null, so its read fails.
/// CurrentState has no Id property until <see cref="SetCurrentStateId"/>
/// adds one, so the client falls back to the state name.
/// </summary>
public void AddOperationState(
NodeId operation,
string stateMachineBrowseName,
NodeId stateMachine,
NodeId currentState,
string? stateName)
{
AddChild(operation, Rob(stateMachineBrowseName), stateMachine);
AddChild(stateMachine, Ua(Opc.Ua.BrowseNames.CurrentState), currentState);
if (stateName == null)
{
m_values.Remove(currentState);
}
else
{
m_values[currentState] = Variant.From(new LocalizedText(stateName));
}
}
/// <summary>
/// The node the harness registered at <paramref name="path"/> below
/// <paramref name="start"/>, to compare a snapshot id against.
/// </summary>
public NodeId Resolve(NodeId start, params QualifiedName[] path)
{
NodeId current = start;
foreach (QualifiedName name in path)
{
current = m_children[(current, name)];
}
return current;
}
/// <summary>
/// Makes every read of <paramref name="node"/> return its value with a bad status.
/// </summary>
public void SetBadQuality(NodeId node)
{
m_badQualityNodes.Add(node);
}
/// <summary>
/// Registers the Idle, Ready and Executing state objects of an operation state
/// machine and points its CurrentState/Id at <paramref name="activeState"/>, so
/// the state is known by NodeId whatever text the server localizes it to.
/// </summary>
public void AddStateNodes(
NodeId stateMachine,
NodeId currentState,
NodeId idle,
NodeId ready,
NodeId executing,
NodeId activeState)
{
AddChild(stateMachine, Rob(RoboticsBrowseNames.Idle), idle);
AddChild(stateMachine, Rob(RoboticsBrowseNames.Ready), ready);
AddChild(stateMachine, Rob(RoboticsBrowseNames.Executing), executing);
SetCurrentStateId(currentState, activeState);
}
/// <summary>
/// Publishes the CurrentState/Id property of <paramref name="currentState"/>
/// holding <paramref name="stateId"/>, the NodeId of the active state.
/// </summary>
public void SetCurrentStateId(NodeId currentState, NodeId stateId)
{
AddValueChild(currentState, Ua(Opc.Ua.BrowseNames.Id), stateId);
}
/// <summary>
/// Drops the value of <paramref name="nodeId"/>: the node still resolves,
/// but its read fails with Bad_NodeIdUnknown.
/// </summary>
public void FailRead(NodeId nodeId)
{
m_values.Remove(nodeId);
}
public void AddStateReads(NodeId stateMachine, string stateName)
{
AddChild(stateMachine, Ua(Opc.Ua.BrowseNames.CurrentState), CurrentStateNode);
AddChild(CurrentStateNode, Ua(Opc.Ua.BrowseNames.Id), CurrentStateIdNode);
m_values[CurrentStateNode] = Variant.From(new LocalizedText(stateName));
m_values[CurrentStateIdNode] = Variant.From(new NodeId(999, 2));
}
public Mock<IStreamingSubscription> Streaming(NodeId nodeId)
{
var streaming = new Mock<IStreamingSubscription>();
streaming.Setup(s => s.SubscribeDataChangesAsync(
It.IsAny<NodeId>(), It.IsAny<Opc.Ua.Client.Subscriptions.MonitoredItems.MonitoredItemOptions>(),
It.IsAny<CancellationToken>()))
.Returns(SingleChange(nodeId));
streaming.Setup(s => s.SubscribeDataChangesAsync(
It.IsAny<IReadOnlyList<NodeId>>(),
It.IsAny<Opc.Ua.Client.Subscriptions.MonitoredItems.MonitoredItemOptions>(),
It.IsAny<CancellationToken>()))
.Returns(SingleChange(nodeId));
return streaming;
}
public ReferenceDescription Ref(NodeId nodeId, string browseName, uint typeId)
{
return new ReferenceDescription
{
NodeId = new ExpandedNodeId(nodeId),
BrowseName = new QualifiedName(browseName, 2),
DisplayName = new LocalizedText(browseName),
NodeClass = NodeClass.Object,
TypeDefinition = new ExpandedNodeId(new NodeId(typeId, RoboticsNamespaceIndex)),
ReferenceTypeId = Opc.Ua.ReferenceTypeIds.HierarchicalReferences,
IsForward = true
};
}
public void AddBrowse(NodeId folder, IReadOnlyList<ReferenceDescription> references)
{
m_browse[folder] = [.. references];
}
public void AddComponent(NodeId parent, ReferenceDescription reference)
{
if (!m_browse.TryGetValue(parent, out List<ReferenceDescription>? list))
{
list = [];
m_browse[parent] = list;
}
reference.ReferenceTypeId = Opc.Ua.ReferenceTypeIds.HasComponent;
list.Add(reference);
}
public void EnableBrowseContinuationFor(NodeId folder)
{
m_continuationNodes.Add(folder);
}
public ushort RoboticsNamespaceIndex =>
(ushort)NamespaceUris.GetIndex(global::Opc.Ua.Robotics.Namespaces.Robotics);
public QualifiedName Rob(string name)
{
return new QualifiedName(name, RoboticsNamespaceIndex);
}
public QualifiedName Di(string name)
{
return new QualifiedName(name, (ushort)NamespaceUris.GetIndex(Opc.Ua.Di.Namespaces.OpcUaDi));
}
public static QualifiedName Ua(string name)
{
return new QualifiedName(name);
}
/// <summary>
/// Registers a child under its full browse name, namespace included:
/// a path resolves only when every element names the child exactly.
/// </summary>
public void AddChild(NodeId parent, QualifiedName browseName, NodeId child)
{
m_children[(parent, browseName)] = child;
}
private void AddValueChild(NodeId parent, QualifiedName browseName, object value)
{
AddValueChild(
parent, browseName, value,
new NodeId((uint)Math.Abs(HashCode.Combine(parent, browseName)), 2));
}
private void AddValueChild(NodeId parent, QualifiedName browseName, object value, NodeId nodeId)
{
AddChild(parent, browseName, nodeId);
m_values[nodeId] = ToVariant(value);
}
/// <summary>
/// Registers a process value below the DI ParameterSet of its component.
/// </summary>
private void AddParameter(NodeId parent, string robName, object value, NodeId nodeId = default)
{
if (!m_parameterSets.TryGetValue(parent, out NodeId parameterSet))
{
parameterSet = new NodeId((uint)Math.Abs(HashCode.Combine(parent, "ParameterSet")), 2);
m_parameterSets[parent] = parameterSet;
AddChild(parent, Di(DiBrowseNames.ParameterSet), parameterSet);
}
if (nodeId.IsNull)
{
AddValueChild(parameterSet, Rob(robName), value);
}
else
{
AddValueChild(parameterSet, Rob(robName), value, nodeId);
}
}
private void AddIdentification(NodeId nodeId, string name)
{
AddValueChild(nodeId, Di(DiBrowseNames.ComponentName), new LocalizedText(name));
AddValueChild(nodeId, Di(DiBrowseNames.AssetId), name + "Asset");
AddValueChild(nodeId, Di(DiBrowseNames.Manufacturer), new LocalizedText("OPC"));
AddValueChild(nodeId, Di(DiBrowseNames.Model), new LocalizedText(name + "Model"));
AddValueChild(nodeId, Di(DiBrowseNames.ProductCode), name + "Code");
AddValueChild(nodeId, Di(DiBrowseNames.SerialNumber), name + "Serial");
AddValueChild(nodeId, Di(DiBrowseNames.DeviceManual), name + "Manual");
AddValueChild(nodeId, Di(DiBrowseNames.HardwareRevision), name + "Hardware");
AddValueChild(nodeId, Di(DiBrowseNames.SoftwareRevision), name + "Software");
AddValueChild(nodeId, Di(DiBrowseNames.ManufacturerUri), "urn:opc:" + name);
AddValueChild(nodeId, Di(DiBrowseNames.ProductInstanceUri), "urn:opc:" + name + ":1");
AddValueChild(nodeId, Ua(DiBrowseNames.SerialNumber), "wrong namespace");
AddValueChild(nodeId, Ua(DiBrowseNames.SoftwareRevision), "wrong namespace");
}
private void AddLoad(NodeId load, EUInformation? units = null, Range? range = null)
{
AddValueChild(load, Rob(RoboticsBrowseNames.Mass), 10.0d);
NodeId mass = Resolve(load, Rob(RoboticsBrowseNames.Mass));
if (units != null)
{
AddValueChild(mass, Ua(Opc.Ua.BrowseNames.EngineeringUnits), units);
}
if (range != null)
{
AddValueChild(mass, Ua(Opc.Ua.BrowseNames.EURange), range);
}
AddValueChild(load, Rob(RoboticsBrowseNames.CenterOfMass), "center");
AddValueChild(load, Rob(RoboticsBrowseNames.Inertia), "inertia");
}
private void AddSafetyFunction(NodeId nodeId, string name)
{
AddValueChild(nodeId, Rob(RoboticsBrowseNames.Name), name);
AddValueChild(nodeId, Rob(RoboticsBrowseNames.Active), true);
AddValueChild(nodeId, Rob(RoboticsBrowseNames.Enabled), true);
}
private void AddRelationship(NodeId source, uint referenceType, NodeId target)
{
NodeId referenceTypeId = new(
referenceType,
(ushort)NamespaceUris.GetIndex(global::Opc.Ua.Robotics.Namespaces.Robotics));
if (!m_browse.TryGetValue(source, out List<ReferenceDescription>? list))
{
list = [];
m_browse[source] = list;
}
list.Add(new ReferenceDescription
{
NodeId = new ExpandedNodeId(target),
BrowseName = new QualifiedName("rel", 2),
DisplayName = new LocalizedText("rel"),
ReferenceTypeId = referenceTypeId,
IsForward = true,
NodeClass = NodeClass.Object
});
}
private void SetupTranslate()
{
Session.Setup(s => s.TranslateBrowsePathsToNodeIdsAsync(
It.IsAny<RequestHeader>(), It.IsAny<ArrayOf<BrowsePath>>(), It.IsAny<CancellationToken>()))
.Returns<RequestHeader, ArrayOf<BrowsePath>, CancellationToken>((_, paths, _) =>
{
var results = new List<BrowsePathResult>();
for (int ii = 0; ii < paths.Count; ii++)
{
BrowsePath path = paths[ii];
NodeId current = path.StartingNode;
bool found = true;
for (int jj = 0; jj < path.RelativePath.Elements.Count; jj++)
{
QualifiedName name = path.RelativePath.Elements[jj].TargetName;
if (!m_children.TryGetValue((current, name), out NodeId next))
{
found = false;
break;
}
current = next;
}
results.Add(found ? GoodPath(current) : BadPath());
}
return new ValueTask<TranslateBrowsePathsToNodeIdsResponse>(
new TranslateBrowsePathsToNodeIdsResponse
{
ResponseHeader = new ResponseHeader(),
Results = results.ToArrayOf(),
DiagnosticInfos = default
});
});
}
private void SetupBrowse()
{
Session.Setup(s => s.BrowseAsync(
It.IsAny<RequestHeader>(), It.IsAny<ViewDescription>(), It.IsAny<uint>(),
It.IsAny<ArrayOf<BrowseDescription>>(), It.IsAny<CancellationToken>()))
.Returns<RequestHeader, ViewDescription, uint, ArrayOf<BrowseDescription>, CancellationToken>(
(_, _, _, descriptions, _) =>
{
var results = new List<BrowseResult>(descriptions.Count);
for (int ii = 0; ii < descriptions.Count; ii++)
{
BrowseDescription description = descriptions[ii];
List<ReferenceDescription> refs = m_browse.TryGetValue(
description.NodeId, out List<ReferenceDescription>? value)
? value.Where(r =>
description.ReferenceTypeId.IsNull ||
r.ReferenceTypeId == description.ReferenceTypeId ||
description.ReferenceTypeId == Opc.Ua.ReferenceTypeIds.References ||
description.ReferenceTypeId ==
Opc.Ua.ReferenceTypeIds.HierarchicalReferences).ToList()
: [];
bool continuation = m_continuationNodes.Remove(description.NodeId) && refs.Count > 0;
results.Add(new BrowseResult
{
StatusCode = StatusCodes.Good,
References = continuation ? [refs[0]] : refs.ToArrayOf(),
ContinuationPoint = continuation ? new ByteString(new byte[] { 1 }) : default
});
}
return new ValueTask<BrowseResponse>(new BrowseResponse
{
ResponseHeader = new ResponseHeader(),
Results = results.ToArrayOf(),
DiagnosticInfos = default
});
});
Session.Setup(s => s.BrowseNextAsync(
It.IsAny<RequestHeader>(), false, It.IsAny<ArrayOf<ByteString>>(), It.IsAny<CancellationToken>()))
.Returns(new ValueTask<BrowseNextResponse>(new BrowseNextResponse
{
ResponseHeader = new ResponseHeader(),
Results = [new BrowseResult { StatusCode = StatusCodes.Good, References = [] }],
DiagnosticInfos = default
}));
}
private void SetupRead()
{
Session.Setup(s => s.ReadAsync(
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
It.IsAny<ArrayOf<ReadValueId>>(), It.IsAny<CancellationToken>()))
.Returns<RequestHeader, double, TimestampsToReturn, ArrayOf<ReadValueId>, CancellationToken>(
(_, _, _, nodes, _) =>
{
var values = new List<DataValue>();
for (int ii = 0; ii < nodes.Count; ii++)
{
ReadValueId node = nodes[ii];
if (node.AttributeId == Attributes.BrowseName)
{
values.Add(Value(new QualifiedName(
"Browse" + ii.ToString(System.Globalization.CultureInfo.InvariantCulture), 2)));
}
else if (node.AttributeId == Attributes.DisplayName)
{
values.Add(Value(new LocalizedText(
"Display" + ii.ToString(System.Globalization.CultureInfo.InvariantCulture))));
}
else
{
values.Add(m_values.TryGetValue(node.NodeId, out Variant variant)
? m_badQualityNodes.Contains(node.NodeId)
? new DataValue(variant, StatusCodes.BadSensorFailure)
: Value(variant)
: new DataValue(Variant.Null, StatusCodes.BadNodeIdUnknown));
}
}
return new ValueTask<ReadResponse>(new ReadResponse
{
ResponseHeader = new ResponseHeader(),
Results = values.ToArrayOf(),
DiagnosticInfos = default
});
});
}
private void SetupCall()
{
Session.Setup(s => s.CallAsync(
It.IsAny<RequestHeader>(), It.IsAny<ArrayOf<CallMethodRequest>>(), It.IsAny<CancellationToken>()))
.Returns<RequestHeader, ArrayOf<CallMethodRequest>, CancellationToken>((_, requests, _) =>
{
Calls.AddRange(requests.ToList());
return new ValueTask<CallResponse>(new CallResponse
{
ResponseHeader = new ResponseHeader(),
Results =
[
new CallMethodResult
{
StatusCode = m_callStatus,
OutputArguments = [Variant.From(m_callOutput)]
}
],
DiagnosticInfos = default
});
});
}
private static async IAsyncEnumerable<DataValueChange> SingleChange(NodeId nodeId)
{
yield return new DataValueChange(null, Value(nodeId), null);
await Task.CompletedTask.ConfigureAwait(false);
}
private static BrowsePathResult GoodPath(NodeId nodeId)
{
return new BrowsePathResult
{
StatusCode = StatusCodes.Good,
Targets = [new BrowsePathTarget { TargetId = new ExpandedNodeId(nodeId) }]
};
}
private static BrowsePathResult BadPath()
{
return new BrowsePathResult { StatusCode = StatusCodes.BadNoMatch, Targets = [] };
}
/// <summary>
/// A UNECE unit; each test unit has its own UnitId, so a value read from
/// the wrong variable shows.
/// </summary>
private static EUInformation Unit(int unitId, string symbol, string name)
{
return new EUInformation
{
NamespaceUri = "http://www.opcfoundation.org/UA/units/un/cefact",
UnitId = unitId,
DisplayName = new LocalizedText("en", symbol),
Description = new LocalizedText("en", name)
};
}
private static DataValue Value(object value)
{
return Value(ToVariant(value));
}
private static DataValue Value(Variant value)
{
return new DataValue(value, StatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow);
}
private static Variant ToVariant(object value)
{
return value switch
{
bool b => Variant.From(b),
int i => Variant.From(i),
ushort u => Variant.From(u),
double d => Variant.From(d),
string s => Variant.From(s),
NodeId n => Variant.From(n),
QualifiedName q => Variant.From(q),
LocalizedText l => Variant.From(l),
IEncodeable e => Variant.FromStructure(e),
_ => Variant.Null
};
}
}
}
}