Skip to content

Robot intent: a task-level command API with a Part 10 lifecycle - #4165

Draft
marcschier wants to merge 3 commits into
OPCFoundation:masterfrom
marcschier:marcschier/robot-intent
Draft

Robot intent: a task-level command API with a Part 10 lifecycle#4165
marcschier wants to merge 3 commits into
OPCFoundation:masterfrom
marcschier:marcschier/robot-intent

Conversation

@marcschier

@marcschier marcschier commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Description

Update the task-level robot intent API added earlier: submit a motion or a task, get a handle back, watch it to completion. 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. #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

  • The model is source-generated from a NodeSet. Opc.Ua.RobotIntent.NodeSet2.xml is added to Opc.Ua.Robotics as an AdditionalFiles entry, so the enums, the polymorphic intent structures, IntentOperationState : ProgramStateMachineState, the method states and the typed clients all derive from the model rather than being hand-written.
  • 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.
  • IntentControllerHost owns admission (in the specification's order), the queue with PLCopen buffer modes, 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.
  • 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.

Bugs the tests caught

Each of these would have been silent in the field:

  • The result was published after the state went terminal, so a client acting on the transition read a null result.
  • Capabilities were resolved against whatever namespace table existed when they were declared, so the list silently matched nothing.
  • FinalResultData and the optional folders are Optional in their type definitions and so were never materialised — a server that implements a facet has to expose its optional members or the facet is unclaimable.
  • A channel lease taken by a caller with no Session left the holder null, so the channel still looked free and a second caller could take it.
  • 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.

Related Issues

There is no issue for this yet; opening as a draft to agree the design first. Happy to open a tracking issue and record the decision there if that is preferred.

The companion specification is drafted in the open at marcschier/opcua-drafts#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

  • I have signed the CLA and read the CONTRIBUTING doc.
  • I have added tests that prove my fix is effective or that my feature works and increased code coverage.
  • I have added all necessary documentation.
  • I have verified that my changes do not introduce (new) build or analyzer warnings.
  • I ran all tests locally using the UA.slnx solution against at least .net framework and .net 10, and all passed.
  • 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: the robotics suite — 149 tests, 25 of them new — passes on net48 and net10.0, with zero warnings on both, and the sample and client projects build. I have not yet run the whole UA.slnx across all five target frameworks, so the box stays unticked until I have.

Worth noting that requirement earned its keep immediately: it caught five APIs that do not exist on .NET Framework (a default interface implementation, ArgumentNullException.ThrowIfNull, string.Create with an interpolated handler, ValueTask.FromResult/CompletedTask, and generic Enum.GetValues), none of which the net10.0 build had complained about.

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. Should the model live in Opc.Ua.Robotics at all? 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. It sits here because this is where the robot-facing API already is.
  3. Removing the convention API from Robotics: the OPC 40010 companion SDK, its client and the robot sample #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.

marcschier and others added 3 commits August 2, 2026 14:35
The ten motion verbs merged in OPCFoundation#4127 established a vocabulary and left the
harder half undone. They were synchronous: no operation handle, no progress,
no server-side cancel, no queueing, no ownership - and the file said as much,
describing itself as a non-normative convention.

That shape cannot work. A motion takes seconds and a pick takes a minute,
while OPC 10000-4 discards a method result when the Session ends "independent
of the task actually performed at the Server". A synchronous motion method
therefore loses the outcome of work that has already physically happened.

This replaces the convention API with an implementation of the OPC UA - Robot
Intent draft (metaverse-specs/robot-intent in marcschier/opcua-drafts).
Submission returns a Part 10 program instance the client watches, which is
the resolution OPC 10000-10 already reaches for exactly this case.

The model is source-generated from its NodeSet, so the enums, the polymorphic
intent structures, IntentOperationState : ProgramStateMachineState and the
typed clients are all derived from the specification rather than hand-copied
from it. Verbs are a DataType hierarchy, so a submission and a mission step
are the same shape and a new intent is a subtype rather than a new method -
which is what AddOperation<TRequest,TResponse> was working around.

IntentControllerHost owns admission in the specification's order, the queue
with PLCopen buffer modes, cancellation with the server's right to refuse,
missions with an immutable committed base and a revisable horizon, and the
capability declaration. 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.

Three things the tests found, each of which would have been a defect in the
field rather than a test artifact:

  - The result was published after the state went terminal, so a client
    acting on the transition read a null result. It is now published first.
  - Capabilities were resolved against whatever namespace table existed when
    they were declared, so the list silently matched nothing. They now
    resolve when the host starts, and are published to the address space so
    the declaration a client reads is the one the host enforces.
  - FinalResultData and the optional folders are Optional in their type
    definitions and so were never materialised. A server that implements a
    facet has to expose its optional members or the facet is unclaimable.

124 tests pass, 24 of them new: the admission order, every state pairing in
the specification's table, buffered ordering, supersession reported as
Superseded rather than as a cancellation, a refused cancel, an accepted one,
retry as a new attempt that leaves the original's history intact, and the
mission base refusing to be altered.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1dab1302-19c5-4a9b-a50c-d97d389713aa
Follows the specification's enlarged scope: safety awareness, trajectories
and force, brokered real-time channels, and the mission step graph.

Safety is a report plus a refusal duty. UpdateSafetyState is how the
application tells the host what the safety system is enforcing; admission
then refuses on the same values a client can read, so a refusal is
explainable from the address space rather than from state only the Server can
see. Ready reflects it too - a client told Ready and then refused has been
told something untrue. Only an explicit Cartesian speed is compared against
the safe limit: a speed FRACTION is of a configured maximum the host does not
know, and refusing what cannot be judged would reject legitimate work.

Trajectories are validated wholly at admission, because a trajectory is
handed over in one call and there is no later exchange in which to complain:
ascending time, per-point axis count, and the declared point limit.

Channels are described and leased, never carried. While a lease is held the
host refuses motion intents unless it declares that it arbitrates, because
two things commanding one robot with no arbitration is the failure that rule
exists to prevent.

The mission engine gained the step graph and the five error policies.
Compensate differs from Fallback only in what happens after the fallback step
succeeds, and that is where the distinction is implemented.

Two bugs the tests found, both of which would have been silent in the field:

  - A lease taken by a caller with no Session left the holder null, so the
    channel still looked free and a second caller could take it. The lease is
    now tracked explicitly rather than inferred from the holder.
  - An empty ContentFilter arrives as an empty element array rather than a
    null filter, so testing only for null made every unconditional transition
    silently untaken - a mission would run its first step and stop. Both null
    and empty now mean unconditional.

149 tests pass, 25 of them new: the safety refusals and the limit that is not
being enforced, trajectory ordering and bounds, force parameter validation,
lease exclusivity and mode gating, motion refused beside a held lease and
admitted when the host arbitrates, each error policy, an unconditional
transition choosing the next step, transitions ignored when branching is not
declared, and a mission without transitions still being the flat sequence.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1dab1302-19c5-4a9b-a50c-d97d389713aa
The intent code used five APIs that do not exist on net472/net48, which the
library targets: a default interface implementation, ArgumentNullException.
ThrowIfNull, string.Create with an interpolated handler, ValueTask.FromResult
and CompletedTask, and the generic Enum.GetValues.

None of them were load-bearing. The null checks now match what the rest of
this library already does, the interpolation uses FormattableString.Invariant,
and the ValueTask results use the struct constructor.

IIntentExecutor.CanCancel loses its default implementation and becomes a
required member. That is not only a portability fix: whether a motion can be
safely abandoned part-way is a decision worth making deliberately rather than
inheriting, and an executor with no such motions writes one line to say so.

The one place a language-version conditional is warranted is the test that
enumerates ExecutionStateEnum, because enumerating rather than listing is the
point - a state added without a clause 6.3 pairing has to fail there.

149 tests pass on net48 and on net10.0, with no warnings on either.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1dab1302-19c5-4a9b-a50c-d97d389713aa
@marcschier

Copy link
Copy Markdown
Collaborator Author

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.

No problem

Should the model live in Opc.Ua.Robotics at all? 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. It sits here because this is where the robot-> facing API already is.

Keep in Opc.Ua.Robotics

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.

No, fully remove now

Serial execution is a deliberate simplification. Concurrent None/Soft intents would be an optimisation, not a correction, but it > is worth agreeing that reading.

Yes, but we need both

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR replaces the earlier opt-in “Robotics operation conventions” verb surface with a task-level Robot Intent API modeled as Part 10 program instances: clients submit an intent/mission, receive a handle (NodeId) back, and observe execution/progress/results asynchronously. It also wires Robot Intent into the Robotics package via source-generation from a NodeSet and updates client/server/test code accordingly.

Changes:

  • Add Robot Intent contracts, host options, and the IntentControllerHost execution engine (admission, queueing, cancellation, missions, real-time channel leasing, safety refusal).
  • Add extensive NUnit coverage for lifecycle rules, missions, safety gating, trajectories/force validation, and channel leasing.
  • Remove the previous non-normative operation convention builder/client API and update Robotics client accessors and builder interfaces.

Reviewed changes

Copilot reviewed 13 out of 14 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/Opc.Ua.Robotics.Tests/RoboticsOperationsConventionBuilderTests.cs Removes tests for the old non-normative convention methods API.
tests/Opc.Ua.Robotics.Tests/IntentScopeExtensionTests.cs New tests covering extended intent scope (safety, trajectories/force, channels, missions).
tests/Opc.Ua.Robotics.Tests/IntentControllerHostTests.cs New tests validating the intent execution lifecycle, queueing, cancellation, retry, and mission base/horizon rules.
src/Opc.Ua.Robotics/RoboticsOperationConventions.cs Removes the old convention request/result types and enums.
src/Opc.Ua.Robotics/Opc.Ua.Robotics.csproj Adds the Robot Intent NodeSet as an AdditionalFiles input for source generation.
src/Opc.Ua.Robotics/Intent/IntentContracts.cs Introduces executor/progress contracts and outcome structures for intent execution.
src/Opc.Ua.Robotics.Server/Intent/IntentControllerHostOptions.cs Adds capability/channel/safety/mission validation and configuration structures.
src/Opc.Ua.Robotics.Server/Intent/IntentControllerHost.cs Adds the Robot Intent host implementation (methods, pump, node materialization, leasing, missions).
src/Opc.Ua.Robotics.Server/Builders/RoboticsOperationsBuilders.cs Removes the old convention operations builder implementation.
src/Opc.Ua.Robotics.Server/Builders/MotionBuilders.cs Removes AddOperations(...) hook for the old convention API.
src/Opc.Ua.Robotics.Server/Builders/MotionBuilderInterfaces.cs Removes AddOperations(...) from the motion builder interface.
src/Opc.Ua.Robotics.Client/RoboticsClient.Accessors.cs Replaces convention operations accessor with an IntentController(...) client entry point.
src/Opc.Ua.Robotics.Client/Operations/RoboticsOperationsClient.cs Removes the old convention operations client implementation.
Suppressed comments (2)

src/Opc.Ua.Robotics.Server/Intent/IntentControllerHost.cs:99

  • The constructor API/docs still expose a removeNode parameter, but the corresponding field is unused (and should be removed to keep the build warning-free). After removing the field, the constructor signature/body should also drop the parameter and assignment to avoid compile errors and reduce misleading API surface.
        /// <param name="removeNode">Removes a node again, when the host can delete.</param>
        public IntentControllerHost(
            IntentControllerState controller,
            IIntentExecutor executor,
            Func<NodeState, CancellationToken, ValueTask> addNode,

src/Opc.Ua.Robotics.Server/Intent/IntentControllerHost.cs:598

  • ExceedsSafeSpeed reads m_safety without synchronization, which can race with UpdateSafetyState and produce inconsistent decisions. Since this method is called outside of lock (m_lock), it should snapshot safety state via the locked SafetyState accessor (or otherwise synchronize).
        private bool ExceedsSafeSpeed(IntentDataType intent)
        {
            if (!m_safety.SafeSpeedLimitActive || m_safety.SafeSpeedLimit <= 0)
            {
                return false;

private const uint StateSuspended = 3;
private const uint StateHalted = 4;

private readonly object m_lock = new();
Comment on lines +68 to +70
private readonly Func<NodeState, CancellationToken, ValueTask> m_addNode;
private readonly Func<NodeState, CancellationToken, ValueTask>? m_removeNode;
private readonly Dictionary<string, IntentEntry> m_intents = [];
Comment on lines +188 to +212
if (!m_safety.PermitsSubmission)
{
return IntentAdmission.Refused(IntentFailureEnum.NotPermittedInMode,
m_safety.SafetyControllerOk
? "A stop is asserted."
: "The safety controller reports a fault.");
}
if (intent == null)
{
return IntentAdmission.Refused(IntentFailureEnum.ParameterInvalid,
"No intent was supplied.");
}
if (intent is MotionIntentDataType && AnyChannelHeldLocked() &&
!m_options.ArbitratesWithRealTimeChannel)
{
return IntentAdmission.Refused(IntentFailureEnum.CapabilityNotSupported,
"A real-time channel lease is held and this Server does not "
+ "arbitrate between the two command sources.");
}
if (ExceedsSafeSpeed(intent))
{
return IntentAdmission.Refused(IntentFailureEnum.SafetyLimitExceeded,
FormattableString.Invariant(
$"The requested speed exceeds the enforced safe limit of {m_safety.SafeSpeedLimit} m/s."));
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants