Robotics client: expose value NodeIds, operation states and the missing identification and motor fields (#4619)

# Description

A Robotics snapshot returns the values it read, but not the variables
they came from. A client that shows a snapshot and wants live updates
has to resolve every value again before it can subscribe. This PR adds
that information to the snapshots, along with the operation state and
the DI and motor fields the contracts did not cover. Every addition is
an init property with a default; no existing member changes.

- **Value NodeIds:** every snapshot value comes with the NodeId of its
variable, or `NodeId.Null` when the server does not publish it:
-
`AxisStateSnapshot.ActualPositionId`/`ActualSpeedId`/`ActualAccelerationId`
- `MotionDeviceSnapshot.SpeedOverrideId`, `LoadSnapshot.MassId`,
`GearSnapshot.PitchId`, `TaskModuleSnapshot.IsReferencedId`
-
`SafetyStateSnapshot.EmergencyStopId`/`OperationalModeId`/`ProtectiveStopId`,
`SafetyFunctionSnapshot.ActiveId`/`EnabledId`
-
`TaskControlSnapshot.ExecutionModeId`/`TaskProgramLoadedId`/`TaskProgramNameId`

Each id comes from the path resolution the value read already does, so
it costs no extra request.
- **Operation state:** `ControllerSnapshot.SystemOperationId`.
`ControllerSnapshot` and `TaskControlSnapshot` add `CurrentStateId`, the
`CurrentState` variable of the SystemOperationStateMachine or
TaskControlStateMachine, and `CurrentState`, the
`RoboticsOperationState` it named when read. `CurrentState` is `null`
when the state machine is absent, its read fails, or it names a state
other than Idle, Ready or Executing; the rest of the snapshot is still
returned.
- **Identification:**
`RoboticsComponentIdentification.HardwareRevision`, `SoftwareRevision`,
`ManufacturerUri`, `ProductInstanceUri` (DI properties).
- **Motor:** `MotorSnapshot.MotorTemperature` with
`MotorTemperatureEngineering` (EngineeringUnits, EURange),
`BrakeReleased` and `EffectiveLoadRate`, each with its NodeId. They are
read from the motor's DI `ParameterSet` with one
TranslateBrowsePathsToNodeIds and one Read per motor.
- The reads use the path helpers from #4609 and the generated proxies;
the per-node structure is unchanged. `docs/Robotics.md` describes the
new members with an example.

Tests:

- **`RoboticsClientServerE2eTests`** (hosted server over opc.tcp). The
fixture now publishes the identification revisions and URIs, the motor's
unit, range and load rate, the gear pitch, and a SystemOperation that
starts in Ready. The new tests:
- compare the ids with the server's own nodes, and check that reading
each id returns the snapshot value;
  - subscribe to an `ActualPositionId` and receive changing values;
- follow the task control's `CurrentState` through LoadByName and
UnloadProgram;
  - read the identification and motor fields.
- **`RoboticsClientFacadeTests`:** check the ids against the
ParameterSet children next to same-named decoys, read `CurrentState`
alongside its `Id` child, and cover an unknown state, a failing
CurrentState read, and a controller and task control without operations.
- On the base branch, the new tests do not compile because the members
are missing.

Local results:
- `UA.slnx` Release builds on all target frameworks with no new
warnings.
- `Opc.Ua.Robotics.Tests`: 650/650 on net10.0 and 590/590 on net8.0.
- Changed-line coverage against #4609: 100 % (168/168,
`check-coverage.ps1`).
- Fork CI (full matrix incl. macOS) and CodeQL on the current head
(`3dbb91392`, with master merged in): green — [CI run
36976259619](https://github.com/biancode/UA-.NETStandard/actions/runs/36976259619)
(189 jobs), [CodeQL run
36976262134](https://github.com/biancode/UA-.NETStandard/actions/runs/36976262134).

## Related Issues

- Fixes #4618

## 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
suites on net10.0 and net8.0 locally on macOS; .NET Framework via the
fork CI's Windows 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>
This commit is contained in:
Klaus Landsdorf
2026-10-03 18:14:23 +02:00
committed by GitHub
parent 5aa38a6bfa
commit c801ac1d64
15 changed files with 1197 additions and 41 deletions
+52
View File
@@ -651,6 +651,58 @@ the session:
`GearType`, subtypes included). A motor's drives are found through its
`IsDrivenBy` references; a drive that drives several motors is listed once.
The values a client typically shows or follows come with the NodeId of the
variable they were read from (`AxisStateSnapshot.ActualPositionId`,
`MotionDeviceSnapshot.SpeedOverrideId`, `SafetyStateSnapshot.EmergencyStopId`,
`TaskControlSnapshot.TaskProgramNameId`, `MotorSnapshot.MotorTemperatureId`,
`LoadSnapshot.MassId`, `GearSnapshot.PitchId`, …), or `NodeId.Null` when the
server does not publish the variable. A client can subscribe to such a value
without resolving the browse paths again. Not every value in a snapshot has an
id: `LoadSnapshot.CenterOfMass` and `Inertia`, `AxisSnapshot.MotionProfile`,
`MotionDeviceSnapshot.Category` and the engineering units and range of a
motor temperature are read once and carry no NodeId.
`ControllerSnapshot` and `TaskControlSnapshot` also carry the state of their
operation state machine: `CurrentStateId` is the `CurrentState` variable of the
`SystemOperationStateMachine` or `TaskControlStateMachine`, and `CurrentState`
is the `RoboticsOperationState` it named when read. The state is told by the
NodeId in `CurrentState/Id`, so it does not depend on the language the server
localizes the state name to. The Id is matched against the `Idle`, `Ready` and
`Executing` states the Robotics model declares on
`SystemOperationStateMachineType` and `TaskControlStateMachineType` (the
NodeIds a server publishes when it does not instantiate the states, mapped to
the session's index of the Robotics namespace), and against state objects a
server instantiates below the machine. The state name is used only when
`CurrentState` has no `Id` property. `CurrentState` is `null` when the state
machine is absent, the read of `CurrentState` failed, its `Id` cannot be read
or names a state other than `Idle`, `Ready` or `Executing`, or, without an
`Id`, the name is another state's; the rest of the snapshot is still
returned. `ControllerSnapshot.SystemOperationId` is the SystemOperation object.
`RoboticsComponentIdentification` includes the DI `HardwareRevision`,
`SoftwareRevision`, `ManufacturerUri` and `ProductInstanceUri`, and
`MotorSnapshot` the motor's `MotorTemperature` (with its `EngineeringUnits`
and `EURange` in `MotorTemperatureEngineering`), `BrakeReleased` and
`EffectiveLoadRate`, all read below the motor's DI `ParameterSet`.
```csharp
AxisSnapshot axis = await robots.ReadAxisAsync(axisNodeId, ct);
if (!axis.State.ActualPositionId.IsNull)
{
await foreach (DataValueChange change in streaming.SubscribeDataChangesAsync(
axis.State.ActualPositionId, null, ct))
{
// The ActualPosition value as it changes.
}
}
TaskControlSnapshot task = await robots.ReadTaskControlAsync(taskControlNodeId, ct);
if (task.CurrentState == RoboticsOperationState.Ready)
{
// A program is loaded; subscribe to task.CurrentStateId to follow the state.
}
```
Every discovery method returns `ArrayOf<NodeId>` and uses `ManagedBrowseAsync`,
so a server that caps references per node cannot silently truncate the result.
When the server does not expose the Robotics namespace, discovery returns an
@@ -37,6 +37,7 @@ using Opc.Ua.Client.Subscriptions;
using Opc.Ua.Client.Subscriptions.Streaming;
using DiBrowseNames = Opc.Ua.Di.BrowseNames;
using RoboticsBrowseNames = Opc.Ua.Robotics.BrowseNames;
using UaBrowseNames = Opc.Ua.BrowseNames;
namespace Opc.Ua.Robotics.Client
{
@@ -222,6 +223,14 @@ namespace Opc.Ua.Robotics.Client
.ConfigureAwait(false);
FolderTypeClient? components = await proxy.GetComponentsAsync(Telemetry, cancellationToken)
.ConfigureAwait(false);
SystemOperationTypeClient? operation = await proxy.GetSystemOperationAsync(Telemetry, cancellationToken)
.ConfigureAwait(false);
NodeId operationId = operation?.ObjectId ?? NodeId.Null;
(NodeId currentStateId, RoboticsOperationState? currentState) = await ReadOperationStateAsync(
operationId,
RoboticsBrowseNames.SystemOperationStateMachine,
s_systemOperationStates,
cancellationToken).ConfigureAwait(false);
return new ControllerSnapshot
{
Identification = await ReadIdentificationAsync(controller, cancellationToken).ConfigureAwait(false),
@@ -230,7 +239,10 @@ namespace Opc.Ua.Robotics.Client
: await BrowseChildNodeIdsAsync(taskControls.ObjectId, cancellationToken).ConfigureAwait(false),
ComponentIds = components == null
? []
: await BrowseChildNodeIdsAsync(components.ObjectId, cancellationToken).ConfigureAwait(false)
: await BrowseChildNodeIdsAsync(components.ObjectId, cancellationToken).ConfigureAwait(false),
SystemOperationId = operationId,
CurrentStateId = currentStateId,
CurrentState = currentState
};
}
@@ -262,6 +274,7 @@ namespace Opc.Ua.Robotics.Client
.ConfigureAwait(false),
SpeedOverride = speed.IsNull ? DataValue.Null : await Session.ReadValueAsync(speed, cancellationToken)
.ConfigureAwait(false),
SpeedOverrideId = speed,
AxisIds = axes == null ? [] : await BrowseChildNodeIdsAsync(axes.ObjectId, cancellationToken)
.ConfigureAwait(false),
PowerTrainIds = powerTrains == null
@@ -308,18 +321,24 @@ namespace Opc.Ua.Robotics.Client
.ConfigureAwait(false);
FolderTypeClient? protective = await proxy.GetProtectiveStopFunctionsAsync(Telemetry, cancellationToken)
.ConfigureAwait(false);
(NodeId emergencyStopId, DataValue emergencyStop) = await ReadChildAsync(
safetyState, ParameterPath(RoboticsBrowseNames.EmergencyStop), cancellationToken)
.ConfigureAwait(false);
(NodeId operationalModeId, DataValue operationalMode) = await ReadChildAsync(
safetyState, ParameterPath(RoboticsBrowseNames.OperationalMode), cancellationToken)
.ConfigureAwait(false);
(NodeId protectiveStopId, DataValue protectiveStop) = await ReadChildAsync(
safetyState, ParameterPath(RoboticsBrowseNames.ProtectiveStop), cancellationToken)
.ConfigureAwait(false);
return new SafetyStateSnapshot
{
Identification = await ReadIdentificationAsync(safetyState, cancellationToken).ConfigureAwait(false),
EmergencyStop = await ReadChildValueAsync(
safetyState, ParameterPath(RoboticsBrowseNames.EmergencyStop), cancellationToken)
.ConfigureAwait(false),
OperationalMode = await ReadChildValueAsync(
safetyState, ParameterPath(RoboticsBrowseNames.OperationalMode), cancellationToken)
.ConfigureAwait(false),
ProtectiveStop = await ReadChildValueAsync(
safetyState, ParameterPath(RoboticsBrowseNames.ProtectiveStop), cancellationToken)
.ConfigureAwait(false),
EmergencyStop = emergencyStop,
EmergencyStopId = emergencyStopId,
OperationalMode = operationalMode,
OperationalModeId = operationalModeId,
ProtectiveStop = protectiveStop,
ProtectiveStopId = protectiveStopId,
EmergencyStopFunctions = emergency == null
? []
: await ReadSafetyFunctionsAsync(emergency.ObjectId, cancellationToken).ConfigureAwait(false),
@@ -341,22 +360,35 @@ namespace Opc.Ua.Robotics.Client
Telemetry, cancellationToken).ConfigureAwait(false);
FolderTypeClient? modules = await proxy.GetTaskModulesAsync(Telemetry, cancellationToken)
.ConfigureAwait(false);
(NodeId executionModeId, DataValue executionMode) = await ReadChildAsync(
taskControl, ParameterPath(RoboticsBrowseNames.ExecutionMode), cancellationToken).ConfigureAwait(false);
(NodeId programLoadedId, DataValue programLoaded) = await ReadChildAsync(
taskControl, ParameterPath(RoboticsBrowseNames.TaskProgramLoaded), cancellationToken)
.ConfigureAwait(false);
(NodeId programNameId, DataValue programName) = await ReadChildAsync(
taskControl, ParameterPath(RoboticsBrowseNames.TaskProgramName), cancellationToken)
.ConfigureAwait(false);
NodeId operationId = operation?.ObjectId ?? NodeId.Null;
(NodeId currentStateId, RoboticsOperationState? currentState) = await ReadOperationStateAsync(
operationId,
RoboticsBrowseNames.TaskControlStateMachine,
s_taskControlStates,
cancellationToken).ConfigureAwait(false);
return new TaskControlSnapshot
{
Identification = await ReadIdentificationAsync(taskControl, cancellationToken).ConfigureAwait(false),
ExecutionMode = await ReadChildValueAsync(
taskControl, ParameterPath(RoboticsBrowseNames.ExecutionMode), cancellationToken)
.ConfigureAwait(false),
TaskProgramLoaded = await ReadChildValueAsync(
taskControl, ParameterPath(RoboticsBrowseNames.TaskProgramLoaded), cancellationToken)
.ConfigureAwait(false),
TaskProgramName = await ReadChildValueAsync(
taskControl, ParameterPath(RoboticsBrowseNames.TaskProgramName), cancellationToken)
.ConfigureAwait(false),
TaskControlOperationId = operation?.ObjectId ?? NodeId.Null,
ExecutionMode = executionMode,
ExecutionModeId = executionModeId,
TaskProgramLoaded = programLoaded,
TaskProgramLoadedId = programLoadedId,
TaskProgramName = programName,
TaskProgramNameId = programNameId,
TaskControlOperationId = operationId,
TaskModuleIds = modules == null
? []
: await BrowseChildNodeIdsAsync(modules.ObjectId, cancellationToken).ConfigureAwait(false)
: await BrowseChildNodeIdsAsync(modules.ObjectId, cancellationToken).ConfigureAwait(false),
CurrentStateId = currentStateId,
CurrentState = currentState
};
}
@@ -369,6 +401,7 @@ namespace Opc.Ua.Robotics.Client
NodeId = load,
Mass = mass.IsNull ? DataValue.Null : await Session.ReadValueAsync(mass, cancellationToken)
.ConfigureAwait(false),
MassId = mass,
CenterOfMass = await ReadChildValueAsync(
load, MemberPath(RoboticsBrowseNames.CenterOfMass), cancellationToken).ConfigureAwait(false),
Inertia = await ReadChildValueAsync(load, MemberPath(RoboticsBrowseNames.Inertia), cancellationToken)
@@ -458,19 +491,43 @@ namespace Opc.Ua.Robotics.Client
private async Task<MotorSnapshot> ReadMotorAsync(NodeId motor, CancellationToken cancellationToken)
{
QualifiedName[] temperature = ParameterPath(RoboticsBrowseNames.MotorTemperature);
(ArrayOf<NodeId> nodes, ArrayOf<DataValue> values) = await ReadChildrenAsync(
motor,
[
temperature,
UaChildPath(temperature, UaBrowseNames.EngineeringUnits),
UaChildPath(temperature, UaBrowseNames.EURange),
ParameterPath(RoboticsBrowseNames.BrakeReleased),
ParameterPath(RoboticsBrowseNames.EffectiveLoadRate)
],
cancellationToken).ConfigureAwait(false);
return new MotorSnapshot
{
Identification = await ReadIdentificationAsync(motor, cancellationToken).ConfigureAwait(false)
Identification = await ReadIdentificationAsync(motor, cancellationToken).ConfigureAwait(false),
MotorTemperature = values[0],
MotorTemperatureId = nodes[0],
MotorTemperatureEngineering = new RoboticsEngineeringValue
{
EngineeringUnits = ToStructure<EUInformation>(values[1]),
Range = ToStructure<Range>(values[2])
},
BrakeReleased = values[3],
BrakeReleasedId = nodes[3],
EffectiveLoadRate = values[4],
EffectiveLoadRateId = nodes[4]
};
}
private async Task<GearSnapshot> ReadGearAsync(NodeId gear, CancellationToken cancellationToken)
{
(NodeId pitchId, DataValue pitch) = await ReadChildAsync(
gear, MemberPath(RoboticsBrowseNames.Pitch), cancellationToken).ConfigureAwait(false);
return new GearSnapshot
{
Identification = await ReadIdentificationAsync(gear, cancellationToken).ConfigureAwait(false),
Pitch = await ReadChildValueAsync(gear, MemberPath(RoboticsBrowseNames.Pitch), cancellationToken)
.ConfigureAwait(false)
Pitch = pitch,
PitchId = pitchId
};
}
@@ -486,6 +543,8 @@ namespace Opc.Ua.Robotics.Client
NodeId taskModule,
CancellationToken cancellationToken)
{
(NodeId isReferencedId, DataValue isReferenced) = await ReadChildAsync(
taskModule, MemberPath(RoboticsBrowseNames.IsReferenced), cancellationToken).ConfigureAwait(false);
return new TaskModuleSnapshot
{
NodeId = taskModule,
@@ -493,8 +552,8 @@ namespace Opc.Ua.Robotics.Client
.ConfigureAwait(false),
Version = await ReadChildStringAsync(
taskModule, MemberPath(RoboticsBrowseNames.Version), cancellationToken).ConfigureAwait(false),
IsReferenced = await ReadChildValueAsync(
taskModule, MemberPath(RoboticsBrowseNames.IsReferenced), cancellationToken).ConfigureAwait(false)
IsReferenced = isReferenced,
IsReferencedId = isReferencedId
};
}
@@ -603,8 +662,11 @@ namespace Opc.Ua.Robotics.Client
return new AxisStateSnapshot
{
ActualPosition = values.Count > 0 ? values[0] : DataValue.Null,
ActualPositionId = nodes[0],
ActualSpeed = values.Count > 1 ? values[1] : DataValue.Null,
ActualAcceleration = values.Count > 2 ? values[2] : DataValue.Null
ActualSpeedId = nodes[1],
ActualAcceleration = values.Count > 2 ? values[2] : DataValue.Null,
ActualAccelerationId = nodes[2]
};
}
@@ -618,15 +680,19 @@ namespace Opc.Ua.Robotics.Client
for (int ii = 0; ii < functionIds.Count; ii++)
{
NodeId functionId = functionIds[ii];
(NodeId activeId, DataValue active) = await ReadChildAsync(
functionId, MemberPath(RoboticsBrowseNames.Active), cancellationToken).ConfigureAwait(false);
(NodeId enabledId, DataValue enabled) = await ReadChildAsync(
functionId, MemberPath(RoboticsBrowseNames.Enabled), cancellationToken).ConfigureAwait(false);
snapshots.Add(new SafetyFunctionSnapshot
{
NodeId = functionId,
Name = await ReadChildStringAsync(
functionId, MemberPath(RoboticsBrowseNames.Name), cancellationToken).ConfigureAwait(false),
Active = await ReadChildValueAsync(
functionId, MemberPath(RoboticsBrowseNames.Active), cancellationToken).ConfigureAwait(false),
Enabled = await ReadChildValueAsync(
functionId, MemberPath(RoboticsBrowseNames.Enabled), cancellationToken).ConfigureAwait(false)
Active = active,
ActiveId = activeId,
Enabled = enabled,
EnabledId = enabledId
});
}
return snapshots.ToArrayOf();
@@ -645,7 +711,11 @@ namespace Opc.Ua.Robotics.Client
DiPropertyPath(DiBrowseNames.Model),
DiPropertyPath(DiBrowseNames.ProductCode),
DiPropertyPath(DiBrowseNames.SerialNumber),
DiPropertyPath(DiBrowseNames.DeviceManual)
DiPropertyPath(DiBrowseNames.DeviceManual),
DiPropertyPath(DiBrowseNames.HardwareRevision),
DiPropertyPath(DiBrowseNames.SoftwareRevision),
DiPropertyPath(DiBrowseNames.ManufacturerUri),
DiPropertyPath(DiBrowseNames.ProductInstanceUri)
],
cancellationToken).ConfigureAwait(false);
var readIds = new List<ReadValueId>
@@ -696,7 +766,11 @@ namespace Opc.Ua.Robotics.Client
Model = ReadLocalized(properties, values, 3, LocalizedText.Null),
ProductCode = ReadString(properties, values, 4),
SerialNumber = ReadString(properties, values, 5),
DeviceManual = ReadString(properties, values, 6)
DeviceManual = ReadString(properties, values, 6),
HardwareRevision = ReadString(properties, values, 7),
SoftwareRevision = ReadString(properties, values, 8),
ManufacturerUri = ReadString(properties, values, 9),
ProductInstanceUri = ReadString(properties, values, 10)
};
}
@@ -886,6 +960,16 @@ namespace Opc.Ua.Robotics.Client
];
}
/// <summary>
/// The path of a child with a standard (namespace 0) browse name, such as
/// <c>EngineeringUnits</c>, <c>EURange</c> or <c>CurrentState</c>, below the
/// node at <paramref name="path"/>.
/// </summary>
private static QualifiedName[] UaChildPath(QualifiedName[] path, string browseName)
{
return [.. path, new QualifiedName(browseName)];
}
/// <summary>
/// The session's index of a namespace; an index no server uses when the
/// namespace is unknown, so paths through it resolve to nothing.
@@ -913,11 +997,151 @@ namespace Opc.Ua.Robotics.Client
NodeId parent,
QualifiedName[] browseNames,
CancellationToken cancellationToken)
{
(_, DataValue value) = await ReadChildAsync(parent, browseNames, cancellationToken).ConfigureAwait(false);
return value;
}
/// <summary>
/// Reads the variable at <paramref name="browseNames"/> below
/// <paramref name="parent"/> together with its NodeId; both are null when the
/// path does not resolve.
/// </summary>
private async Task<(NodeId NodeId, DataValue Value)> ReadChildAsync(
NodeId parent,
QualifiedName[] browseNames,
CancellationToken cancellationToken)
{
NodeId nodeId = await ResolveChildAsync(parent, browseNames, cancellationToken).ConfigureAwait(false);
return nodeId.IsNull
? DataValue.Null
: await Session.ReadValueAsync(nodeId, cancellationToken).ConfigureAwait(false);
? (nodeId, DataValue.Null)
: (nodeId, await Session.ReadValueAsync(nodeId, cancellationToken).ConfigureAwait(false));
}
/// <summary>
/// Resolves several paths below <paramref name="parent"/> in one request and
/// reads the variables found in another. A path that does not resolve has a
/// null NodeId and reads as <see cref="DataValue.Null"/>; a failed read keeps
/// the status the server returned.
/// </summary>
private async Task<(ArrayOf<NodeId> NodeIds, ArrayOf<DataValue> Values)> ReadChildrenAsync(
NodeId parent,
QualifiedName[][] browseNames,
CancellationToken cancellationToken)
{
ArrayOf<NodeId> nodes = await ResolveChildrenAsync(parent, browseNames, cancellationToken)
.ConfigureAwait(false);
ArrayOf<DataValue> read = await ReadValuesAsync(Resolved(nodes), cancellationToken)
.ConfigureAwait(false);
var values = new DataValue[nodes.Count];
int next = 0;
for (int ii = 0; ii < nodes.Count; ii++)
{
values[ii] = nodes[ii].IsNull || next >= read.Count ? DataValue.Null : read[next++];
}
return (nodes, values.ToArrayOf());
}
/// <summary>
/// Reads the <c>CurrentState</c> variable of the operation state machine
/// <paramref name="stateMachineBrowseName"/> below <paramref name="operation"/>.
/// The state is told by the NodeId in <c>CurrentState/Id</c>, whatever language
/// the server localizes <c>CurrentState</c> to: the Idle, Ready or Executing
/// state the Robotics model declares on the machine type
/// (<paramref name="typeStates"/>), or a state object the server instantiated
/// below the machine. The state name is used only when the server exposes no
/// <c>CurrentState/Id</c>; an Id that is unreadable or names another state
/// yields no state, as do a missing machine and a failed <c>CurrentState</c>
/// read, so the rest of the snapshot is still returned.
/// </summary>
private async Task<(NodeId CurrentStateId, RoboticsOperationState? CurrentState)> ReadOperationStateAsync(
NodeId operation,
string stateMachineBrowseName,
OperationStateIds typeStates,
CancellationToken cancellationToken)
{
if (operation.IsNull)
{
return (NodeId.Null, null);
}
QualifiedName[] machine = MemberPath(stateMachineBrowseName);
QualifiedName[] currentState = UaChildPath(machine, UaBrowseNames.CurrentState);
(ArrayOf<NodeId> nodes, ArrayOf<DataValue> values) = await ReadChildrenAsync(
operation,
[currentState, UaChildPath(currentState, UaBrowseNames.Id)],
cancellationToken).ConfigureAwait(false);
if (nodes[0].IsNull || !StatusCode.IsGood(values[0].StatusCode))
{
return (nodes[0], null);
}
if (nodes[1].IsNull)
{
return (nodes[0], ToOperationState(values[0]));
}
if (!StatusCode.IsGood(values[1].StatusCode) ||
!values[1].WrappedValue.TryGetValue(out NodeId stateId) ||
stateId.IsNull)
{
return (nodes[0], null);
}
RoboticsOperationState? state = ToOperationState(stateId, typeStates);
if (state != null)
{
return (nodes[0], state);
}
ArrayOf<NodeId> states = await ResolveChildrenAsync(
operation,
[
[.. machine, .. MemberPath(RoboticsBrowseNames.Idle)],
[.. machine, .. MemberPath(RoboticsBrowseNames.Ready)],
[.. machine, .. MemberPath(RoboticsBrowseNames.Executing)]
],
cancellationToken).ConfigureAwait(false);
return (nodes[0], ToOperationState(stateId, states[0], states[1], states[2]));
}
/// <summary>
/// The operation state the model state <paramref name="stateId"/> is, the
/// numeric ids of <paramref name="typeStates"/> mapped to the index the
/// session gives the Robotics namespace; null when it is none of them.
/// </summary>
private RoboticsOperationState? ToOperationState(NodeId stateId, OperationStateIds typeStates)
{
int ns = Session.NamespaceUris.GetIndex(global::Opc.Ua.Robotics.Namespaces.Robotics);
if (ns < 0)
{
return null;
}
return ToOperationState(
stateId,
new NodeId(typeStates.Idle, (ushort)ns),
new NodeId(typeStates.Ready, (ushort)ns),
new NodeId(typeStates.Executing, (ushort)ns));
}
/// <summary>
/// The operation state <paramref name="stateId"/> names among the given state
/// nodes, a null one never matching; null when it names none of them.
/// </summary>
private static RoboticsOperationState? ToOperationState(
NodeId stateId,
NodeId idle,
NodeId ready,
NodeId executing)
{
if (!idle.IsNull && stateId == idle)
{
return RoboticsOperationState.Idle;
}
if (!ready.IsNull && stateId == ready)
{
return RoboticsOperationState.Ready;
}
if (!executing.IsNull && stateId == executing)
{
return RoboticsOperationState.Executing;
}
return null;
}
private async Task<string?> ReadChildStringAsync(
@@ -948,6 +1172,41 @@ namespace Opc.Ua.Robotics.Client
return default;
}
/// <summary>
/// The operation state a <c>CurrentState</c> value names, by the state names
/// OPC 40010-1 gives both operation state machines; null when the value is
/// missing or bad, or names another state.
/// </summary>
private static RoboticsOperationState? ToOperationState(in DataValue value)
{
if (!StatusCode.IsGood(value.StatusCode) ||
!value.WrappedValue.TryGetValue(out LocalizedText state))
{
return null;
}
return state.Text switch
{
RoboticsBrowseNames.Idle => RoboticsOperationState.Idle,
RoboticsBrowseNames.Ready => RoboticsOperationState.Ready,
RoboticsBrowseNames.Executing => RoboticsOperationState.Executing,
_ => null
};
}
/// <summary>
/// The structure a value holds, decoded with the session's message context;
/// null when the value is missing, bad or of another type.
/// </summary>
/// <typeparam name="T">The generated structure type, such as <see cref="EUInformation"/>.</typeparam>
private T? ToStructure<T>(in DataValue value)
where T : class, IEncodeable, new()
{
return StatusCode.IsGood(value.StatusCode) &&
value.WrappedValue.TryGetStructure<T>(Session.MessageContext, out T? structure)
? structure
: null;
}
private async Task<ArrayOf<DataValue>> ReadValuesAsync(
List<NodeId> nodeIds,
CancellationToken cancellationToken)
@@ -1063,5 +1322,32 @@ namespace Opc.Ua.Robotics.Client
}
return null;
}
/// <summary>
/// The Idle, Ready and Executing states the Robotics model declares on the
/// SystemOperationStateMachineType.
/// </summary>
private static readonly OperationStateIds s_systemOperationStates = new(
SystemOperationStateMachineTypeIds.StateIds.Idle,
SystemOperationStateMachineTypeIds.StateIds.Ready,
SystemOperationStateMachineTypeIds.StateIds.Executing);
/// <summary>
/// The Idle, Ready and Executing states the Robotics model declares on the
/// TaskControlStateMachineType.
/// </summary>
private static readonly OperationStateIds s_taskControlStates = new(
TaskControlStateMachineTypeIds.StateIds.Idle,
TaskControlStateMachineTypeIds.StateIds.Ready,
TaskControlStateMachineTypeIds.StateIds.Executing);
/// <summary>
/// The numeric ids of the Idle, Ready and Executing states of an operation
/// state machine type in the Robotics namespace.
/// </summary>
/// <param name="Idle">The id of the Idle state.</param>
/// <param name="Ready">The id of the Ready state.</param>
/// <param name="Executing">The id of the Executing state.</param>
private readonly record struct OperationStateIds(uint Idle, uint Ready, uint Executing);
}
}
@@ -39,14 +39,29 @@ namespace Opc.Ua.Robotics
/// </summary>
public DataValue ActualPosition { get; init; } = DataValue.Null;
/// <summary>
/// The ActualPosition variable to subscribe to, or <see cref="NodeId.Null"/> when not published.
/// </summary>
public NodeId ActualPositionId { get; init; } = NodeId.Null;
/// <summary>
/// The ActualSpeed value, including status and timestamps.
/// </summary>
public DataValue ActualSpeed { get; init; } = DataValue.Null;
/// <summary>
/// The ActualSpeed variable to subscribe to, or <see cref="NodeId.Null"/> when not published.
/// </summary>
public NodeId ActualSpeedId { get; init; } = NodeId.Null;
/// <summary>
/// The ActualAcceleration value, including status and timestamps.
/// </summary>
public DataValue ActualAcceleration { get; init; } = DataValue.Null;
/// <summary>
/// The ActualAcceleration variable to subscribe to, or <see cref="NodeId.Null"/> when not published.
/// </summary>
public NodeId ActualAccelerationId { get; init; } = NodeId.Null;
}
}
@@ -48,5 +48,24 @@ namespace Opc.Ua.Robotics
/// Additional DI component instance NodeIds installed in the controller.
/// </summary>
public ArrayOf<NodeId> ComponentIds { get; init; } = [];
/// <summary>
/// The SystemOperation instance, or <see cref="NodeId.Null"/> when absent.
/// </summary>
public NodeId SystemOperationId { get; init; } = NodeId.Null;
/// <summary>
/// The CurrentState variable of the SystemOperationStateMachine, or <see cref="NodeId.Null"/> when absent.
/// </summary>
public NodeId CurrentStateId { get; init; } = NodeId.Null;
/// <summary>
/// The state the SystemOperationStateMachine was in when read.
/// </summary>
/// <remarks>
/// <see langword="null"/> when the machine is absent, the read failed or the
/// value names a state other than Idle, Ready or Executing.
/// </remarks>
public RoboticsOperationState? CurrentState { get; init; }
}
}
@@ -48,5 +48,10 @@ namespace Opc.Ua.Robotics
/// The optional pitch value, including status and timestamps.
/// </summary>
public DataValue Pitch { get; init; } = DataValue.Null;
/// <summary>
/// The Pitch variable to subscribe to, or <see cref="NodeId.Null"/> when not published.
/// </summary>
public NodeId PitchId { get; init; } = NodeId.Null;
}
}
@@ -44,6 +44,11 @@ namespace Opc.Ua.Robotics
/// </summary>
public DataValue Mass { get; init; } = DataValue.Null;
/// <summary>
/// The Mass variable to subscribe to, or <see cref="NodeId.Null"/> when not published.
/// </summary>
public NodeId MassId { get; init; } = NodeId.Null;
/// <summary>
/// Engineering metadata for the load mass.
/// </summary>
@@ -49,6 +49,11 @@ namespace Opc.Ua.Robotics
/// </summary>
public DataValue SpeedOverride { get; init; } = DataValue.Null;
/// <summary>
/// The SpeedOverride variable to subscribe to, or <see cref="NodeId.Null"/> when not published.
/// </summary>
public NodeId SpeedOverrideId { get; init; } = NodeId.Null;
/// <summary>
/// Axis instance NodeIds contained by the motion device.
/// </summary>
@@ -38,5 +38,40 @@ namespace Opc.Ua.Robotics
/// The motor identification.
/// </summary>
public RoboticsComponentIdentification Identification { get; init; } = new();
/// <summary>
/// The MotorTemperature value, including status and timestamps.
/// </summary>
public DataValue MotorTemperature { get; init; } = DataValue.Null;
/// <summary>
/// The MotorTemperature variable to subscribe to, or <see cref="NodeId.Null"/> when not published.
/// </summary>
public NodeId MotorTemperatureId { get; init; } = NodeId.Null;
/// <summary>
/// The engineering units and range of MotorTemperature.
/// </summary>
public RoboticsEngineeringValue MotorTemperatureEngineering { get; init; } = new();
/// <summary>
/// The optional BrakeReleased value, including status and timestamps.
/// </summary>
public DataValue BrakeReleased { get; init; } = DataValue.Null;
/// <summary>
/// The BrakeReleased variable to subscribe to, or <see cref="NodeId.Null"/> when not published.
/// </summary>
public NodeId BrakeReleasedId { get; init; } = NodeId.Null;
/// <summary>
/// The optional EffectiveLoadRate value, including status and timestamps.
/// </summary>
public DataValue EffectiveLoadRate { get; init; } = DataValue.Null;
/// <summary>
/// The EffectiveLoadRate variable to subscribe to, or <see cref="NodeId.Null"/> when not published.
/// </summary>
public NodeId EffectiveLoadRateId { get; init; } = NodeId.Null;
}
}
@@ -79,5 +79,25 @@ namespace Opc.Ua.Robotics
/// A URI or location for the component manual.
/// </summary>
public string? DeviceManual { get; init; }
/// <summary>
/// The DI HardwareRevision, when published.
/// </summary>
public string? HardwareRevision { get; init; }
/// <summary>
/// The DI SoftwareRevision, when published.
/// </summary>
public string? SoftwareRevision { get; init; }
/// <summary>
/// The DI ManufacturerUri, when published.
/// </summary>
public string? ManufacturerUri { get; init; }
/// <summary>
/// The DI ProductInstanceUri, when published.
/// </summary>
public string? ProductInstanceUri { get; init; }
}
}
@@ -49,9 +49,19 @@ namespace Opc.Ua.Robotics
/// </summary>
public DataValue Active { get; init; } = DataValue.Null;
/// <summary>
/// The Active variable to subscribe to, or <see cref="NodeId.Null"/> when not published.
/// </summary>
public NodeId ActiveId { get; init; } = NodeId.Null;
/// <summary>
/// The optional Enabled value, including status and timestamps.
/// </summary>
public DataValue Enabled { get; init; } = DataValue.Null;
/// <summary>
/// The Enabled variable to subscribe to, or <see cref="NodeId.Null"/> when not published.
/// </summary>
public NodeId EnabledId { get; init; } = NodeId.Null;
}
}
@@ -44,16 +44,31 @@ namespace Opc.Ua.Robotics
/// </summary>
public DataValue EmergencyStop { get; init; } = DataValue.Null;
/// <summary>
/// The EmergencyStop variable to subscribe to, or <see cref="NodeId.Null"/> when not published.
/// </summary>
public NodeId EmergencyStopId { get; init; } = NodeId.Null;
/// <summary>
/// The OperationalMode value, including status and timestamps.
/// </summary>
public DataValue OperationalMode { get; init; } = DataValue.Null;
/// <summary>
/// The OperationalMode variable to subscribe to, or <see cref="NodeId.Null"/> when not published.
/// </summary>
public NodeId OperationalModeId { get; init; } = NodeId.Null;
/// <summary>
/// The ProtectiveStop value, including status and timestamps.
/// </summary>
public DataValue ProtectiveStop { get; init; } = DataValue.Null;
/// <summary>
/// The ProtectiveStop variable to subscribe to, or <see cref="NodeId.Null"/> when not published.
/// </summary>
public NodeId ProtectiveStopId { get; init; } = NodeId.Null;
/// <summary>
/// Emergency-stop function entries.
/// </summary>
@@ -44,16 +44,31 @@ namespace Opc.Ua.Robotics
/// </summary>
public DataValue ExecutionMode { get; init; } = DataValue.Null;
/// <summary>
/// The ExecutionMode variable to subscribe to, or <see cref="NodeId.Null"/> when not published.
/// </summary>
public NodeId ExecutionModeId { get; init; } = NodeId.Null;
/// <summary>
/// Whether a task program is loaded, including status and timestamps.
/// </summary>
public DataValue TaskProgramLoaded { get; init; } = DataValue.Null;
/// <summary>
/// The TaskProgramLoaded variable to subscribe to, or <see cref="NodeId.Null"/> when not published.
/// </summary>
public NodeId TaskProgramLoadedId { get; init; } = NodeId.Null;
/// <summary>
/// The loaded task-program name, including status and timestamps.
/// </summary>
public DataValue TaskProgramName { get; init; } = DataValue.Null;
/// <summary>
/// The TaskProgramName variable to subscribe to, or <see cref="NodeId.Null"/> when not published.
/// </summary>
public NodeId TaskProgramNameId { get; init; } = NodeId.Null;
/// <summary>
/// The TaskControlOperation instance, or <see cref="NodeId.Null"/> when absent.
/// </summary>
@@ -63,5 +78,19 @@ namespace Opc.Ua.Robotics
/// Task modules exposed by this task control.
/// </summary>
public ArrayOf<NodeId> TaskModuleIds { get; init; } = [];
/// <summary>
/// The CurrentState variable of the TaskControlStateMachine, or <see cref="NodeId.Null"/> when absent.
/// </summary>
public NodeId CurrentStateId { get; init; } = NodeId.Null;
/// <summary>
/// The state the TaskControlStateMachine was in when read.
/// </summary>
/// <remarks>
/// <see langword="null"/> when the machine is absent, the read failed or the
/// value names a state other than Idle, Ready or Executing.
/// </remarks>
public RoboticsOperationState? CurrentState { get; init; }
}
}
@@ -53,5 +53,10 @@ namespace Opc.Ua.Robotics
/// Whether another task module references this module.
/// </summary>
public DataValue IsReferenced { get; init; } = DataValue.Null;
/// <summary>
/// The IsReferenced variable to subscribe to, or <see cref="NodeId.Null"/> when not published.
/// </summary>
public NodeId IsReferencedId { get; init; } = NodeId.Null;
}
}
@@ -39,8 +39,8 @@ using Opc.Ua.Client;
using Opc.Ua.Client.FileSystem;
using Opc.Ua.Client.Subscriptions;
using Opc.Ua.Client.Subscriptions.Streaming;
using RoboticsBrowseNames = Opc.Ua.Robotics.BrowseNames;
using DiBrowseNames = Opc.Ua.Di.BrowseNames;
using RoboticsBrowseNames = Opc.Ua.Robotics.BrowseNames;
namespace Opc.Ua.Robotics.Client.Tests
{
@@ -177,6 +177,321 @@ namespace Opc.Ua.Robotics.Client.Tests
});
}
[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 ProgramsAsyncReturnsFileSystemClientAndFailsWhenAbsent()
{
@@ -271,8 +586,10 @@ namespace Opc.Ua.Robotics.Client.Tests
{
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);
});
}
@@ -388,6 +705,7 @@ namespace Opc.Ua.Robotics.Client.Tests
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;
@@ -517,6 +835,20 @@ namespace Opc.Ua.Robotics.Client.Tests
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 EUInformation Celsius { get; } = new()
{
NamespaceUri = "http://www.opcfoundation.org/UA/units/un/cefact",
UnitId = 4408652,
DisplayName = new LocalizedText("en", "°C"),
Description = new LocalizedText("en", "degree Celsius")
};
public void ConfigureCompleteTopology()
{
// The layout of OPC 40010-1: folders and non-parameter members with
@@ -571,6 +903,13 @@ namespace Opc.Ua.Robotics.Client.Tests
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);
AddIdentification(DriveId, "Drive");
@@ -601,6 +940,20 @@ namespace Opc.Ua.Robotics.Client.Tests
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);
@@ -638,6 +991,82 @@ namespace Opc.Ua.Robotics.Client.Tests
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);
}
public void AddStateReads(NodeId stateMachine, string stateName)
{
AddChild(stateMachine, Ua(Opc.Ua.BrowseNames.CurrentState), CurrentStateNode);
@@ -766,7 +1195,12 @@ namespace Opc.Ua.Robotics.Client.Tests
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)
@@ -909,7 +1343,9 @@ namespace Opc.Ua.Robotics.Client.Tests
else
{
values.Add(m_values.TryGetValue(node.NodeId, out Variant variant)
? Value(variant)
? m_badQualityNodes.Contains(node.NodeId)
? new DataValue(variant, StatusCodes.BadSensorFailure)
: Value(variant)
: new DataValue(Variant.Null, StatusCodes.BadNodeIdUnknown));
}
}
@@ -981,11 +1417,13 @@ namespace Opc.Ua.Robotics.Client.Tests
{
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
};
}
@@ -219,7 +219,7 @@ namespace Opc.Ua.Robotics.Client.Tests
using var pump = new CancellationTokenSource();
Task writer = Task.Run(async () =>
{
double value = 10;
double value = CurrentA1Position();
while (!pump.IsCancellationRequested)
{
value++;
@@ -274,6 +274,184 @@ namespace Opc.Ua.Robotics.Client.Tests
Assert.That(await task.ReadStateAsync().ConfigureAwait(false), Is.EqualTo(RoboticsOperationState.Idle));
}
[Test]
public async Task SnapshotsCarryTheNodeIdsOfTheirValuesAsync()
{
RoboticsTopologySnapshot snapshot = await m_client.ReadSystemAsync(
await SystemIdAsync().ConfigureAwait(false)).ConfigureAwait(false);
// The id is the server's ParameterSet variable, not a node of the same
// name elsewhere below the axis.
AxisSnapshot a1 = snapshot.Axes.ToArray()!.Single(a => a.Identification.BrowseName.Name == "A1");
Assert.That(a1.State.ActualPositionId, Is.EqualTo(m_a1Position.NodeId));
foreach (AxisSnapshot axis in snapshot.Axes.ToArray()!)
{
NodeId[] ids = [axis.State.ActualPositionId, axis.State.ActualSpeedId, axis.State.ActualAccelerationId];
Assert.That(ids.All(id => !id.IsNull), Is.True, axis.Identification.BrowseName.Name);
Assert.That(ids.Distinct().Count(), Is.EqualTo(3), axis.Identification.BrowseName.Name);
}
MotionDeviceSnapshot robot = snapshot.MotionDevices[0];
Assert.That(robot.SpeedOverrideId.IsNull, Is.False);
LoadSnapshot flange = snapshot.Loads.ToArray()!.Single(l => l.NodeId == robot.FlangeLoadId);
Assert.That(flange.MassId.IsNull, Is.False);
SafetyStateSnapshot safety = snapshot.SafetyStates[0];
Assert.That(safety.EmergencyStopId.IsNull, Is.False);
Assert.That(safety.OperationalModeId.IsNull, Is.False);
Assert.That(safety.ProtectiveStopId.IsNull, Is.False);
SafetyFunctionSnapshot door = safety.ProtectiveStopFunctions[0];
Assert.That(door.ActiveId.IsNull, Is.False);
Assert.That(door.EnabledId.IsNull, Is.False);
TaskControlSnapshot task = snapshot.TaskControls[0];
Assert.That(task.ExecutionModeId.IsNull, Is.False);
Assert.That(task.TaskProgramLoadedId.IsNull, Is.False);
Assert.That(task.TaskProgramNameId.IsNull, Is.False);
// Diagnostics is built without the optional IsReferenced: no node, no id.
TaskModuleSnapshot[] modules = snapshot.TaskModules.ToArray()!;
Assert.That(modules.Where(m => m.Name != "Diagnostics").All(m => !m.IsReferencedId.IsNull), Is.True);
Assert.That(modules.Single(m => m.Name == "Diagnostics").IsReferencedId.IsNull, Is.True);
Assert.That(snapshot.Gears.ToArray()!.All(g => !g.PitchId.IsNull), Is.True);
Assert.That(snapshot.Gears[0].Pitch.WrappedValue.TryGetValue(out double pitch), Is.True);
Assert.That(pitch, Is.EqualTo(5.0));
// Each id is the variable itself: reading it returns the snapshot value.
NodeId[] nodes =
[
robot.SpeedOverrideId,
flange.MassId,
safety.OperationalModeId,
task.ExecutionModeId,
snapshot.Gears[0].PitchId
];
Variant[] expected =
[
robot.SpeedOverride.WrappedValue,
flange.Mass.WrappedValue,
safety.OperationalMode.WrappedValue,
task.ExecutionMode.WrappedValue,
snapshot.Gears[0].Pitch.WrappedValue
];
for (int ii = 0; ii < nodes.Length; ii++)
{
DataValue value = await m_session.ReadValueAsync(nodes[ii]).ConfigureAwait(false);
Assert.That(value.WrappedValue, Is.EqualTo(expected[ii]), nodes[ii].ToString());
}
}
[Test]
public async Task ASubscriptionOnTheActualPositionIdDeliversChangingValuesAsync()
{
AxisSnapshot a1 = await m_client.ReadAxisAsync(await AxisIdAsync("A1").ConfigureAwait(false))
.ConfigureAwait(false);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
using var pump = new CancellationTokenSource();
var writer = Task.Run(async () =>
{
double value = CurrentA1Position();
while (!pump.IsCancellationRequested)
{
value++;
Write(m_a1Position, value);
await Task.Delay(150, CancellationToken.None).ConfigureAwait(false);
}
});
var positions = new List<double>();
try
{
await foreach (DataValueChange change in m_streaming.SubscribeDataChangesAsync(
a1.State.ActualPositionId, null, timeout.Token).ConfigureAwait(false))
{
if (change.Value.WrappedValue.TryGetValue(out double position) &&
(positions.Count == 0 || positions[^1] != position))
{
positions.Add(position);
}
if (positions.Count >= 3)
{
break;
}
}
}
finally
{
pump.Cancel();
await writer.ConfigureAwait(false);
}
Assert.That(positions, Has.Count.GreaterThanOrEqualTo(3));
Assert.That(positions, Is.Ordered.Ascending);
}
[Test]
public async Task SnapshotsReportTheCurrentStateOfTheOperationsAsync()
{
// The server starts the SystemOperation in Ready, not in the default Idle.
ControllerSnapshot controller = await m_client.ReadControllerAsync(m_controllerId).ConfigureAwait(false);
Assert.That(controller.SystemOperationId, Is.EqualTo(m_systemOperationId));
Assert.That(controller.CurrentStateId, Is.EqualTo(m_systemCurrentStateId));
Assert.That(controller.CurrentState, Is.EqualTo(RoboticsOperationState.Ready));
TaskControlClient task = m_client.TaskControl(m_taskControlId);
if (await task.ReadStateAsync().ConfigureAwait(false) == RoboticsOperationState.Ready)
{
await task.UnloadProgramAsync().ConfigureAwait(false);
}
TaskControlSnapshot idle = await m_client.ReadTaskControlAsync(m_taskControlId).ConfigureAwait(false);
Assert.That(idle.CurrentStateId, Is.EqualTo(m_taskCurrentStateId));
Assert.That(idle.CurrentState, Is.EqualTo(RoboticsOperationState.Idle));
await task.LoadByNameAsync("Main").ConfigureAwait(false);
TaskControlSnapshot ready = await m_client.ReadTaskControlAsync(m_taskControlId).ConfigureAwait(false);
Assert.That(ready.CurrentStateId, Is.EqualTo(m_taskCurrentStateId), "the CurrentState node stays the same");
Assert.That(ready.CurrentState, Is.EqualTo(RoboticsOperationState.Ready));
DataValue current = await m_session.ReadValueAsync(ready.CurrentStateId).ConfigureAwait(false);
Assert.That(current.WrappedValue.TryGetValue(out LocalizedText state), Is.True);
Assert.That(state.Text, Is.EqualTo(BrowseNames.Ready));
await task.UnloadProgramAsync().ConfigureAwait(false);
TaskControlSnapshot unloaded = await m_client.ReadTaskControlAsync(m_taskControlId).ConfigureAwait(false);
Assert.That(unloaded.CurrentState, Is.EqualTo(RoboticsOperationState.Idle));
}
[Test]
public async Task IdentificationAndMotorValuesAreReadAsync()
{
RoboticsTopologySnapshot snapshot = await m_client.ReadSystemAsync(
await SystemIdAsync().ConfigureAwait(false)).ConfigureAwait(false);
RoboticsComponentIdentification robot = snapshot.MotionDevices[0].Identification;
Assert.That(robot.HardwareRevision, Is.EqualTo("HW-2"));
Assert.That(robot.SoftwareRevision, Is.EqualTo("7.3.1"));
Assert.That(robot.ManufacturerUri, Is.EqualTo("https://acme-robotics.example"));
Assert.That(robot.ProductInstanceUri, Is.EqualTo("urn:acme-robotics:AR6-0001"));
Assert.That(
snapshot.Controllers[0].Identification.ProductInstanceUri,
Is.EqualTo("urn:acme-robotics:RC9-0001"));
// The drive is built with a product code only.
Assert.That(snapshot.Drives[0].Identification.ProductCode, Is.EqualTo("DR-1"));
Assert.That(snapshot.Drives[0].Identification.HardwareRevision, Is.Null);
Assert.That(snapshot.Motors, Has.Count.EqualTo(6));
foreach (MotorSnapshot motor in snapshot.Motors.ToArray()!)
{
Assert.That(motor.Identification.SoftwareRevision, Is.EqualTo("7.3.1"));
Assert.That(motor.MotorTemperature.WrappedValue.TryGetValue(out double temperature), Is.True);
Assert.That(temperature, Is.EqualTo(38.5));
Assert.That(motor.MotorTemperatureId.IsNull, Is.False);
Assert.That(motor.MotorTemperatureEngineering.EngineeringUnits?.DisplayName.Text, Is.EqualTo("°C"));
Assert.That(motor.MotorTemperatureEngineering.Range?.Low, Is.EqualTo(-20));
Assert.That(motor.MotorTemperatureEngineering.Range?.High, Is.EqualTo(120));
Assert.That(motor.BrakeReleased.WrappedValue.TryGetValue(out bool released), Is.True);
Assert.That(released, Is.True);
Assert.That(motor.BrakeReleasedId.IsNull, Is.False);
Assert.That(motor.EffectiveLoadRate.WrappedValue.TryGetValue(out ushort rate), Is.True);
Assert.That(rate, Is.EqualTo(40));
Assert.That(motor.EffectiveLoadRateId.IsNull, Is.False);
}
MotorSnapshot motorA1 = snapshot.Motors.ToArray()!
.Single(m => m.Identification.BrowseName.Name == "M-A1");
Assert.That(motorA1.MotorTemperatureId, Is.EqualTo(m_motorA1Temperature.NodeId));
}
private async Task<NodeId> SystemIdAsync()
{
await foreach (MotionDeviceSystemEntry entry in m_client.EnumerateMotionDeviceSystemsAsync()
@@ -298,6 +476,8 @@ namespace Opc.Ua.Robotics.Client.Tests
{
m_serverContext = context.Context;
ITaskControlBuilder? mainTask = null;
IControllerBuilder? controllerBuilder = null;
ISystemOperationBuilder? systemOperation = null;
await context.AddMotionDeviceSystemAsync(
"Cell",
system =>
@@ -318,10 +498,14 @@ namespace Opc.Ua.Robotics.Client.Tests
});
system.AddController("Controller", controller =>
{
controllerBuilder = controller;
controller.WithComponentName("Controller")
.WithIdentification(Identity("RC-9", "RC9", "RC9-0001"));
controller.AddSoftware("RobotOS", software => software
.WithIdentification(Identity("RobotOS", "ROS-7", "ROS7-0001")));
// Not the default Idle, so a snapshot that reads no state fails.
systemOperation = controller.AddSystemOperation(operation => operation
.WithInitialState(RoboticsOperationState.Ready));
mainTask = controller.AddTaskControl("MainTask", task =>
{
task.WithComponentName("Main task")
@@ -357,6 +541,11 @@ namespace Opc.Ua.Robotics.Client.Tests
},
cancellationToken).ConfigureAwait(false);
m_taskControlId = mainTask!.State.NodeId;
m_taskCurrentStateId = mainTask.State.TaskControlOperation!
.TaskControlStateMachine!.CurrentState!.NodeId;
m_controllerId = controllerBuilder!.State.NodeId;
m_systemOperationId = systemOperation!.State.NodeId;
m_systemCurrentStateId = systemOperation.State.SystemOperationStateMachine!.CurrentState!.NodeId;
m_cellReady.TrySetResult(true);
}
catch (Exception exception)
@@ -395,8 +584,16 @@ namespace Opc.Ua.Robotics.Client.Tests
{
IMotorBuilder motor = pt.AddMotor($"M-{name}", m => m
.WithIdentification(Identity("SM-80", "SM80", $"M-{name}"))
.WithMotorTemperature(38.5)
.WithBrakeReleased(true));
.WithMotorTemperature(38.5, Unit("CEL", "°C", "degree Celsius"), new Range(120, -20))
.WithBrakeReleased(true)
.WithEffectiveLoadRate(40)
.Configure((node, context) =>
{
if (index == 0)
{
m_motorA1Temperature = Child(node.ParameterSet!, context, BrowseNames.MotorTemperature);
}
}));
// One drive powers the first two motors; the snapshot lists it once.
if (index < 2)
{
@@ -404,7 +601,8 @@ namespace Opc.Ua.Robotics.Client.Tests
}
pt.AddGear($"G-{name}", g => g
.WithIdentification(Identity("HD-40", "HD40", $"G-{name}"))
.WithGearRatio(160, 1));
.WithGearRatio(160, 1)
.WithPitch(5.0));
});
powerTrain.Moves(axis);
axis.Requires(powerTrain);
@@ -515,6 +713,16 @@ namespace Opc.Ua.Robotics.Client.Tests
return configuration;
}
/// <summary>
/// The A1 position as the server holds it now. The writers of the position tests
/// count up from here, so the order of the tests cannot reset the position and
/// break the ascending assertions.
/// </summary>
private double CurrentA1Position()
{
return m_a1Position.WrappedValue.TryGetValue(out double position) ? position : 0;
}
private void Write(BaseDataVariableState node, double value)
{
node.WrappedValue = Variant.From(value);
@@ -530,6 +738,10 @@ namespace Opc.Ua.Robotics.Client.Tests
data.Model = new LocalizedText("en", model);
data.ProductCode = productCode;
data.SerialNumber = serial;
data.HardwareRevision = "HW-2";
data.SoftwareRevision = "7.3.1";
data.ManufacturerUri = "https://acme-robotics.example";
data.ProductInstanceUri = $"urn:acme-robotics:{serial}";
};
}
@@ -590,7 +802,12 @@ namespace Opc.Ua.Robotics.Client.Tests
private RoboticsClient m_client = null!;
private ISystemContext m_serverContext = null!;
private BaseDataVariableState m_a1Position = null!;
private BaseDataVariableState m_motorA1Temperature = null!;
private NodeId m_taskControlId;
private NodeId m_taskCurrentStateId;
private NodeId m_controllerId;
private NodeId m_systemOperationId;
private NodeId m_systemCurrentStateId;
private int m_resets;
private string m_serverUrl = string.Empty;
}