# Description Implement the draft **OPC UA — Robot Intent** companion model end to end: task-level motion verbs, a Part 10 lifecycle, a server hosting surface, a client, two samples and a live integration suite. It replaces the opt-in convention API merged in #4127 with an implementation generated from a companion information model. ## Why OPC 40010-1 *Robotics* describes robot **topology** in detail and defines **no motion verbs at all** — its entire actuation surface is `Start`, `Stop` and loading a named program. A conformant client can discover everything about a robot's construction and cannot ask it to move anywhere. #4127 filled that gap with ten verbs in an application-owned namespace, resolved by BrowseName, and the file said what it was: *"opt-in, explicitly non-normative industrial operation conventions"*. That contribution established a **vocabulary**. It established no **lifecycle**, and the lifecycle is the harder half. A motion takes seconds; a pick takes a minute. OPC 10000-4 §5.12.2 discards a method result when the Session ends *"independent of the task actually performed at the Server"* — so a synchronous motion method does not merely time out, it **loses the outcome of work that has already physically happened**. OPC 10000-10 §4.1 gives the OPC Foundation's own resolution: a Method performs a calculation, a **Program** runs a batch process or a machine tool part program. So `SubmitIntent` returns as soon as the intent is *admitted*, and what it returns is a NodeId — an `IntentOperationState` (a Part 10 program instance) the client subscribes to for progress and reads for the result. ## What changed **Model and shared code** — `Opc.Ua.RobotIntent.NodeSet2.xml` is source-generated in `Opc.Ua.Robotics`, so the enums, the polymorphic intent structures, `IntentOperationState : ProgramStateMachineState`, the method states and the typed clients derive from the model rather than being hand-written. Its only `RequiredModel` is the base UA namespace, so a server can adopt it without OPC 40010 or DI. `PoseMath` implements the specification's Annex C conversion between its unit quaternion and the core `ThreeDFrame` — including the `asin` clamp that stops a pole orientation becoming a domain error, and the non-negative-`w` representative that makes two servers agree on four numbers. `FrameTree` composes transforms along the frame tree. **Verbs are a DataType hierarchy, not one Method each.** A single submission and a mission step are then the same shape, and a new intent is a subtype rather than a new method — which is what `AddOperation<TRequest,TResponse>` was working around. **Server** — `IntentControllerHost` owns admission in the specification's order, the queue with PLCopen buffer modes, blending, cancellation *with the server's right to refuse*, missions with a committed base and a revisable horizon plus an IEC 61131-3 step graph, safety-aware refusals, real-time channel brokerage, and the capability declaration. All thirteen Methods are wired; command authority and channel leases are released when the holding Session closes; every client-supplied NodeId is validated against the controller being commanded (clause 11.3). `RobotIntentNodeManager`, the fluent `IIntentControllerBuilder` and `AddRobotIntent`/`ConfigureRobotIntent` make it reachable, and `RobotIntentFacetCalculator` computes which of the specification's facets an instance actually satisfies. **Client** — `RobotIntentClient` discovers `Server/RobotIntent/Controllers` and reads what a robot accepts; `IntentOperationHandle` subscribes and completes on a terminal state, recovering the result after a reconnect because clause 6.7 promises it survives; `CommandAuthorityLease` and `RealTimeChannelLease` manage the two exclusive resources; fluent builders cover every intent and mission. **Samples** — all robotics samples move under `samples/Robotics/`. `IntentEnabledRobot` is one UR5e-style offset-wrist arm on a bench, declared completely enough to show what a conformant server publishes, with a simulated safety source so the clause 10.4 refusals are demonstrable. `IntentViewerClient` turns a **click on a target prim in the OpenUSD viewport** into an intent and watches the arm execute it; it also runs headless, which is how CI exercises it. **Intents execute serially**, which satisfies every `BlockingMode` constraint by construction: the specification forbids beginning a `Single` or `Hard` intent while another executes and merely *permits* `None` and `Soft` to overlap. The convention API (`RoboticsOperationConventions`, `RoboticsOperationsBuilders`, `RoboticsOperationsClient`) is removed. ### On safety — what this deliberately does *not* claim The interface is **non-safety-rated**, and that is a property of the technology rather than a scoping choice. OPC 10000-15 carries cyclic safety data from a SafetyProvider to a SafetyConsumer; the consumer's `RequestSPDU` holds an identifier, a monitoring number and one octet of explicitly **non-safety** flags, so a caller has no channel through which to supply safety-rated arguments. Every safety fieldbus (PROFIsafe, CIP Safety, FSoE, openSAFETY) expresses a safety command as a **continuously asserted cyclic signal**, because the integrity argument rests on the fail-safe state that follows when assertion *stops* — and a Method call has no defined behaviour when it stops being called. What the host does instead is **observe and refuse**: `UpdateSafetyState` is how the application reports what the safety system is enforcing, and admission then refuses on the same values a client can read, so a refusal is explainable from the address space rather than from Server-internal state. It may never *instruct* the safety system, and no Method here commands a safe motion function, changes an operational mode or clears a stop. ## Defects this found in the specification, fixed upstream Implementing a specification is the only way to find out whether it can be implemented. Seven defects went back to [marcschier/opcua-drafts#47](https://github.com/marcschier/opcua-drafts/pull/47) rather than being worked around here: - **Refusal was unobservable.** Clause 6.2 orders six distinct refusals and clause 5.8 calls the failure set "small and diagnosable on purpose" — but `SubmitIntent` returned only `IntentId` and `Operation`, so a client could see *that* something was refused and never *which*. It now returns `Accepted`, `Failure` and `Message`, and clause 6.2 states that a refusal returns `Good` rather than substituting a Bad `StatusCode`. - **The Part 10 promotion was illegal.** `IntentOperationType.ProgramDiagnostic` was declared as a Property of `PropertyType`; OPC 10000-10 declares it a Variable of `ProgramDiagnostic2Type` reached by `HasComponent`. The declaration added a *second* member beside the inherited one instead of promoting it — which is precisely why an earlier version of this branch had deleted it. Correcting the TypeDefinition made it generate cleanly with **no change to the curated Core**. - **Clause 9 said nothing about the Method surface.** A server could declare `MissionsSupported` true and omit `SubmitMission` entirely, because the mission and channel Methods are Optional. This implementation did exactly that until an integration test caught it. A fourth honesty rule and a table now fix which Methods each declaration implies. - **Conformance was not machine-readable, and two implementations disagreed.** Clause 12 defined conformance in terms of facets and gave a Server nowhere to publish which ones it satisfied, so every client had to re-derive Table 12.2 from the address space. This repository proved why that fails: the server''s `RobotIntentFacetCalculator` and the client''s `RobotIntentRules.DeriveFacets` each implemented clause 12.2 independently and **disagreed on essentially every row** — the client''s `Blending` was `BlendingSupported` alone where the server also requires all four blending buffer modes, its `QueuedIntents` was `MaxQueueDepth > 0` where the server also requires `Buffered`. A client and server from the same release answered "does this controller conform?" differently, and both were reading the specification correctly, because several rows are behavioural and no amount of browsing settles them. `IntentCapabilitiesType` now carries **`SupportedFacets`**, exactly as OPC 10000-5 does with `ServerCapabilitiesType.ServerProfileArray`, RI-Base requires it, and clause 12.2 separates structural requirements a Server shall meet before listing a facet from attested ones governed by clause 9. - `WaitIntentDataType.Signal` was unbounded where clause 11.3 has to check it; §11.3 gained a table fixing the expected type of every NodeId-valued member; §5.7.0 gave `Ready`/`ActiveIntent`/`ActiveMission` normative meaning; §6.9 bounded `RequestedLease`; §6.5 and §6.4 now say what a server that cannot differentiate `StopMode` must do, and which stop a superseded intent gets. ## Bugs the tests and reviews caught Each of these would have been silent in the field: - **The execution engine was never wired into the public API.** `IntentControllerBuilder.RegisterAsync` built the address space and never started an `IntentControllerHost`, so through the documented `AddRobotIntent` → `ConfigureRobotIntent` path **every Method returned `BadNotImplemented`** and the registered `IIntentExecutor` was never resolved. A browsable address space is not a working robot interface, and only a live integration test showed the difference. - **NodeId scoping failed open on an empty index.** A controller advertising `CallProgramIntentDataType` with no published `ProgramType` accepted an *arbitrary* NodeId as `CallProgram.Program` — and `CallProgram` runs code the server holds. - **Blending completed the predecessor at submission time** and abandoned its motion, telling the client it succeeded and publishing an all-zero pose at the origin as a genuine `AchievedPose`. - **Mission base immutability compared only identifiers**, so a client could keep the ids and swap the *motion* of an already-committed step. - **`FinalResultData/Result` was never registered** with the node manager: Browse showed it, Read returned `BadNodeIdUnknown` — so clause 6.7's promise to a Part 10 client did not hold. - `DisposeAsync` disposed primitives out from under in-flight executor code; re-using a terminated `IntentId` destroyed the retained result and duplicated a NodeId; neither side registered the Robot Intent encodeables, so `Pose3DDataType` did not decode; host startup was hooked to a task that ran in only one hosting shape. - The result was published **after** the state went terminal; capabilities were resolved against a stale namespace table; an empty `ContentFilter` arrives as an empty element array rather than a null filter, so testing only for null made every unconditional mission transition silently untaken — a mission would run its first step and stop. ### Found by a later test-quality audit, after the above was written An audit of the test suite itself found five tests whose **names** asserted a normative requirement while their **code** asserted something weaker or nothing at all. The worst **fabricated the answer it checked**: a helper caught a decode failure and invented "5 supported intents, 6 axes, all facets true", so six capability-honesty assertions across four tests could pass while reading nothing from the server. The fallback is gone and the suite passes with honest reads. Chasing the facet gaps it reported led to the clause 12 defect above. Two further defects followed from completing that work: - **`RI-Interop-40010` was never computed**, and the code documented the omission as impossible to fix "because the calculator receives only a Robot Intent controller, not the linked OPC 40010 model". That was wrong: Annex B's structural requirement is an **inverse** `HasIntentController` reference, reachable from `IntentControllerState` — and the integration suite was already browsing exactly that. A server implementing Annex B could not claim Annex B. - **The published claim could go stale.** `SupportedFacets` was a snapshot taken during `RegisterAsync`, so a reference attached afterwards left it wrong. It is now bound through `BindRead` and recomputed on read, so the claim tracks the address space by construction rather than by discipline — which matters for anyone bolting Robot Intent onto an existing OPC 40010 node manager, where the attach ordering is often not theirs to choose. Two tests that **could not fail** were also replaced: one asserted `AccessLevel` equals `CurrentRead` to pin a read-only marking, but `BaseVariableState` initialises every variable to exactly that, so deleting the marking left it green; it is now a write expecting `BadNotWritable`, including a real OPC UA `Write` service call over a live session. Finally, a build break that a plain build hides: `RoboticsAotTests` used `out string?` in a project declaring `<Nullable>disable</Nullable>`. Building the project **without** `-f` suppresses `CS8632` through `NoWarn=nullable` and succeeds; building it **with** `-f net10.0` resolves `NoWarn` differently, `CS8632` fires, and `TreatWarningsAsErrors` makes it an error. CI builds per-TFM, so this would have failed there while passing every local build to that point. ## Testing and coverage | | | |---|---| | `dotnet build UA.slnx -t:Rebuild` | **0 errors, 2 warnings** — both pre-existing `CA1861` in `tests/Opc.Ua.Tools.Tests`, a file this PR does not touch | | **Whole `UA.slnx` test suite, net10.0** | **50 of 52 assemblies fully green** (measured at `40d9cf213`, before the `master` merge; the other two are explained below) | | `Opc.Ua.Robotics.Tests` | **467** net10.0, **447** net48 | | `Opc.Ua.Robotics.Intent.Tests` (live client <-> server) | **20** on net10.0 and net48, run repeatedly | | `Opc.Ua.OpenUsd.Tests` | **904** after merging `master` (664 before; the rest arrive with #4160 / #4162 / #4180) | | `Opc.Ua.Aot.Tests` | **121/121** | | `Opc.Ua.Di.Tests` (regression from the sample move) | **364/365**, one pre-existing skip | | **Changed-line coverage** | **90.7 %**, no file below 80 % — comfortably above the enforced band, which after #4158 applies only to patches larger than 100 changed lines | The two non-green assemblies are **not** caused by this branch. Rather than assert that, I ran the same selections at the merge-base `ae241b662` on the same machine: - `ServiceCallReassemblerTests.PrivateRequestSummaryFormatsKnownRequestKinds` expects `12.5` and gets `12,5` — a culture-sensitive assertion failing on a European locale. Fails 4/4 on **both** commits. - `CertRotationLiveTests.L2Cert2RotateCertificateDuringServerRestartRecoversSharedChannelAsync` fails on a teardown count. Fails 4/4 on **both** commits. - A third failure seen once, AOT `FindServersAsync` with `BadSecureChannelClosed`, did not reproduce: 4/4 on retest and 121/121 in the final run. Environmental. Two things are worth calling out because they would otherwise be invisible: - `tools/**` is **not** in the `coverage-thresholds.json` ignore list, and no test project referenced the OpenUSD tools assemblies — so their changed lines counted as uncovered and the real patch figure was 75 %, exactly the blocking threshold. Rather than widening the ignore list, the pure decisions in the viewport host (pointer-to-physical-pixel conversion, `RenderPickRequest` construction, stale-retry, backend discovery, pick-mode selection) were separated from the Avalonia/native shell and tested headless against a fake picking backend. - `dotnet build UA.slnx` **hides warnings on unchanged projects**; only `-t:Rebuild` surfaces them. That is how a `CA2263` introduced by a net48 portability fix survived several "zero warning" builds. The multi-target requirement earned its keep repeatedly: it caught a default interface implementation, `ArgumentNullException.ThrowIfNull`, `string.Create` with an interpolated handler, `ValueTask.FromResult`/`CompletedTask`, `Task.WaitAsync`, generic `Enum.GetValues` and generic `Enum.IsDefined`, and a non-generic `TaskCompletionSource` — none of which the net10.0 build complained about. A net10.0-**only** hang in the simulated executor and two load-sensitive flaky tests were also found and fixed at the cause. ### Merged with `master` `master` (through `e73e71184`) is merged in as `08205d81d`. Four conflicts, all where the two branches touched the same robot and OpenUSD surfaces, and none resolved by dropping a side: - #4162 added six files to `samples/MinimalRobotServer/` while this branch moved that directory to `samples/Robotics/MinimalRobotServer/`. They are kept at the moved path, and `UA.slnx` takes the union of both sides' new projects. - #4160 replaced `object` with a `UsdValue` union and rewrote `Flatten`; this branch had reformatted the same method and fixed a path in its doc comment. Git produced a hybrid that did not compile. The resolution keeps `master`'s `UsdValue` arm and `Flatten(UsdValue)` overload in this branch's block-bodied form, with the path fix retained. - `master` inserted `FindStageCamera` directly before two methods this branch had relocated, so the merge duplicated `WriteStageUsda` and `GetPrivateStateRoot`. Only `FindStageCamera` is genuinely new — `master`'s diff shows the other two as unmodified context — so the duplicates are dropped and this branch's `internal` versions kept, including the `GetPrivateStateRoot(string?)` overload the connector tests use to stay out of the real user profile. - `master` moved the OpenUSD stack to `0.4.0-alpha`; this branch pins `OpenUsd.Rendering` in the test project so the renderer-pick tests can reach the picking backend. Left at `0.1.0-alpha` that pin is below what the viewer now resolves transitively, which is `CS1705`. The pin follows `master`. CI was green on `baaf35e9` with **190 checks passing and none failing**, across every build leg (net472 / net48 / netstandard2.0 / netstandard2.1 / net8.0 / net9.0 / net10.0, Debug and Release) and the full test matrix on Windows and Linux. Since then `master` has been merged twice more and two CI-stability fixes landed (see below), so the current head is re-running. Locally, `dotnet build UA.slnx -t:Rebuild` gives **0 errors** and the 2 pre-existing `CA1861` warnings; Robotics **467** net10.0 / **447** net48, Robot Intent **20** on both, OpenUSD **904**, AOT **121/121**, source generation **3771**. ### Review round Five reviewers over disjoint areas found defects worth calling out, all fixed in `3ffec2c1`: - **A refused mission step was reported `Succeeded`.** The advance path returned the same `false` for "steps exhausted" and "step refused", so a protective stop between steps told a supervisory system the mission had completed while the robot stood halted with work unexecuted. - **Three attacker-controlled NodeIds reached the executor unvalidated** — `Pick.Tool`, `Place.Tool`, `ToolChange.DockStation`. `DockStation` drives a physical tool-exchange motion to a location the server never authorised. The replacement test enumerates NodeId members by reflection so the next one added cannot be missed. - **A lock-order inversion that loses a stop.** `CanCancel` and the mission condition evaluator ran under the host lock while the executor's own thread re-entered it via `ReportBlendBegin`. - **A non-terminal executor outcome killed the pump** from outside the `try/catch` that exists because the executor is untrusted — `Ready` stayed true and submissions kept being accepted while nothing moved. - **`Pause` published `Suspended` while the robot kept moving.** Now queue-only and honest about it; the specification gained the matching rule ([opcua-drafts `357a558`](https://github.com/marcschier/opcua-drafts/pull/47)). - **`RI-RealTimeChannel` and `RI-Safety` were false claims** — granted on an empty folder with no methods, and on the mere presence of a Mandatory node. - **The Part 10 promotions never promoted.** Namespace-1 BrowseNames declared second members beside the inherited ones; the conformance test could not see it because its lookup stripped the prefix. Fixed upstream and regenerated. - **Renderer picking never worked.** The reflective probe bound a renderer that is not a picking backend and throws off its owner thread. It now binds only `IRenderPickingBackend`, falls back immediately and loudly, and the docs say plainly that renderer picking is unavailable with this package version. - **The client lost command authority the instant it gained it** — monitored items report the current value first, and the lease treated every notification as a loss. ### CI stability fixes for master-side failures The CI split in #4158 turned on legs that had never run, and two of the failures they exposed were real bugs on `master` rather than anything this branch introduced. Both are fixed here because they were blocking every branch: - **ECC key generation was broken on macOS.** `ExportExplicitParameters` throws `PlatformNotSupportedException` there — Apple's SecurityTransforms supports named curves only — and it was called for exactly one value, the curve order `n`. The four curves OPC 10000-12 admits have published orders, so those are carried directly, with `CurveOrderTableMatchesThePlatform` checking each against the platform's own exported order wherever export works, so the constants cannot silently drift. This accounts for the two ECC failures in `test-macOS-latest-Server`. - **The certificate-leak detector raced asynchronous disposal.** It polled a fixed budget and reported whatever it saw, which cannot distinguish a genuine leak (count steady) from a suite still draining fire-and-forget disposals (count falling) — on a loaded agent that produced `Certificate leak detected: 2 instance(s)` with every test passing. It now waits for the count to **settle**, so a real leak is reported sooner than before while a slow drain gets as long as it keeps progressing. Shared by every suite that asserts no certificate leaks. The remaining macOS failures on `master` (`Gds`, `Tools`, `Redundancy.Samples`, `aot-macos`) have different root causes, are not reproducible without a macOS host, and are **not** addressed here. ### Merged with `master` again `master` through `416d619c4` (#4158, the CI matrix split and graduated coverage bands) is merged in as `08ae4528`. One conflict, in `docs/DeveloperGuide.md`, where both sides had deliberately rewritten the coverage-gate section: - `master`'s rewrite is kept in full — graduated `patch.bands`, Codecov being informational-only, and where the numbers surface per CI system. - This branch's paragraph about the `ignore` globs is kept too, re-sited ahead of those subsections. I re-checked it against the merged `coverage-thresholds.json` rather than assuming it still held: the list is still `tests/**`, `samples/**`, `obj`, `bin`, `*.g.cs`, with **`tools/**` absent** — so the warning that anything under `tools/` is measured like product code, and that an assembly no test project references contributes changed lines counted as *uncovered*, is still accurate and worth keeping. - `master`'s "reproduce a coverage failure locally" line supersedes this branch's near-identical one, so only `master`'s survives. Post-merge: `dotnet build UA.slnx -t:Rebuild` **0 errors**, 2 pre-existing `CA1861`; Robotics **467** net10.0 / **447** net48; Robot Intent **20** on both. ### Getting CI green The pipeline had never actually run on this branch until now (it was waiting on the fork-PR approval gate), so the first run surfaced four real problems, each fixed at source: - **Seven build legs failed** because `IntentEnabledRobot` pins `net10.0` for the OpenUSD payload but never opted into `RestrictForLegacyTfm`. On the legacy legs the libraries build as net472/net48, where `Opc.Ua.Types` exposes the `System.Threading.Lock` polyfill publicly, and that collides with the sample's own net10.0 BCL (`CS0433`). `Opc.Ua.OpenUsd.Connector.Viewer` was already using the opt-out for the same reason. - **A Linux-only test failure** turned out to be a real defect, not a platform quirk: `SetPose` published the *commanded* interpolated pose beside joint frames derived from the *solved* joints, so the two disagreed by the inverse-kinematics residual — about 8 mm, constant after any real motion. That is the lying-digital-twin defect one level deeper than the per-step IK fix reached. `SetPose` now derives both from the joint angles. - **Three source-generator tests** asserted the old emitted shape. The clause 6.2 Null-Variant fix was implemented in `ObjectTypeProxyGenerator` so every generated proxy benefits, which changed that shape; the tests are updated and strengthened to pin the `IsNull` branch as well. - **A CI-only timeout** in the simulated arm drain: the executor runs on a manual clock so the tests do not depend on machine speed, but the drain guarded itself with a wall-clock deadline, which put that dependence back. CI runs with coverage instrumentation, which is enough to take a per-step IK probe from ~0.6 s to over 5 s. The guard is now a simulated step budget. One failure was **not** from this PR and did not recur: a Roslyn `AccessViolationException` compiling `Opc.Ua.ISA95` on one leg, while the Debug leg of the same target passed on identical code and the Release build succeeds locally. ## Related Issues - Relates to #3827 — the request that prompted the robotics intent work. - Builds on #4127. The companion specification is drafted in the open at [marcschier/opcua-drafts#47](https://github.com/marcschier/opcua-drafts/pull/47) (`metaverse-specs/robot-intent/`), with the prior art and the reasoning behind each decision in the research document beside it. **Nothing in it is normative or endorsed by the OPC Foundation, and its NodeIds and namespace URI are provisional** — which is the main reason this is a draft. ## 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. - [x] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. - [ ] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [ ] I have addressed **all** PR feedback received. On the test checkbox, precisely: the whole `UA.slnx` suite now runs on net10.0 with **50 of 52 assemblies fully green**, and `Opc.Ua.Robotics.Tests` passes on **all five** target frameworks including net472, net8.0 and net9.0 which had never previously been exercised. The two remaining failures are reproduced identically at the merge-base and are detailed above, so I have ticked the box; if you would rather it stayed unticked until those two are green, say so and I will revert it. ## Points I would most like feedback on 1. **The namespace and NodeIds are provisional.** They come from a draft specification, not from the OPC Foundation. If this lands before the model is registered, the identifiers will move. 2. **The model lives in `Opc.Ua.Robotics`.** It is standalone on the base UA namespace and takes no dependency on OPC 40010, so a separate `Opc.Ua.RobotIntent` package would arguably be cleaner. Keeping it here was a deliberate call — this is where the robot-facing API already is — but it is the easiest thing to change now and the hardest later. 3. **Removing the convention API from #4127 is a breaking change** for anyone who adopted it. It was explicitly marked non-normative, so I have assumed that is acceptable — please say if a deprecation period is wanted instead. 4. **Serial execution** is a deliberate simplification. Concurrent `None`/`Soft` intents would be an optimisation, not a correction, but it is worth agreeing that reading. 5. **`samples/Robotics/` reorganisation** moves `MinimalRobotServer`. Happy to split that into its own commit if it makes review easier. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1dab1302-19c5-4a9b-a50c-d97d389713aa Copilot-Session: a248a589-9a20-4372-868e-5d347e57001b
81 KiB
OPC UA Robotics
The Robotics libraries implement the OPC UA Robotics companion specification (OPC 40010-1, version 1.02). Robotics builds on Industrial Automation (OPC 10000-200), which in turn builds on Device Integration (OPC 10000-100); all three models are source-generated from their released NodeSets and loaded in dependency order.
Status: draft companion model. The namespace
http://opcfoundation.org/UA/RobotIntent/and every NodeId in it are provisional. This implements the working-group draft OPC UA — Robot Intent; nothing here is official or endorsed by the OPC Foundation. Do not deploy it on a production robot and expect the identifiers to survive.
OPC 40010 describes a robot in detail — its motion device system, its axes, its power trains, its
controller, its safety states — and defines no motion verbs at all. Its whole actuation surface is
Start, Stop and loading a named program. A conformant client can discover everything about a
robot's construction and cannot ask it to move anywhere.
Robot Intent supplies the verbs, and only the verbs, so the two compose rather than compete:
Opc.Ua.Roboticscarries the source-generated model, the executor contracts, and the Annex C pose maths.Opc.Ua.Robotics.Servercarries the execution engine, the address space builders and the hosting integration.Opc.Ua.Robotics.Clientcarries discovery, the awaitable operation handle, command authority, missions and the fluent intent builders.
The NodeSet declares exactly one RequiredModel — the base OPC UA namespace — so a server can adopt
Robot Intent without pulling in OPC 40010, OPC 10000-100 DI, or anything else.
Packages
| Package | Purpose |
|---|---|
OPCFoundation.NetStandard.Opc.Ua.Robotics |
Source-generated OPC 40010/IA and draft Robot Intent models, generated NodeIds/DataTypes/ObjectType clients, ArrayOf<T>-based common contracts shared by client and server, the IIntentExecutor contract, IntentExecution, IIntentProgress, IntentOutcome, PoseMath and FrameTree. |
OPCFoundation.NetStandard.Opc.Ua.Robotics.Server |
Stock Robotics node manager, Robot Intent node manager, model providers, hosting extensions (AddRobotics, AddRobotIntent, ConfigureRobotics, ConfigureRobotIntent), validated fluent topology builders, IntentControllerHost, safety binding, real-time channel declarations and facet calculation. |
OPCFoundation.NetStandard.Opc.Ua.Robotics.Client |
Continuation-safe, subtype-aware discovery of Robotics instances over the DI client, Robotics type classification, Robot Intent discovery, the awaitable operation handle, command authority, real-time-channel leases, missions and RobotIntentBuilder. |
Generated OPC 40010 model types stay in the specification namespaces Opc.Ua.Robotics and
Opc.Ua.IA; hand-written APIs compose the generated NodeStates, factories,
enums, and ObjectType clients instead of replacing or inheriting from them.
The generated Opc.Ua.Robotics.Namespaces and Opc.Ua.IA.Namespaces classes
expose the model namespace URIs, and RoboticsModel adds namespace-safe
resolution and classification helpers over the generated ObjectTypeIds /
ReferenceTypeIds classes, which remain the source of truth.
The generated Robot Intent types live in Opc.Ua.RobotIntent. The hand-written server APIs live in
Opc.Ua.Robotics.Server and Opc.Ua.Robotics.Server.Builders; the hand-written client APIs live in
Opc.Ua.Robotics.Client.Intent.
Minimal hosted server
AddRobotics() registers the stock RoboticsNodeManager, the built-in DI/IA/
Robotics model provider, and the Robotics configuration pipeline.
ConfigureRobotics(...) runs after the models are loaded and builds instances:
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Opc.Ua.Robotics.Server;
using Opc.Ua.Robotics.Server.Builders;
HostApplicationBuilder host = Host.CreateApplicationBuilder(args);
host.Services
.AddOpcUa()
.AddServer(options =>
{
options.ApplicationName = "RobotCellServer";
options.EndpointUrls.Add("opc.tcp://localhost:62830/RobotCellServer");
})
.AddRobotics(options =>
options.InstanceNamespaceUri = "urn:example:robot-cell")
.ConfigureRobotics(async context =>
{
await context.AddMotionDeviceSystemAsync("RobotCell", system =>
{
ISafetyStateBuilder safety = system.AddSafetyState("Safety");
IMotionDeviceBuilder robot = system.AddMotionDevice("R1", device =>
device.WithMotionDeviceCategory(
MotionDeviceCategoryEnumeration.ARTICULATED_ROBOT));
IDriveBuilder drive = robot.AddDrive(
"Drive1",
item => item.WithProductCode("DRV-1"));
IPowerTrainBuilder train = robot.AddPowerTrain("PowerTrain1");
IAxisBuilder axis = robot.AddAxis("A1", item => item
.WithMotionProfile(AxisMotionProfileEnumeration.ROTARY)
.WithActualPosition(0));
train.AddMotor("Motor1", motor => motor.IsDrivenBy(drive));
train.Moves(axis);
axis.Requires(train);
IControllerBuilder controller = system.AddController("Controller1");
controller.AddSoftware("Runtime", software =>
software.WithIdentification(data =>
{
data.Manufacturer = new LocalizedText("Vendor");
data.Model = new LocalizedText("Robot Runtime");
data.SoftwareRevision = "1.0";
}));
ITaskControlBuilder taskControl = controller.AddTaskControl("Main");
controller.Controls(robot).UsesSafetyState(safety);
taskControl.Controls(robot);
}, context.CancellationToken);
});
await host.Build().RunAsync();
AddRobotics() owns the DI namespace and therefore cannot be combined with
AddOpcUaDi(); both register the shared DiAddressSpaceOwnership marker and the
second call throws with the name of the conflicting extension.
Hosting API
| Method | Builder | Purpose |
|---|---|---|
AddRobotics(Action<RoboticsServerOptions>?) |
IOpcUaServerBuilder |
Registers the stock manager, built-in model provider, and configuration pipeline. |
AddRoboticsModel<TProvider>() |
IOpcUaServerBuilder |
Adds an IRoboticsModelProvider that contributes further compiled models. |
ConfigureRobotics(Action<IRoboticsBuildContext>) |
IOpcUaServerBuilder |
Synchronous configurator for the stock manager. |
ConfigureRobotics(Func<IRoboticsBuildContext, ValueTask>) |
IOpcUaServerBuilder |
Asynchronous configurator. |
ConfigureRobotics(Func<IRoboticsBuildContext, CancellationToken, ValueTask>) |
IOpcUaServerBuilder |
Asynchronous configurator with the hosting token. |
ConfigureRobotics<TConfigurator>() |
IOpcUaServerBuilder |
Class-based, dependency-injected IRoboticsConfigurator. |
ConfigureRoboticsFor<TNodeManager>(…) |
IOpcUaServerBuilder |
The same three lambda overloads, targeting an application-owned DiNodeManager. |
ConfigureRoboticsFor<TNodeManager, TConfigurator>() |
IOpcUaServerBuilder |
Class-based configurator for an application-owned manager. |
Configurators registered for the same node manager run in registration order and
share one build context; the hosting pipeline calls Seal() once they have all
completed. Every overload is additive, so a composition root can mix lambdas and
classes.
RoboticsServerOptions.InstanceNamespaceUri selects the application-owned
namespace used for dynamically created instances. It defaults to
urn:opcua-netstandard:robotics:instances and is validated to be an absolute
URI that is not the OPC UA, DI, IA, or Robotics model namespace.
Class-based code-behind
A class configurator is resolved from the container, so drivers, telemetry, file-system providers, and authorization services can be injected:
public sealed class RobotCell(IRobotBackend backend) : IRoboticsConfigurator
{
public ValueTask ConfigureAsync(
IRoboticsBuildContext context,
CancellationToken cancellationToken)
{
return context.AddMotionDeviceSystemAsync("RobotCell", system =>
{
IMotionDeviceBuilder robot = system.AddMotionDevice("R1");
robot.AddAxis("A1", axis => axis
.WithMotionProfile(AxisMotionProfileEnumeration.ROTARY)
.BindActualPosition(backend.ReadJoint1Async));
// …
}, cancellationToken);
}
}
// builder.ConfigureRobotics<RobotCell>();
Model providers
IRoboticsModelProvider composes additional compiled models before instance
configuration, which keeps optional models (for example the draft OpenUSD
binding) out of Opc.Ua.Robotics.Server:
public sealed class OpenUsdModelProvider : IRoboticsModelProvider
{
public int Order => 0;
public ArrayOf<string> NamespaceUris => new[] { Opc.Ua.OpenUsd.Namespaces.OpenUSD };
public void AddPredefinedNodes(NodeStateCollection nodes, ISystemContext context)
=> nodes.AddOpcUaOpenUsd(context);
}
// builder.AddRoboticsModel<OpenUsdModelProvider>();
Providers run in ascending Order. The built-in DI/IA/Robotics provider uses
int.MinValue, so application providers run afterwards by default. A provider
that replaces the built-in core provider must advertise both the IA and Robotics
namespace URIs.
Build context
IRoboticsBuildContext is created once per manager startup and shared by every
configurator of that manager:
| Member | Purpose |
|---|---|
Manager |
The active DiNodeManager. |
Context |
The active ISystemContext. |
Nodes |
The single fluent INodeManagerBuilder owned by the context. |
InstanceNamespaceIndex |
The resolved application-owned namespace index. |
DeviceSet |
The DI DeviceSet node that Robotics systems are added below. |
CancellationToken |
The hosting cancellation token. |
GetRequiredService<T>() |
Narrow service resolution for code-behind. |
Seal() |
Seals the fluent builder and starts configured simulations. Called by the hosting pipeline. |
AddMotionDeviceSystemAsync(browseName, configure, cancellationToken) is the
single entry point for topology. It configures the tree, validates it, verifies
that every instance NodeId is unique, adds the semantic references, and then
registers the completed generated state tree — rolling back the reservation if
any step fails. Reservations for NodeIds and root BrowseNames are held per node
manager, so concurrent configurators cannot collide.
Topology builders
Every builder implements IRoboticsNodeBuilder<TState>, which exposes the
generated State, the owning BuildContext, a low-level
Configure(Action<TState, ISystemContext>) escape hatch, and AsNode() for the
fluent INodeBuilder<TState> view after registration.
| Builder | Adds |
|---|---|
IMotionDeviceSystemBuilder |
AddController, AddMotionDevice, AddSafetyState. |
IControllerBuilder |
AddSoftware, AddTaskControl, AddSystemOperation, AddPrograms, WithCurrentUser, AddAuxiliaryComponent, AddDrive, Controls, UsesSafetyState. |
ISystemOperationBuilder |
WithInitialState, OnGetReady, OnStart, OnStop, OnStandDown, WithStopModes, OnTransition. |
IProgramsBuilder |
UseFileSystem(provider) / UseFileSystem<TProvider>(), WithOptions. |
IRoboticsUserBuilder |
The mandatory Controller CurrentUser child. |
IRoboticsSoftwareBuilder |
Software identification (manufacturer, model, revision). |
IMotionDeviceBuilder |
AddAxis, AddPowerTrain, AddDrive, AddAuxiliaryComponent, WithFlangeLoad, WithMotionDeviceCategory, speed-override binding, UsesTaskControl. |
IAxisBuilder |
WithMotionProfile, AsVirtual, actual position/speed/acceleration, WithAdditionalLoad, Requires. |
IPowerTrainBuilder |
AddMotor, AddGear, Moves, HasSlave. |
IMotorBuilder |
Identification, motor temperature, brake-released, effective load rate, IsDrivenBy. |
IGearBuilder |
Identification, WithGearRatio(int numerator, uint denominator), WithPitch. |
IDriveBuilder / IAuxiliaryComponentBuilder |
Product code, asset id, component name. |
ILoadBuilder |
WithMass, WithCenterOfMass, WithInertia. |
ISafetyStateBuilder |
AddEmergencyStop, AddProtectiveStop, emergency-stop / operational-mode / protective-stop values and bindings. |
ITaskControlBuilder |
AddTaskModule, AddTaskControlOperation, execution mode, task-program name and loaded flag, Controls. |
ITaskControlOperationBuilder |
OnStart, OnStop, OnLoadByName, OnLoadByNodeId, OnUnloadByName, OnUnloadByNodeId, OnUnloadProgram, OnResetToProgramStart, WithMotionDevicesUnderControl. |
ITaskModuleBuilder |
WithName, WithVersion, WithIsReferenced. |
Instances are always materialised through the generated
ISystemContext.CreateInstanceOf<Type> factories (for example
CreateInstanceOfMotionDeviceSystemType), so each instance carries the full
companion-type structure and its per-instance NodeIds, rather than only a
type-definition reference on a bare BaseObjectState.
Binding live values
WithXxx seeds a static value; BindXxx attaches an asynchronous read (and,
where the variable is writable, a write) handler:
robot.AddAxis("A1", axis => axis
.WithMotionProfile(AxisMotionProfileEnumeration.ROTARY)
.BindActualPosition(backend.ReadJoint1Async)
.BindActualSpeed(backend.ReadJoint1SpeedAsync));
robot.BindSpeedOverride(
backend.ReadSpeedOverrideAsync,
backend.WriteSpeedOverrideAsync);
Read handlers are Func<CancellationToken, ValueTask<DataValue>>, so quality and
timestamps stay under application control. SpeedOverride writes are validated
to be finite and within 0–100 for every non-Bad status code.
Semantic references
The builders add the Robotics reference types with the correct forward/inverse semantics:
| Call | Reference |
|---|---|
controller.Controls(motionDevice) |
Controls (optional). |
controller.UsesSafetyState(safetyState) |
HasSafetyStates. |
taskControl.Controls(motionDevice) |
Controls. |
powerTrain.Moves(axis) |
Moves. |
axis.Requires(powerTrain) |
Requires. |
powerTrain.HasSlave(other) |
HasSlave, for non-1:1 kinematics. |
motor.IsDrivenBy(drive) |
IsDrivenBy (optional). |
builder.IsConnectedTo(other) |
IsConnectedTo, between any two Robotics nodes. |
ITaskControlBuilder.Controls and IMotionDeviceBuilder.UsesTaskControl add the
standard Controls relation. When the task control also has a
TaskControlOperation (via AddTaskControlOperation), the motion device's
TaskControlReference property is populated to point at that operation node.
Standard operations and programs
ControllerType.SystemOperation and TaskControlType.TaskControlOperation are
optional facets carrying the two Part 16 state machines. The builders wire the
methods to application handlers and move the machine only when a handler
succeeds:
IControllerBuilder controller = system.AddController("Controller1");
controller.WithCurrentUser(user => user.WithName("operator"));
controller.AddSystemOperation(operation => operation
.WithInitialState(RoboticsOperationState.Idle)
.WithStopModes([RoboticsStopMode.Normal, RoboticsStopMode.Emergency],
RoboticsStopMode.Normal)
.OnGetReady((context, ct) => backend.GetReadyAsync(ct))
.OnStart((context, ct) => backend.StartAsync(ct))
.OnStop((request, ct) => backend.StopAsync(request.StopMode, ct))
.OnStandDown((context, ct) => backend.StandDownAsync(ct)));
controller.AddPrograms(programs => programs
.UseFileSystem<IRobotProgramStore>()
.WithOptions(o => o.AllowDelete = false));
ITaskControlBuilder task = controller.AddTaskControl("Main");
task.AddTaskControlOperation(operation => operation
.OnLoadByName((name, ct) => backend.LoadProgramAsync(name, ct))
.OnStart((context, ct) => backend.RunAsync(ct))
.OnStop((request, ct) => backend.HaltAsync(request.StopMode, ct))
.WithMotionDevicesUnderControl([robot.State.NodeId]));
Causes follow the spec transitions: GetReady Idle→Ready, Start
Ready→Executing, Stop Executing→Ready, StandDown and the unload verbs
Ready→Idle, the load verbs Idle→Ready. A cause that is illegal from the current
state returns BadInvalidState without invoking the handler.
LastTransition, LastTransitionReason, PossibleStopModes, and
ConfiguredDefaultStopMode are maintained automatically.
AddPrograms binds the optional Programs FileDirectoryType to the stack's
existing IFileSystemProvider model through the shared
Opc.Ua.Server.FileSystem.IFileDirectoryBinder, so any node manager — not just
the dedicated file-system manager — can serve a Part 5 directory. Binding runs
after the Robotics tree is registered, and is disposed if the build rolls back.
Validation
Registration fails with a ServiceResultException (BadConfigurationError)
that reports every problem at once when the configured topology violates the
companion specification:
- a motion-device system without at least one controller, one motion device, and one safety state;
- a controller without at least one
SoftwareTypeand oneTaskControlTypeinstance — both are mandatory placeholders; - a controller without its mandatory
CurrentUserchild; - a motion device without at least one axis and one power train;
- a non-virtual axis without a
Requireslink — mark an axis withAsVirtual()when it has no power train; - a power train without a motor, or a gear with a zero ratio denominator;
- an emergency stop, protective stop, or task module without a
Name.
Registration additionally rejects a descendant whose NodeId is null, outside the
instance namespace, duplicated within the tree, or already indexed by the node
manager. Duplicate BrowseNames raise BadBrowseNameDuplicated, both for
siblings under one parent and for a MotionDeviceSystem BrowseName already
reserved on the same node manager.
Because validation runs before registration, a failed build leaves no partial subtree in the address space.
Units follow the specification: a gear ratio numerator is a signed int and the
denominator is a uint; WithPitch is millimetres of linear travel per
output-side revolution and is a BaseDataVariableType without EngineeringUnits.
Custom node managers and non-DI hosting
The stock RoboticsNodeManager owns the DI address space and exactly one
application instance namespace. Use it — through AddRobotics() — whenever
Robotics is the only companion model the server adds. Reach for a custom node
manager instead when any of the following applies:
- the server composes additional models into the same manager (for example
the OpenUSD binding, or RSL/GPOS positioning, alongside Robotics), so a single
LoadPredefinedNodesAsyncmust return all of them in dependency order; - the server needs its own
INodeIdFactoryscheme (deterministic string NodeIds, an external asset registry, a sharded allocator); - the server already owns a
DiNodeManagerfor its device model and Robotics is one facet of it.
An application that already owns a DiNodeManager keeps it and still gets the
validated fluent builders:
builder.ConfigureRoboticsFor<MyDeviceNodeManager>(async context =>
{
await context.AddMotionDeviceSystemAsync("RobotCell", system => { /* … */ },
context.CancellationToken);
});
Outside the hosting pipeline entirely, load the models and create the context directly:
// Inside a DiNodeManager: load DI + IA + Robotics in dependency order.
predefinedNodes.AddRoboticsTypeSystem(context);
// After the models are loaded:
IRoboticsBuildContext buildContext =
manager.CreateRoboticsBuildContext(new RoboticsServerOptions
{
InstanceNamespaceUri = "urn:example:robot-cell"
});
CreateRoboticsBuildContext validates the DeviceSet, the loaded Robotics
model, and the configured instance namespace before returning. The manager's
Context.NodeIdFactory must implement IRoboticsNodeIdFactory; the allocator
must be thread-safe, must reserve unique NodeIds for unregistered nodes, and must
allocate Robotics instances in the configured instance namespace.
MinimalRobotServer is the worked example of
the custom-manager route: it composes Robotics, IA, DI, the draft OpenUSD
binding, and RSL/GPOS in one DiNodeManager subclass.
Vendor extensions
The Robotics packages are a base for robot vendors, not a closed set. A vendor
ships their own package that references Opc.Ua.Robotics (and
Opc.Ua.Robotics.Server when it needs fluent accessors), adds its own
NodeSet2.xml declaring ObjectTypes derived from the companion types (for
example an AcmeMotionDeviceType under MotionDeviceType), and runs the model
source generator with default options:
<AdditionalFiles Include="Model\Acme.Robots.NodeSet2.xml">
<ModelSourceGeneratorPrefix>Acme.Robots</ModelSourceGeneratorPrefix>
</AdditionalFiles>
The ModelDependencyAttribute emitted by Opc.Ua.Robotics makes the generator
resolve the base state types from that assembly instead of re-emitting them, so
AcmeMotionDeviceState derives from the shipped MotionDeviceState. The
ModelFluentAccessorProviderAttribute on Opc.Ua.Robotics.Server does the same
for the fluent accessors: the vendor assembly emits accessors only for its new
types and inherits the Robotics ones. See
Model Dependencies.
On the client side every discovery and classification call is subtype aware, so vendor specialisations are found and labelled without any client change.
Layering
The three packages follow the same layering as the Device Integration and Positioning trios:
| Package | References | Generation |
|---|---|---|
Opc.Ua.Robotics |
Opc.Ua.Di |
Robotics + IA models with ModelSourceGeneratorOmitFluentApi=true |
Opc.Ua.Robotics.Server |
Opc.Ua.Robotics, Opc.Ua.Di.Server, Opc.Ua.Server |
fluent accessors only, with ModelSourceGeneratorFluentAccessorsOnly=true |
Opc.Ua.Robotics.Client |
Opc.Ua.Robotics, Opc.Ua.Di.Client, Opc.Ua.Client |
— |
The Opc.Ua.Robotics.Operations contracts ship in the model package so a client
and a server can share them without either taking a dependency on the other.
The model package stays free of any server dependency because the generated
fluent-accessor method bodies call into the Opc.Ua.Server fluent builders.
Emitting them from the model package would force Opc.Ua.Robotics — and
therefore Opc.Ua.Robotics.Client — to reference Opc.Ua.Server. The
accessors are therefore generated once, in the server package, against the state
types the model package already ships.
Common contracts
Opc.Ua.Robotics ships immutable ArrayOf<T>-based records that project a robot
cell without a client or server dependency:
RoboticsTopologySnapshot, MotionDeviceSystemSnapshot, ControllerSnapshot,
MotionDeviceSnapshot, AxisSnapshot, AxisStateSnapshot, AxisLimits,
AxisEngineeringOptions, LoadSnapshot, PowerTrainSnapshot, MotorSnapshot,
GearSnapshot, DriveSnapshot, SafetyStateSnapshot,
SafetyFunctionSnapshot, TaskControlSnapshot, TaskModuleSnapshot,
RoboticsComponentIdentification, and RoboticsEngineeringValue.
Containment identifiers stay on the owning instance, while
RoboticsRelationshipSnapshot (built from RoboticsRelationshipEntry values) is
the authoritative projection of the semantic Robotics references listed above.
Client
Opc.Ua.Robotics.Client extends the Device Integration client: Robotics types
derive from IA, which derives from OPC 10000-100, so RoboticsClient composes
DiTopologyClient rather than reimplementing device navigation. It lets a
generic client — a viewer, an OpenUSD connector, or a fleet manager — find,
label, and drive robot cells without hard-coded NodeIds.
Registration
services.AddOpcUa()
.AddClient(options => { /* endpoint and application options */ })
.AddRoboticsClient();
AddRoboticsClient() also calls AddOpcUaDi(), and registers a
Func<CancellationToken, Task<RoboticsClient>> factory bound to the managed
session. The direct constructor
new RoboticsClient(session, telemetry) remains available as the non-DI
fallback.
API
| Member | Purpose |
|---|---|
RoboticsClient(ISession, ITelemetryContext) |
Creates the client over a connected session. |
session.Robotics(telemetry) |
Extension shorthand for the constructor. |
Session / Telemetry |
The session and telemetry context the client was created with. |
Topology |
The DiTopologyClient this client extends — use it to walk the DI device topology (DeviceSetId, NetworkSetId, DeviceTopologyId). |
DiscoverMotionDeviceSystemsAsync(ct) |
Discovers every MotionDeviceSystem below the DI DeviceSet. |
DiscoverMotionDeviceSystemsAsync(root, ct) |
Same, below an explicit root (for example the Objects folder). |
EnumerateMotionDeviceSystemsAsync(ct) |
Streams systems as they are discovered. |
DiscoverMotionDevicesAsync(root, ct) |
Discovers MotionDevices, typically below a system's MotionDevices folder. |
DiscoverControllersAsync(root, ct) |
Discovers Controllers, typically below a system's Controllers folder. |
DiscoverAxesAsync(root, ct) |
Discovers Axes, typically below a motion device's Axes folder. |
ReadSystemAsync(system, ct) |
Reads a complete RoboticsTopologySnapshot, including the semantic RoboticsRelationshipSnapshot. |
ReadControllerAsync / ReadMotionDeviceAsync / ReadAxisAsync / ReadSafetyStateAsync / ReadTaskControlAsync |
Per-node typed snapshots. |
SystemOperation(controller) |
The standard SystemOperation state-machine client. |
TaskControl(taskControl) |
The standard TaskControl state-machine client. |
ProgramsAsync(controller, ct) |
A FileSystemClient rooted at the Controller Programs directory. |
OperationsAsync(motionDevice, ct) |
The non-normative operation convention client. |
ObserveAxisAsync / ObserveSafetyAsync |
Streaming telemetry over the subscription API. |
GetRoboticsTypeNameAsync(typeDefinition, ct) |
Classifies a TypeDefinition against the server's type hierarchy, so vendor subtypes resolve to their closest standard Robotics type. Returns null when the node is not a Robotics type. |
RoboticsClient.DiscoverMotionDeviceSystemsAsync(session, root, ct) (static) |
Session-only discovery for callers that do not hold a client instance. |
RoboticsClient.TryGetRoboticsTypeName(typeDefinition, namespaceUris, out name) (static) |
Offline exact-match classification with no server round-trip. |
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
empty ArrayOf<NodeId> and classification returns null / false instead of
throwing.
Walking a cell
using Opc.Ua.Robotics.Client;
var robots = new RoboticsClient(session, telemetry);
foreach (NodeId system in await robots.DiscoverMotionDeviceSystemsAsync(ct))
{
foreach (NodeId device in await robots.DiscoverMotionDevicesAsync(system, ct))
{
foreach (NodeId axis in await robots.DiscoverAxesAsync(device, ct))
{
// Read ParameterSet/ActualPosition, subscribe, drive a twin, …
}
}
}
Classifying a discovered node
// Exact match, no server round-trip — use when you already resolved the
// namespace table and only care about the standard types.
if (RoboticsClient.TryGetRoboticsTypeName(
typeDefinition, session.NamespaceUris, out string? typeName))
{
// typeName is MotionDeviceSystem, MotionDevice, Axis, or Controller.
}
// Subtype aware — an AcmeMotionDeviceType instance is reported as MotionDevice.
string? kind = await robots.GetRoboticsTypeNameAsync(typeDefinition, ct);
Invoking robot operations
The Robotics methods themselves (task-control and system state-machine
operations) are exposed through the source-generated ObjectType clients in
Opc.Ua.Robotics, which give a typed result carrying a ServiceResult plus the
declared outputs — for example LoadByName(string name) returning a Status
Int32. The generated state-machine identifiers live in
SystemOperationStateMachineTypeIds and TaskControlStateMachineTypeIds.
A higher-level verb façade over these generated proxies is documented under Operation conventions below. The standard state machines are reached through
SystemOperation(...)andTaskControl(...).
Standard operations
RoboticsClient exposes the two OPC 40010 state machines directly:
SystemOperationClient system = robots.SystemOperation(controllerNodeId);
await system.GetReadyAsync(ct); // Idle -> Ready
await system.StartAsync(ct); // Ready -> Executing
await system.StopAsync(RoboticsStopMode.Normal, ct); // Executing -> Ready
await system.StandDownAsync(ct); // Ready -> Idle
RoboticsOperationState state = await system.ReadStateAsync(ct);
await foreach (RoboticsOperationState s in system.ObserveStateAsync(ct))
{
// Idle / Ready / Executing
}
TaskControlClient task = robots.TaskControl(taskControlNodeId);
await task.LoadByNameAsync("weld-seam-3", ct);
await task.StartAsync(ct);
await task.StopAsync(RoboticsStopMode.Normal, ct);
await task.ResetToProgramStartAsync(ct);
await task.UnloadProgramAsync(ct);
A verb that is illegal from the current state is rejected with BadInvalidState
before the server-side handler runs, and a handler that returns a bad
ServiceResult leaves the state machine where it was.
Programs
When the Controller exposes the optional Programs directory, it is a standard
Part 5 FileDirectoryType, so it is read and written with the ordinary file
services:
FileSystemClient programs = await robots.ProgramsAsync(controllerNodeId, ct);
await foreach (var entry in programs.EnumerateAsync("/", ct))
{
// program files exposed by the controller
}
Observing telemetry
await foreach (AxisStateSnapshot axis in robots.ObserveAxisAsync(axisNodeId, ct))
{
// ActualPosition / ActualSpeed / ActualAcceleration as they change
}
Operation conventions
OPC 40010 defines no motion verbs. Its actuation surface is the two state
machines above, plus the standard Programs directory when a controller
exposes one. The former non-normative AddOperations / AddOperation builder
API has been removed; use the Robotics state machines for device control and
the Robot Intent model for task-level verbs such as move, grasp, release,
process, mission, retry and real-time-channel flows.
For application-specific verbs that do not fit the standardized Robot Intent types, expose an application-owned information model and call it through normal OPC UA methods, or encode the behaviour as a program selected through TaskControl. Do not create custom Robotics children in the OPC UA, DI, IA, or Robotics namespaces.
Sample
MinimalRobotServer exposes a
MotionDeviceSystem with two independently mobile 6-axis robots, a cell
emergency stop, a speed-override command, and a runtime-mounted gripper. It
combines Robotics with OPC 10000-210 RSL frames and OPC 10000-211 GPOS locations
and binds the whole cell to an OpenUSD stage, so a generic connector renders it
live with no robot-specific code.
URML primitive mapping
The URML robot-intent language names OPC UA Robotics as its canonical non-ROS substrate, and its Layer-2 v0.1.0 vocabulary of 27 primitives is the motivating use case for issue #3827. Every primitive has a route through this SDK:
| URML primitive | Route |
|---|---|
move_to, grasp, release |
convention verbs MoveTo, Grasp, Release |
pick_from, place_at, swap_tool (industrial profile) |
convention verbs PickFrom, PlaceAt, SwapTool |
set_output |
convention verb SetOutput |
call_program |
normative: TaskControl(...).LoadByNameAsync + StartAsync, with programs listed through ProgramsAsync(controller). CallProgram is the fallback. |
measure |
ReadAxisAsync and the other snapshot readers |
wait_for |
ObserveAxisAsync / ObserveSafetyAsync / ObserveStateAsync |
report |
plain session write |
wait |
client-side delay; no server surface needed |
bimanual |
address the specific MotionDevice; DiscoverMotionDevicesAsync enumerates them |
dock, hover, detect, scan, capture, speak, listen, take_off, land, return_to_home, plan_path, follow_trajectory, drive, turn |
application-owned information model methods, or programs selected through TaskControl |
URML's Layer-1 capability manifest is derived live from
ReadSystemAsync, rather than hand-declared: motion devices give arm count,
AxisSnapshot.Limits and AxisEngineeringOptions give joint limits and
velocities, MotionDeviceCategory gives mobility, SafetyStateSnapshot gives
the safety envelope, ProgramsAsync gives the declared programs, and
RoboticsRelationshipSnapshot gives the kinematic structure. Robot Intent
types and application-owned methods are resolved from the live address space, so
no per-deployment NodeId mapping file is required.
Robot Intent
Hosting a controller
The hosted path is the normal path for an application server. AddRobotIntent registers the standalone
node manager, the draft model provider and a rejecting executor. AddRobotIntentExecutor<T> replaces
that rejecting executor with the application implementation, and ConfigureRobotIntent runs after the
model and the Server/RobotIntent/Controllers root exist:
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Opc.Ua;
using Opc.Ua.RobotIntent;
using Opc.Ua.Robotics.Server;
using Opc.Ua.Robotics.Server.Builders;
HostApplicationBuilder host = Host.CreateApplicationBuilder(args);
host.Services
.AddOpcUa()
.AddServer(options =>
{
options.ApplicationName = "IntentRobot";
options.EndpointUrls.Add("opc.tcp://localhost:4840/IntentRobot");
})
.AddRobotIntent(options =>
{
options.InstanceNamespaceUri = "urn:example:intent-robot";
})
.AddRobotIntentExecutor<MyIntentExecutor>()
.ConfigureRobotIntent(async (context, ct) =>
{
await context.AddIntentControllerAsync(
"Arm1",
ConfigureArmController,
ct);
});
await host.Build().RunAsync();
The direct-construction fallback is the same address space and the same builder, just without the
Generic Host pipeline. A server that constructs node managers itself can register
RobotIntentNodeManagerFactory directly, or construct RobotIntentNodeManager with explicit
IRobotIntentModelProvider and RobotIntentServerOptions services. Once the manager has created its
address space, call CreateRobotIntentBuildContext and use the same AddIntentControllerAsync
extension:
using Opc.Ua;
using Opc.Ua.Robotics.Server;
var factory = new RobotIntentNodeManagerFactory(
new IRobotIntentModelProvider[] { new RobotIntentModelProvider() }.ToArrayOf(),
new RobotIntentServerOptions
{
InstanceNamespaceUri = "urn:example:intent-robot"
});
// Register the factory with the server's node-manager registration mechanism.
// If you already have a RobotIntentNodeManager instance after address-space creation:
IRobotIntentBuildContext context = manager.CreateRobotIntentBuildContext(ct);
await context.AddIntentControllerAsync("Arm1", ConfigureArmController, ct);
That standalone path is intentionally useful: because the NodeSet requires only the base OPC UA
namespace, a machine can expose Robot Intent without OPC 40010, DI or the Robotics topology model. A
server that already owns an OPC 40010 node manager can instead use ConfigureRobotIntentFor<TNodeManager>
and then link a MotionDeviceSystem to the intent controller with HasIntentController. That inverse
reference is the structural evidence used to derive RI-Interop-40010.
Declaring a robot
The controller builder makes the model declaration and the host declaration one thing. What the server
publishes under Capabilities is the contract IntentControllerHost enforces at submission time.
using System.Threading;
using System.Threading.Tasks;
using Opc.Ua;
using Opc.Ua.RobotIntent;
using Opc.Ua.Robotics.Server.Builders;
static void ConfigureArmController(IIntentControllerBuilder controller)
{
controller
.WithOperationalMode(OperationalModeEnum.AutomaticExternal)
.WithReady(true)
.WithMaxQueueDepth(8)
.Accepts<JointMoveIntentDataType>()
.Accepts<LinearMoveIntentDataType>()
.Accepts<CircularMoveIntentDataType>()
.Accepts<GraspIntentDataType>(cancelSupported: false)
.Accepts<ReleaseIntentDataType>()
.Accepts<PickIntentDataType>(cancelSupported: false)
.Accepts<PlaceIntentDataType>()
.Accepts<ToolChangeIntentDataType>(cancelSupported: false)
.Accepts<SetOutputIntentDataType>()
.Accepts<CallProgramIntentDataType>()
.Accepts<WaitIntentDataType>()
.WithSafetyState(new MySafetySource());
IIntentFrameBuilder world = controller.AddFrame(
"World",
"world",
FrameRoleEnum.World,
Pose("world", 0.0, 0.0, 0.0));
IIntentFrameBuilder @base = controller.AddFrame(
"Base",
"robot-base",
FrameRoleEnum.Base,
Pose("world", 0.0, 0.0, 0.82),
frame => frame.WithParent(world));
IIntentFrameBuilder flange = controller.AddFrame(
"Flange",
"robot-flange",
FrameRoleEnum.MechanicalInterface,
Pose("robot-base", 0.0, 0.0, 0.18),
frame => frame.WithParent(@base));
IIntentFrameBuilder tcp = controller.AddFrame(
"GripperTcp",
"gripper-tcp",
FrameRoleEnum.Tool,
Pose("robot-flange", 0.0, 0.0, 0.12),
frame => frame.WithParent(flange));
controller.AddTool("ParallelGripper", tcp, fitted: true);
for (uint index = 0; index < 6; index++)
{
controller.AddAxis($"J{index + 1}", index, AxisKindEnum.Revolute);
}
controller.AddLocation(
"Bin",
Pose("world", 0.45, -0.30, 0.82),
location => location.WithOccupancy(false, capacity: 1));
controller.AddLocation("Fixture", Pose("world", 0.50, 0.25, 0.82));
controller.AddOutput("GripperOpen", DataTypeIds.Boolean, new Variant(true));
controller.AddOutput("BenchLight", DataTypeIds.Boolean, new Variant(false));
controller.AddProgram("Home", "home");
controller.AddProgram("PickAndPlace", "pick-and-place");
controller.AddRealTimeChannel(
"JointTelemetry",
"joint-telemetry",
RealTimeTransportEnum.OpcUaFx,
"opc.udp://239.0.0.40:4840");
controller.WithDescription(description => description
.WithKinematicChain(CreateKinematicChain())
.WithLimits(
reachRadius: 0.85,
payloadLimit: 5.0,
maxCartesianSpeed: 0.25,
maxCartesianAcceleration: 0.7));
}
static Pose3DDataType Pose(string frameId, double x, double y, double z)
{
return new Pose3DDataType
{
FrameId = frameId,
Position = new[] { x, y, z }.ToArrayOf(),
Orientation = new[] { 0.0, 0.0, 0.0, 1.0 }.ToArrayOf()
};
}
static ArrayOf<KinematicJointDataType> CreateKinematicChain()
{
var joints = new KinematicJointDataType[6];
for (int ii = 0; ii < joints.Length; ii++)
{
joints[ii] = new KinematicJointDataType
{
AxisId = $"J{ii + 1}",
Kind = AxisKindEnum.Revolute,
OriginTransform = Pose(ii == 0 ? "robot-base" : $"J{ii}", 0.0, 0.0, 0.12),
AxisVector = new[] { 0.0, 0.0, 1.0 }.ToArrayOf()
};
}
return joints.ToArrayOf();
}
public sealed class MySafetySource : IRobotIntentSafetySource
{
public ValueTask<RobotIntentSafetySnapshot> ReadAsync(CancellationToken cancellationToken)
{
return new ValueTask<RobotIntentSafetySnapshot>(new RobotIntentSafetySnapshot(
SafeMotionFunctionEnum.None,
EmergencyStopActive: false,
ProtectiveStopActive: false,
SafeSpeedLimitActive: false,
SafeSpeedLimit: 0.0,
SafetyControllerOk: true,
LocalizedText.Null));
}
}
The builder enforces the invariants that make the address space dependable. A tool can only point at a
TCP frame whose role is Tool, and at most one tool below a controller can be Fitted=true, because a
motion intent otherwise has no unambiguous active tool centre point. Axis indices must be unique and
contiguous from zero, because JointMoveIntentDataType.JointTargets is an array and the index is the
array coordinate. Capabilities.AxisCount is written from the number of axes, so a client can validate
a joint target vector before submitting. Every capability must include BufferModeEnum.Aborting, which
is the fail-safe "replace what is running" mode every controller must understand.
Declare only what the executor can really do. Accepts<TIntent>() is not documentation; it is the
admission rule. If a robot cannot abandon a tool change safely, declare
Accepts<ToolChangeIntentDataType>(cancelSupported: false) and implement CanCancel accordingly.
Writing an executor
IntentControllerHost owns admission, queueing, the Part 10 state machine, cancellation acceptance and
the final result node. IIntentExecutor owns the doing: turning a typed intent into controller-specific
motion, reporting progress and returning an outcome.
using System;
using System.Threading;
using System.Threading.Tasks;
using Opc.Ua;
using Opc.Ua.RobotIntent;
public sealed class MyIntentExecutor : IIntentExecutor
{
public async ValueTask<IntentOutcome> ExecuteAsync(
IntentExecution execution,
CancellationToken cancellationToken)
{
switch (execution.Intent)
{
case LinearMoveIntentDataType linear:
await MoveLinearAsync(linear.Target, execution.Progress, cancellationToken);
return IntentOutcome.SucceededAt(linear.Target);
case JointMoveIntentDataType joint when joint.HasJointTargets:
await MoveJointsAsync(joint.JointTargets, execution.Progress, cancellationToken);
return IntentOutcome.Success;
case WaitIntentDataType wait:
await Task.Delay(TimeSpan.FromMilliseconds(wait.Duration), cancellationToken);
return IntentOutcome.Success;
default:
return IntentOutcome.Fail(
IntentFailureEnum.CapabilityNotSupported,
"The executor does not implement this intent.");
}
}
public bool CanCancel(IntentExecution execution)
{
return execution.Intent is not ToolChangeIntentDataType;
}
private static async Task MoveLinearAsync(
Pose3DDataType target,
IIntentProgress progress,
CancellationToken cancellationToken)
{
for (int step = 1; step <= 20; step++)
{
cancellationToken.ThrowIfCancellationRequested();
progress.ReportProgress(step / 20.0);
progress.ReportPose(target);
await Task.Delay(TimeSpan.FromMilliseconds(50), cancellationToken);
}
}
private static async Task MoveJointsAsync(
ArrayOf<double> joints,
IIntentProgress progress,
CancellationToken cancellationToken)
{
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
progress.ReportProgress(1.0);
}
}
The cancellation token is signalled after a cancel has been accepted and the operation has entered
Cancelling. The executor then brings motion to a controlled end and returns; it does not need to
manufacture a Cancelled result, because the host records that terminal state. CanCancel is the
per-operation hook for refusing a cancel that would leave the cell in a worse state, such as a tool
change mid-exchange.
Client: submit and await
The high-level client discovers controllers under Server/RobotIntent/Controllers, reads their
capabilities, takes command authority, builds a typed intent and returns an awaitable operation handle.
using System;
using Opc.Ua;
using Opc.Ua.RobotIntent;
using Opc.Ua.Robotics.Client.Intent;
RobotIntentClient discovery = session.RobotIntent(telemetry);
ArrayOf<RobotIntentNodeLookupEntry> controllers =
await discovery.DiscoverControllersAsync(ct);
RobotIntentControllerClient controller = discovery.Controller(controllers[0].NodeId);
RobotIntentControllerInfo info = await controller.ReadAsync(ct);
await using CommandAuthorityLease authority =
await controller.RequestAuthorityAsync(ct);
if (!authority.Granted)
{
throw new InvalidOperationException($"Command authority is held by {authority.CurrentOwner}.");
}
Pose3DDataType target = RobotIntentBuilder.Pose(
x: 0.45,
y: -0.30,
z: 0.82,
qx: 0.0,
qy: 0.0,
qz: 0.0,
qw: 1.0,
frameId: "world");
LinearMoveIntentDataType intent = RobotIntentBuilder
.LinearMove(target, speed: 0.2)
.WithIntentId("move-to-bin")
.WithBufferMode(BufferModeEnum.Aborting)
.Build();
await using IntentOperationHandle handle =
await controller.SubmitIntentAsync(intent, ct);
IntentResultDataType result = await handle.Completion;
Console.WriteLine($"{handle.IntentId} ended with {handle.Current.ExecutionState}: {result.Failure}");
Use TrySubmitIntentAsync when refusal is part of the normal control flow and you want the fixed
IntentFailureEnum rather than an exception:
IntentSubmissionResult submission = await controller.TrySubmitIntentAsync(intent, ct);
if (!submission.Accepted)
{
Console.WriteLine($"Refused: {submission.Failure} {submission.Message.Text}");
return;
}
await using IntentOperationHandle handle =
await controller.TrackOperationAsync(submission.IntentId, submission.Operation, ct);
IntentOperationHandle.StartAsync subscribes to ExecutionState, Progress, CurrentPose and
Result. The Changed event is the convenient way to update a UI:
handle.Changed += snapshot =>
{
Console.WriteLine($"{snapshot.ExecutionState} {snapshot.Progress:P0}");
Pose3DDataType current = snapshot.CurrentPose;
_ = current.FrameId;
};
CurrentPose is a status report at the Subscription's sampling and publishing rate. It is deliberately
not a servo channel; using it to close a motion-control loop is outside the Robot Intent model and
outside OPC UA client/server timing guarantees.
Client: missions
A mission builder emits the same IntentDataType structures used for single submissions. Released
steps form the base; unreleased steps form the horizon. A horizon update replaces the unreleased
suffix while preserving the released prefix.
MissionDataType mission = RobotIntentBuilder.Mission("tray-42")
.WithMissionUpdateId(1)
.ReleasedStep("approach", RobotIntentBuilder
.LinearMove(RobotIntentBuilder.Pose(0.40, -0.20, 0.90, 0, 0, 0, 1, "world"), 0.2)
.Build())
.ReleasedStep("pick", RobotIntentBuilder.Pick(
info.Lookups.Locations[0].NodeId,
info.Lookups.Tools[0].NodeId).Build())
.HorizonStep("place", RobotIntentBuilder.Place(
info.Lookups.Locations[1].NodeId,
info.Lookups.Tools[0].NodeId).Build())
.Build();
MissionSubmissionResult submitted = await controller.SubmitMissionAsync(mission, ct);
if (!submitted.Accepted)
{
Console.WriteLine($"Mission refused: {submitted.Failure} {submitted.Message.Text}");
return;
}
ArrayOf<MissionStepDataType> revisedHorizon = new[]
{
new MissionStepDataType
{
StepId = "place",
SequenceId = 3,
Released = false,
Intent = RobotIntentBuilder.Place(
info.Lookups.Locations[1].NodeId,
info.Lookups.Tools[0].NodeId).Build()
}
}.ToArrayOf();
MissionUpdateOutcome update =
await controller.UpdateMissionAsync("tray-42", 2, revisedHorizon, ct);
switch (update.Result)
{
case MissionUpdateResultEnum.Accepted:
break;
case MissionUpdateResultEnum.Outdated:
await controller.ReadAsync(ct);
break;
case MissionUpdateResultEnum.BaseConflict:
Console.WriteLine("The update changed a released step; rebuild from the current base.");
break;
case MissionUpdateResultEnum.UnknownMission:
Console.WriteLine("The mission has already ended or was never admitted.");
break;
case MissionUpdateResultEnum.Rejected:
Console.WriteLine(update.Message.Text);
break;
}
Transitions turn the flat list into a step graph. MissionCondition.Always() creates the empty
ContentFilter that OPC UA defines as true; an empty Transitions array leaves the mission as a flat
sequence.
MissionDataType branched = RobotIntentBuilder.Mission("inspect-or-rework")
.WithMissionUpdateId(1)
.ReleasedStep("inspect", RobotIntentBuilder.CallProgram(info.Lookups.Programs[0].NodeId).Build())
.HorizonStep("accept", RobotIntentBuilder.Wait(100).Build())
.HorizonStep("rework", RobotIntentBuilder.CallProgram(info.Lookups.Programs[1].NodeId).Build())
.Transition("inspect", "accept", DivergenceKindEnum.Alternative, MissionCondition.Always())
.Transition("inspect", "rework", DivergenceKindEnum.Alternative)
.ErrorPolicy("rework", ErrorPolicyEnum.Retry)
.Build();
Client: cancellation, pause and retry
Cancellation is refusal-aware. A server may legitimately refuse because the executor's CanCancel
returned false or because the session does not hold command authority. Cancelling is not terminal;
wait for the operation handle to reach Cancelled, Succeeded, Failed or Retriable.
IntentCommandOutcome cancel =
await handle.CancelAsync(StopModeEnum.QuickStop, ct);
if (!cancel.Accepted)
{
Console.WriteLine("The server refused this cancel request.");
}
IntentCommandOutcome pause = await handle.PauseAsync(ct);
if (pause.Accepted)
{
await handle.ResumeAsync(ct);
}
IntentResultDataType final = await handle.Completion;
if (handle.Current.ExecutionState == ExecutionStateEnum.Retriable)
{
IntentSubmissionResult retry = await handle.RetryAsync(ct);
if (retry.Accepted)
{
await using IntentOperationHandle retryHandle =
await controller.TrackOperationAsync(retry.IntentId, retry.Operation, ct);
final = await retryHandle.Completion;
}
}
Pause stops queue dispatch only. An intent that is already executing keeps reporting Executing;
Resume lets queued work start again.
Pose maths
PoseMath implements Annex C conversion between Robot Intent's (x, y, z, w) quaternion and the core
OPC UA ThreeDFrame A/B/C orientation:
Pose3DDataType pose = RobotIntentBuilder.Pose(
0.4, 0.2, 0.8,
0.0, 0.0, 0.3826834323650898, 0.9238795325112867,
"world");
ThreeDFrame frame = PoseMath.ToThreeDFrame(pose);
Pose3DDataType roundTripped = PoseMath.FromThreeDFrame(frame, "world");
if (!PoseMath.TryValidate(roundTripped, 1e-6, out string? error))
{
throw new InvalidOperationException(error);
}
FrameTree is the corresponding helper for re-expressing poses through a declared frame graph:
var frames = new FrameTree();
frames.TryAdd(
"world",
"",
RobotIntentBuilder.Pose(0, 0, 0, 0, 0, 0, 1, "world"),
FrameRoleEnum.World,
out _);
frames.TryAdd(
"robot-base",
"world",
RobotIntentBuilder.Pose(0.5, 0.0, 0.0, 0, 0, 0, 1, "world"),
FrameRoleEnum.Base,
out _);
Pose3DDataType inBase = RobotIntentBuilder.Pose(
0.1, 0.0, 0.2,
0.0, 0.0, 0.0, 1.0,
"robot-base");
if (frames.TryExpress(inBase, "world", out Pose3DDataType inWorld, out string? frameError))
{
Console.WriteLine(inWorld.Position[0]);
}
Handling refusal
A refusal is an ordinary method outcome: the Method call returns Good, Accepted is false and the
failure is in the output arguments. A Bad StatusCode still means the transport, Session or Service
layer failed. The point of the small failure set is that the client can choose a policy without parsing
human text:
IntentSubmissionResult submission = await controller.TrySubmitIntentAsync(intent, ct);
if (!submission.Accepted)
{
switch (submission.Failure)
{
case IntentFailureEnum.QueueFull:
await Task.Delay(TimeSpan.FromSeconds(1), ct);
break;
case IntentFailureEnum.ParameterInvalid:
case IntentFailureEnum.JointLimit:
case IntentFailureEnum.WorkspaceLimit:
Console.WriteLine("Re-plan with a reachable target.");
break;
case IntentFailureEnum.ControlNotOwned:
case IntentFailureEnum.NotPermittedInMode:
case IntentFailureEnum.SafetyLimitExceeded:
Console.WriteLine($"Operator action required: {submission.Message.Text}");
break;
default:
Console.WriteLine(submission.Message.Text);
break;
}
}
Facets in code
A controller publishes the facets it claims in the read-only Capabilities.SupportedFacets, so a
client reads the claim rather than reconstructing it. The server binds that variable to the facet
calculator, so the list is recomputed on every read and tracks the address space instead of being a
registration-time snapshot. On the server-side builder:
ArrayOf<string> facets = builder.ComputeFacets();
ArrayOf<string> published = RobotIntentFacetCalculator.Compute(builder.State);
On the client, ReadAsync returns what the server published:
RobotIntentControllerInfo info = await controller.ReadAsync(ct);
if (info.SupportedFacets.Contains("RI-Mission-Horizon"))
{
Console.WriteLine("The controller accepts missions with horizon updates.");
}
if (!info.Facets.EveryCapabilitySupportsAborting)
{
throw new InvalidOperationException("The server published an invalid capability set.");
}
info.Facets remains available as a convenience projection of the individual capability variables,
and against a server that predates SupportedFacets it is all there is. Prefer SupportedFacets
where the server publishes it: the projection can only see the flags, so it necessarily disagrees with
the server about any facet whose requirements go beyond a single flag.
That disagreement is the reason SupportedFacets exists. A facet is not a restatement of the
declaration a client has already read. Some of what the table below requires — that blending modes are
honoured, that the refusal rules are followed, that a mission base is immutable — cannot be settled by
reading the address space at all, so a client deriving facets locally is guessing at precisely the
rows that matter most. A published claim that could drift from the model would reintroduce the same
defect with the server's authority behind the wrong answer; the live read binding makes that drift
impossible by construction.
Limitations
The current stack implements the draft information model, admission rules, Part 10 operation lifecycle,
missions, command authority, cancellation, safety observation, capability/facet reporting, real-time
channel leasing and the client handles shown above. It does not provide a safety-rated interface, a
servo-level real-time channel, or a vendor robot driver. The facet calculator checks every structural
requirement in clause 12.2, but behavioural requirements remain the server's attestation and require
interop or acceptance testing to verify. The sample executor is a simulator, transition conditions are
only as powerful as the server-supplied ConditionEvaluator, and real-time channels are brokered as
leases rather than implemented as a cyclic transport in this package. The namespace
http://opcfoundation.org/UA/RobotIntent/ and all NodeIds remain provisional until the companion
specification is ratified.
Why a submission is not a method call
An OPC UA Call cannot stay open for the length of a real motion. Session timeouts, SecureChannel
re-keying and transport timeouts all bound it, and OPC 10000-4 §5.12.2 is explicit that when the
Session ends the method result is discarded "independent of the task actually performed at the
Server". A synchronous method that commands a robot is therefore not merely inelegant: it loses the
outcome of work that has already physically happened. The robot keeps moving after the answer has
been thrown away.
OPC 10000-10 gives the OPC Foundation's own resolution — a Method performs a calculation, a
Program runs a batch process or a machine-tool part program. So SubmitIntent returns as soon as
the intent is admitted, and what it returns is a NodeId: an IntentOperationType instance, a
Part 10 program instance created for that submission, which the client subscribes to for progress and
reads for the result.
Building on ProgramStateMachineType buys four things this model then does not have to invent:
transition events, a terminal result object that survives the operation, invocation diagnostics
recording which Session commanded what, and a lifetime model for the instance itself. Two of those —
FinalResultData and ProgramDiagnostic — are Optional in Part 10, and Robot Intent promotes both to
Mandatory, because a shall that rests on a member a conformant server may omit is not a
requirement.
The intent hierarchy
Intents are a DataType hierarchy, not one Method per verb.
IntentDataType (abstract)
├── MotionIntentDataType (abstract)
│ ├── JointMoveIntentDataType movej / MoveJ / PTP / J / MOVJ
│ ├── LinearMoveIntentDataType movel / MoveL / LIN / L / MOVL
│ ├── CircularMoveIntentDataType movec / MoveC / CIRC / C / MOVC
│ ├── TrajectoryIntentDataType a time-parameterised path, handed over whole
│ ├── CartesianPathIntentDataType a taught path with per-waypoint blending
│ ├── ForceIntentDataType move until contact
│ └── ProcessIntentDataType (abstract)
│ ├── ArcWeldIntentDataType SpotWeldIntentDataType
│ ├── DispenseIntentDataType FastenIntentDataType
│ └── PalletiseIntentDataType SurfaceFinishIntentDataType
├── GraspIntentDataType / ReleaseIntentDataType
├── PickIntentDataType / PlaceIntentDataType
├── ToolChangeIntentDataType
├── SetOutputIntentDataType
├── CallProgramIntentDataType
└── WaitIntentDataType
Three consequences follow, and each is why the shape was chosen:
- A single intent and a mission step are the same thing.
MissionStepDataType.Intentis anIntentDataType, so nothing has to be expressed twice. - Extension is subtyping. A vendor adds an intent by deriving from
IntentDataType. It is then carried, queued, cancelled and reported by the existing machinery without a new Method. - Discovery is a read, not a probe.
IntentCapabilitiesType.SupportedIntentsnames each accepted DataType, so a client learns what a robot accepts by reading one Variable rather than by browsing for BrowseNames and inferring support from their presence.
Poses, frames and units
Pose3DDataType carries a FrameId, a Position of three doubles in metres, and an
Orientation of four doubles forming a unit quaternion ordered (x, y, z, w).
Four rules make that unambiguous, and the server enforces all of them:
- Every frame is right-handed.
- Units are fixed by the specification and are not negotiable per instance: position in metres,
joint targets in radians for a
Revoluteaxis and metres for aPrismaticone, force in newtons, durations in milliseconds.Pose3DDataTypeappears as a Method argument, where noEUInformationproperty can reach it, so a per-instance unit would be undeliverable. Orientationmust be normalised. A quaternion whose norm differs from 1 by more than1e-6is rejected withParameterInvalid.FrameIdnames aCoordinateFrameTypeinstance under the controller'sFramesfolder. An emptyFrameIdmeans the server's default work frame.
Quaternions are used because OPC UA defines no quaternion DataType anywhere, and because the A, B
and C fields of the core ThreeDOrientation carry no convention of their own. PoseMath implements
the specification's Annex C conversion in both directions, including the two properties that are
normative and easy to get wrong:
- the
asinargument is clamped to[-1, +1], because floating-point error at a pole otherwise turns a legal orientation into a domain error; qand-qdenote the same orientation, and the conversion emits the representative whosewis non-negative, so two servers describing one orientation produce the same four numbers.
FrameTree composes the transforms along the path between two frames, so a pose given in one frame can
be re-expressed in another.
The lifecycle
The Part 10 state machine carries the coarse state and generates the events. ExecutionState refines
it, because Queued, Cancelling and the three distinct terminal outcomes cannot be told apart from
CurrentState alone. The pairing is exhaustive — a combination not in this table is not legal:
ExecutionState |
Part 10 state | Meaning |
|---|---|---|
Accepted |
Ready |
Admitted and validated; not yet queued or executing. |
Queued |
Ready |
Waiting behind another intent. QueuePosition is non-zero. |
Executing |
Running |
Commanding the robot now. |
Suspended |
Suspended |
Reserved for an executor-visible suspension; the stock Pause command does not publish it. |
Cancelling |
Running |
A cancel was accepted; motion is being brought to a controlled end. |
Succeeded |
Halted |
Terminal. Completed as requested. |
Failed |
Halted |
Terminal. Result.Failure carries the reason. |
Cancelled |
Halted |
Terminal. Ended early because a cancel was accepted. |
Retriable |
Halted |
Terminal for now; Retry may re-attempt it. |
Cancelling is not terminal. A client that treats acceptance of a cancel as the end of motion acts
too early.
Refusal is an ordinary outcome
SubmitIntent refuses in a fixed order, and the order matters — a caller that lacks authority must be
told that, not that its parameters are wrong:
ControlNotOwned— the calling Session does not hold command authority.NotPermittedInMode—OperationalModeis notAutomaticorAutomaticExternal.CapabilityNotSupported— the intent's DataType is not amongSupportedIntents, or itsBufferMode/BlockingModeis not among those the capability entry permits.ParameterInvalid— a parameter is missing, malformed or out of range.QueueFull— admitting it would exceedMaxQueueDepth.
A refusal creates no operation instance and moves nothing. It is reported in the output arguments —
Accepted false with a Failure and a Message — and the call returns Good. A Bad StatusCode
still means what it always meant: the transport, the Session or the Service layer failed. The
distinction is normative, and it is what makes the failure set diagnosable: a client decides whether to
retry, re-plan or escalate from the IntentFailureEnum value alone.
Queueing and blending
BufferMode decides how a new submission relates to what is already executing. The values are
PLCopen's MC_BufferMode, adopted unchanged:
| Value | Meaning |
|---|---|
Aborting |
Abort what is executing and start immediately. The default, and always accepted. |
Buffered |
Queue; start when the predecessor succeeds. |
BlendingLow / BlendingPrevious / BlendingNext / BlendingHigh |
Queue, and do not decelerate to a stop at the boundary. |
Where blending occurs, the predecessor reaches Succeeded when blending begins, not when its
target is exactly attained, and its Result.AchievedPose records where the tool centre point was at
that moment. That is what PLCopen defines, and reporting it any other way would tell a client the robot
stopped somewhere it never was. A server that accepts a blending mode but executes it as Buffered
reports BlendingSupported false, so a client can tell a robot that blends from one that merely
tolerates being asked to.
BlockingMode is orthogonal and constrains concurrency rather than ordering — it is the VDA 5050
blockingType matrix. A server does not begin an intent whose BlockingMode is Single or Hard
while any other intent is executing.
Cancellation is not the Cancel Service
The OPC UA Cancel Service in OPC 10000-4 §5.7.5 cancels an outstanding service request. It does
not stop the robot: it returns Bad_RequestCancelledByClient for that request and leaves the motion
running. Stopping a robot is CancelIntent, CancelMission or CancelAll.
A server may refuse a cancel and says so in the Accepted output. Some motions cannot be abandoned
part-way without leaving the cell worse than completing them would — a tool change mid-exchange, a
placement mid-release.
Missions
A mission is an ordered sequence of intents submitted and tracked as a unit, so a supervisor can commit work in advance and still change what has not yet been committed.
Every step carries Released. The released steps form a prefix called the base; the rest form the
horizon.
Step 0 Step 1 Step 2 │ Step 3 Step 4
released released released │ horizon horizon
└────────── base: committed ─────────┘ └── revisable ──┘
The base is committed and immutable: the server assumes every released step is executing or already
executed and refuses any update that would alter, remove or reorder one. UpdateMission replaces the
horizon wholly and may release some of it, extending the base. MissionUpdateId must be strictly
greater than the mission's current value, which is what makes two updates that crossed in flight safe —
the later one wins and the earlier is rejected with Outdated rather than applied out of order. An
update is applied atomically.
Where a mission carries Transitions, it becomes the step-and-transition form of an IEC 61131-3
sequential function chart. Conditions are OPC UA ContentFilters — the base specification's own filter
grammar, reused rather than invented — and DivergenceKind says whether exactly one transition is taken
(Alternative, evaluated in array order so two clients predict the same branch) or all of them are
(Parallel). Per-step ErrorPolicy covers Abort, Retry, Skip, Fallback and Compensate. An
empty Transitions array leaves the mission the flat sequence it was, which is what makes the graph an
addition rather than a replacement.
Command authority
At most one Session at a time holds command authority over a controller, and only that Session may submit. Authority is released automatically when the holding Session closes, so a crashed client does not lock a robot permanently. Reading, browsing and subscribing require no authority: observation is always permitted.
Command authority arbitrates between OPC UA clients. It is not the single point of control that ISO 10218-2 requires — that concerns mutual exclusion of remote command and local manual control and is enforced by safety-rated means outside this interface. It is also not authorisation: a Session that holds authority but lacks the necessary Role is still refused.
Safety, and the boundary that is never crossed
This is a non-safety-rated application interface. The Methods here are application-level requests. They do not constitute, and must not be used as, safety functions as defined in IEC 61508, nor safety communication as defined in IEC 61784-3 or IEC 62541-15.
This is a property of the technology, not a scoping preference. OPC 10000-15 carries cyclic safety data from a SafetyProvider to a SafetyConsumer, and the consumer's request carries an identifier, a monitoring number and one octet of explicitly non-safety flags — so a caller has no channel through which to supply safety-rated arguments. Every safety fieldbus expresses a safety command as a continuously asserted cyclic signal, because the integrity argument rests on the fail-safe state that follows when assertion stops. A Method call has no defined behaviour when it stops being called, and therefore cannot be a safety function however it is labelled.
What the model can do is observe and refuse. SafetyStateType reports what the safety system is
enforcing, and the server refuses a submission:
- with
SafetyLimitExceeded, whenSafeSpeedLimitActiveis true and the intent'sConstraints.CartesianSpeedexceedsSafeSpeedLimit; - with
NotPermittedInMode, whenEmergencyStopActiveorProtectiveStopActiveis true, whenSafetyControllerOkis false, or whenOperationalModeis notAutomaticorAutomaticExternal.
Each of those is observable against a running server: assert a protective stop and a conformant server
refuses; lower SafeSpeedLimit below a submitted speed and it refuses.
What none of that makes true. These refusals are an application-layer courtesy performed by non-safety-rated software. They reduce the number of requests the safety system has to reject; they are not a protective measure.
- A client must not treat acceptance of an intent as evidence that the motion is safe.
- A client must not treat
SafeSpeedLimitas a limit this interface enforces — the safety system enforces it, and would enforce it identically if this model did not exist. StopModeexpresses urgency and selects no IEC 60204-1 stop category. A client that requires a category-rated stop obtains it from the safety system; it cannot be obtained here.- The model may observe the safety system and refuse on what it sees. It may never
instruct it: no Method commands a safe motion function, changes an operational mode, or clears a
stop.
OperationalModeis read-only, because mode selection is a safety function performed by a key switch or an interlock, and an interface that could change it from the network would defeat the arrangement it is reporting.
What this interface carries, and what it brokers
OPC UA method invocation is not deterministic and completes in tens of milliseconds. Vendor real-time channels run two to four orders of magnitude faster on dedicated transports. The model divides the work rather than pretending the gap is not there.
Carried here. A trajectory, a Cartesian path or a force-controlled move is handed over whole and
run by the robot's own motion kernel. The round trip happens once, at submission, so transport latency
bounds how quickly work can be started and never how accurately it is executed. This is the shape of
FollowJointTrajectory in ROS and of the PLCopen buffered path function blocks, and it is why
trajectory execution belongs here while trajectory streaming does not.
Brokered. Where a client genuinely needs a high-rate channel — visual servoing, force tracking,
conveyor following — RealTimeChannelType describes one and OpenRealTimeChannel leases it. The
samples travel on that channel and never through this interface. Of the transports named (Rtde,
Egm, Fri, Rsi, MotoRos2, OpcUaFx, Other) only OpcUaFx is an OPC Foundation specification;
the rest are vendor channels the model describes without defining.
A lease lapses at LeaseExpiry unless renewed, and is released when the holding Session closes — the
same reasoning as command authority: a client that dies must not hold a resource for good. While a lease
is held, the server refuses motion intents with CapabilityNotSupported unless it can genuinely
arbitrate between the two sources, because two things commanding one robot with no arbitration is
exactly the failure that rule exists to prevent.
IntentOperationType.CurrentPose exists so a client can watch a motion. It is a status report
delivered at whatever rate the client's Subscription asks for, and using it to close a control loop is
outside this model.
NodeIds in intents are untrusted input
Every NodeId-valued member of an intent is chosen by the client, so the server validates that each
resolves to a node of the expected type under the controller being commanded, and refuses with
ParameterInvalid otherwise. A NodeId that resolves to a node belonging to a different controller, or
to no node at all, is never acted on.
| Member | Resolves to |
|---|---|
PickIntentDataType.Source, PlaceIntentDataType.Destination, PalletiseIntentDataType.Pattern |
a LocationType under the controller |
MotionIntentDataType.ToolFrame, ForceIntentDataType.FrameId |
a CoordinateFrameType under the controller; ToolFrame additionally of Role Tool |
ToolChangeIntentDataType.Tool |
a ToolType under the controller, or null to release the fitted tool |
SetOutputIntentDataType.Output |
an OutputSignalType under the controller; Value must match that signal's own DataType |
CallProgramIntentDataType.Program, ProcessIntentDataType.ProcessProgram |
a ProgramType under the controller |
WaitIntentDataType.Signal |
an OutputSignalType under the controller, or a Boolean Variable under it |
FastenIntentDataType.Joint |
a joint in an OPC 40450/40451 model where one is implemented |
CallProgramIntentDataType deserves particular care because it runs code the server holds: it is
restricted to programs published as ProgramType instances, and a program identifier naming anything
else is refused.
Commanding is a privileged operation. Every Method here moves a machine that can injure people and
destroy property, so the server requires an authenticated Session and restricts the Methods of
IntentControllerType by Role, distinctly from read access to the same address space. Observing a robot
and commanding one are different privileges and are not conflated. UserExecutable is applied so a
client discovers what it may invoke before invoking it.
Interoperating with OPC 40010
OPC 40010 describes the robot; Robot Intent commands it. The two are joined by one reference and are otherwise independent — this model takes no dependency on the Robotics NodeSet, and a server implementing only Robot Intent is fully conformant.
A server claiming the interop profile exposes a HasIntentController reference from the
MotionDeviceSystemType instance describing the robot to the IntentControllerType instance that
commands it, reports the same operational mode as the OPC 40010 model, publishes as ProgramType
instances exactly those programs the OPC 40010 task control can load, and expresses its poses in frames
consistent with the mounting and geometry OPC 40010 describes. The published RI-Interop-40010 facet
is derived from the inverse of that reference on the intent controller, and because SupportedFacets
is a live read binding, the claim tracks the address space whether the reference is attached before or
after the controller is registered.
It does not duplicate OPC 40010's topology. AxisType exists here only to fix the order, kind and
limits a joint target needs; where OPC 40010 is also implemented its axis description is the fuller one
and OPC 40010 decides. RobotDescriptionType.KinematicChain is additive, because OPC 40010 defines
no kinematic chain an inverse-kinematics solver could use — and no tool centre point at all, which is
why ToolType.TcpFrame supplies the concept and has nothing in OPC 40010 to contradict.
Facets
Only RI-Base is mandatory. A server implements the facets it can honour and declares the rest false;
a facet other than RI-Base is claimed only where every intent type it names appears in
SupportedIntents. Each controller lists what it claims in Capabilities.SupportedFacets, which
RI-Base requires — a conformance claim that cannot be read is not a claim.
Requirements below are of two kinds, and the difference decides what a tool can check. Structural
requirements are settled by reading the address space and the capability declaration; the facet
calculator checks every one of them, and a server shall not list a facet whose structural requirements
are unmet. Attested requirements — accepting, honouring, maintaining or observing a rule — cannot
be settled by reading, only by exercising the server, and are the server's own statement under the
honesty rules. Listing RI-Blending while treating the blending modes as Buffered is a false
statement in exactly the sense the honesty rules forbid, whatever BlendingSupported says.
| Facet | Requires |
|---|---|
| RI-Base (mandatory) | RobotIntentRootType; at least one IntentControllerType with Capabilities, Frames, Tools, Locations, Axes and Intents; SupportedFacets; SubmitIntent, CancelIntent, CancelAll, RequestControl, ReleaseControl; IntentOperationType instances with the state model above; the refusal rules (attested) |
| RI-Motion-Joint / -Linear / -Circular | the corresponding move intent; joint additionally needs AxisType instances covering 0..AxisCount − 1 |
| RI-Trajectory | TrajectoryIntentDataType, TrajectorySupported true, and the tolerance rules (attested) |
| RI-Path | CartesianPathIntentDataType and TrajectorySupported true |
| RI-Force | ForceIntentDataType and ForceControlSupported true — the robot genuinely regulates force (attested) |
| RI-RealTimeChannel | RealTimeChannelsSupported true, the RealTimeChannels folder, and the lease rules (attested) |
| RI-Safety | SafetyState present with a bound safety source; populated from the safety system, and the safety refusals (attested) |
| RI-Description | Description with a KinematicChain covering every axis, ReachRadius, PayloadLimit, MaxCartesianSpeed |
| RI-Process-ArcWeld / -SpotWeld / -Dispense / -Fasten / -Palletise / -SurfaceFinish | the corresponding process intent; palletise also needs a LocationType pattern, surface finish also needs RI-Force |
| RI-Grasp | GraspIntentDataType, ReleaseIntentDataType, and a ToolType with a TcpFrame |
| RI-PickPlace | PickIntentDataType, PlaceIntentDataType, and a LocationType |
| RI-ToolChange | ToolChangeIntentDataType and more than one ToolType |
| RI-Output / RI-Program / RI-Wait | SetOutputIntentDataType + Outputs; CallProgramIntentDataType + Programs; WaitIntentDataType |
| RI-Queue | MaxQueueDepth greater than zero and Buffered accepted; QueuePosition maintained (attested) |
| RI-Blending | BlendingSupported true and the four blending modes accepted; the modes honoured and Result.AchievedPose at the blend point (attested) |
| RI-Pause / RI-Retry | Pause and Resume; Retry with Retriable reachable |
| RI-Mission | MissionsSupported true, SubmitMission, CancelMission, MissionType instances |
| RI-Mission-Horizon | RI-Mission plus MissionHorizonSupported and UpdateMission; base immutability (attested) |
| RI-Mission-Branching | RI-Mission plus MissionBranchingSupported; transitions evaluated and error policies honoured (attested) |
| RI-Interop-40010 | inverse HasIntentController from the MotionDeviceSystemType instance to the IntentControllerType instance; operational-mode agreement with OPC 40010, ProgramType instances exactly matching the programs the OPC 40010 task control can load, pose/frame consistency and safety consistency (attested) |
See also
- Robot Intent — the task-level motion verbs this model leaves
undefined. OPC 40010 describes the robot; Robot Intent commands it, and the two
are joined by a single
HasIntentControllerreference rather than by either model depending on the other. - Device Integration (DI) developer guide — the base model, fluent device builders, and the companion-spec packaging pattern.
- Relative Spatial Location and Global Positioning — the RSL and GPOS models used by the robot sample.
- OpenUSD binding — rendering a robot cell as a live USD stage and driving intents from a viewport pick.
- State machines — the Part 10 program lifecycle Robot Intent builds on.
- Subscriptions — how the Robot Intent client tracks an operation.
- Robotics samples — runnable servers and clients.
- Dependency Injection — the shared
AddOpcUa()hosting surface. - Source Generated NodeManagers — the fluent
INodeManagerBuilderand generated factories the builders compose.