Files
Marc Schier ae241b6625 Robotics: the OPC 40010 companion SDK, its client and the robot sample (#4127)
**Stack 5/5** — base #4126. Part of the #4096 split; see #4096 for the
overall picture. Closes the API work for #3827.

> Review after #4123, #4124, #4125 and #4126. This branch is
content-identical to the original #4096 branch, so merging the stack
lands exactly what #4096 contained.

## The problem this solves

OPC 40010 1.02 defines a rich robot **topology** but **no motion verbs
at all** — its actuation surface is the SystemOperation and TaskControl
state machines plus a Controller `Programs` directory. Meanwhile
[URML](https://github.com/URML-MARS/URML) names OPC UA Robotics as its
canonical non-ROS substrate and asks for `MoveTo` / `Grasp` / `Release`
methods the companion spec does not define, so today it needs a
hand-maintained per-deployment NodeId mapping file.

This closes that gap in two clearly separated tiers, and never presents
the second as standard.

## Contents

**Server** — stock `RoboticsNodeManager`, ordered
`IRoboticsModelProvider` composition, `IRoboticsConfigurator`
code-behind, and validated fluent topology builders for systems,
controllers, motion devices, axes, power trains, motors, gears, drives,
safety states, software, task controls and task modules, with the
correct OPC 40010 semantic references.

**Standard operations** — `AddSystemOperation` and
`AddTaskControlOperation` drive the Part 16 state machines. An illegal
cause is rejected with `BadInvalidState` before the handler runs; a
handler returning a bad `ServiceResult` does not move the machine; and a
transition already in flight rejects a concurrent one rather than
letting both commit.

**Programs** — binds the optional `FileDirectoryType` through the
reusable `IFileDirectoryBinder` added in 1/5.

**Operation conventions** — opt-in and explicitly non-normative `MoveTo`
/ `MoveJ` / `MoveL` / `Grasp` / `Release` / `PickFrom` / `PlaceAt` /
`SwapTool` / `SetOutput` / `CallProgram`, created **only** in an
application-owned namespace — the OPC UA, DI, IA and Robotics namespaces
are rejected — plus a generic `AddOperation<TRequest, TResponse>`
extension point.

**Client** — `RoboticsClient` extends the DI client, is subtype aware
throughout, and provides typed topology snapshots, the standard
operation and task-control clients, `ProgramsAsync`, streaming
observation and a DI factory. Verbs resolve by BrowseName, so no NodeId
mapping file is needed.

**Sample** — `MinimalRobotServer` composes five models (Robotics, IA,
DI, OpenUSD, RSL/GPOS) purely through `IRoboticsModelProvider`
contributions, which is the real proof that the stock hosting is
sufficient. It follows the published KUKA KR 16-2 kinematics and axis
limits with an eased pick-and-place cycle.

Also restores the vendor-subtype scanner test held back from 2/5, which
uses the Robotics NodeSet as its fixture.

## Defects found and fixed along the way

- **`ControllerType.CurrentUser`** is Mandatory in OPC 40010 but was
neither built nor validated. Now has a builder and a validation error.
- **`MotionDeviceType.TaskControlReference`** was absent because it must
target a `TaskControlOperationType` instance that did not exist. Now
wired.

## Validation

`dotnet build UA.slnx` — 0 errors, 0 warnings.

| Suite | Result |
| --- | --- |
| Opc.Ua.Robotics.Tests | 103 / 0 |
| Opc.Ua.OpenUsd.Tests | 635 / 0 |
| Opc.Ua.Di.Tests | 342 / 0 |
| Opc.Ua.SourceGeneration.Tests | 94 / 0 |
| Opc.Ua.Aot.Tests (NativeAOT) | 120 / 0 |

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8cbb8cd0-f0cb-4ab0-bea2-6202fbf69485
2026-08-01 07:55:58 +02:00

149 lines
5.9 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.Threading;
using NUnit.Framework;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Robotics;
using Opc.Ua.Robotics.Client;
namespace Opc.Ua.Robotics.Client.Tests
{
/// <summary>
/// Unit tests for <see cref="RoboticsModel"/> and <see cref="RoboticsClient"/> type
/// resolution, including the namespace-absent safety behaviour.
/// </summary>
[TestFixture]
[Category("Robotics")]
public sealed class RoboticsClientTests
{
private static NamespaceTable TableWithRobotics(out ushort roboticsIndex)
{
var table = new NamespaceTable();
roboticsIndex = (ushort)table.GetIndexOrAppend(global::Opc.Ua.Robotics.Namespaces.Robotics);
return table;
}
[Test]
public void TypeNodeIdResolvesInRoboticsNamespace()
{
NamespaceTable table = TableWithRobotics(out ushort index);
NodeId node = RoboticsModel.TypeNodeId(RoboticsModel.MotionDeviceSystemType, table);
Assert.That(node, Is.EqualTo(new NodeId(RoboticsModel.MotionDeviceSystemType, index)));
}
[Test]
public void TypeNodeIdNullTableThrows()
{
Assert.Throws<ArgumentNullException>(
() => RoboticsModel.TypeNodeId(RoboticsModel.MotionDeviceSystemType, null!));
}
[Test]
public void TypeNodeIdThrowsWhenNamespaceAbsent()
{
// Documents why the Try* / discovery helpers must not funnel through TypeNodeId:
// NodeId.Create throws BadNodeIdInvalid when the namespace is not in the table.
Assert.Throws<ServiceResultException>(
() => RoboticsModel.TypeNodeId(RoboticsModel.MotionDeviceSystemType, new NamespaceTable()));
}
[TestCase(RoboticsModel.MotionDeviceSystemType, "MotionDeviceSystem")]
[TestCase(RoboticsModel.MotionDeviceType, "MotionDevice")]
[TestCase(RoboticsModel.AxisType, "Axis")]
[TestCase(RoboticsModel.ControllerType, "Controller")]
public void TryGetRoboticsTypeNameReturnsFriendlyName(uint typeId, string expected)
{
NamespaceTable table = TableWithRobotics(out ushort index);
var typeDefinition = new NodeId(typeId, index);
bool matched = RoboticsClient.TryGetRoboticsTypeName(typeDefinition, table, out string? name);
Assert.That(matched, Is.True);
Assert.That(name, Is.EqualTo(expected));
}
[Test]
public void TryGetRoboticsTypeNameUnknownTypeReturnsFalse()
{
NamespaceTable table = TableWithRobotics(out ushort index);
var typeDefinition = new NodeId(9999u, index);
bool matched = RoboticsClient.TryGetRoboticsTypeName(typeDefinition, table, out string? name);
Assert.That(matched, Is.False);
Assert.That(name, Is.Null);
}
[Test]
public void TryGetRoboticsTypeNameReturnsFalseWhenNamespaceAbsent()
{
// The Robotics namespace is not in the table: the method must return false
// rather than throw (regression guard for the namespace-absent fix).
var table = new NamespaceTable();
var typeDefinition = new NodeId(RoboticsModel.MotionDeviceSystemType, 3);
bool matched = RoboticsClient.TryGetRoboticsTypeName(typeDefinition, table, out string? name);
Assert.That(matched, Is.False);
Assert.That(name, Is.Null);
}
[Test]
public void TryGetRoboticsTypeNameNullTableThrows()
{
Assert.Throws<ArgumentNullException>(
() => RoboticsClient.TryGetRoboticsTypeName(NodeId.Null, null!, out _));
}
[Test]
public void DiscoverMotionDeviceSystemsNullSessionThrows()
{
Assert.ThrowsAsync<ArgumentNullException>(
() => RoboticsClient.DiscoverMotionDeviceSystemsAsync(null!, NodeId.Null, CancellationToken.None));
}
[Test]
public async System.Threading.Tasks.Task DiscoverMotionDeviceSystemsReturnsEmptyWhenNamespaceAbsent()
{
var session = new Moq.Mock<ISession>();
session.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
ArrayOf<NodeId> result = await RoboticsClient.DiscoverMotionDeviceSystemsAsync(
session.Object, NodeId.Null, CancellationToken.None).ConfigureAwait(false);
Assert.That(result.Count, Is.Zero);
}
}
}