diff --git a/src/Opc.Ua.Robotics.Client/Operations/RoboticsOperationsClient.cs b/src/Opc.Ua.Robotics.Client/Operations/RoboticsOperationsClient.cs deleted file mode 100644 index ab3ad7a436..0000000000 --- a/src/Opc.Ua.Robotics.Client/Operations/RoboticsOperationsClient.cs +++ /dev/null @@ -1,398 +0,0 @@ -/* ======================================================================== - * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. - * - * OPC Foundation MIT License 1.00 - * - * Permission is hereby granted, free of charge, to any person - * obtaining a copy of this software and associated documentation - * files (the "Software"), to deal in the Software without - * restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the - * Software is furnished to do so, subject to the following - * conditions: - * - * The above copyright notice and this permission notice shall be - * included in all copies or substantial portions of the Software. - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES - * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND - * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT - * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, - * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING - * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR - * OTHER DEALINGS IN THE SOFTWARE. - * - * The complete license agreement can be found here: - * http://opcfoundation.org/License/MIT/1.00/ - * ======================================================================*/ - -using System; -using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; -using Opc.Ua.Client; -using Opc.Ua.Robotics.Operations; - -namespace Opc.Ua.Robotics.Client.Operations -{ - /// - /// Invokes opt-in, explicitly non-normative Robotics operation convention methods. - /// - public sealed class RoboticsOperationsClient - { - /// - /// Creates a convention operation client for one operations object. - /// - public RoboticsOperationsClient(ISession session, NodeId operationsNodeId, ITelemetryContext telemetry) - { - Session = session ?? throw new ArgumentNullException(nameof(session)); - OperationsNodeId = operationsNodeId.IsNull - ? throw new ArgumentException("An operations NodeId is required.", nameof(operationsNodeId)) - : operationsNodeId; - Telemetry = telemetry ?? throw new ArgumentNullException(nameof(telemetry)); - } - - /// - /// Gets the connected session. - /// - public ISession Session { get; } - - /// - /// Gets the operations object NodeId. - /// - public NodeId OperationsNodeId { get; } - - /// - /// Gets the telemetry context. - /// - public ITelemetryContext Telemetry { get; } - - /// - /// Invokes MoveTo by BrowseName. - /// - public Task MoveToAsync( - MoveToRequest request, - CancellationToken cancellationToken = default) - { - return InvokeStandardAsync("MoveTo", ToArguments(request), cancellationToken); - } - - /// - /// Invokes MoveJ by BrowseName. - /// - public Task MoveJAsync( - JointMoveRequest request, - CancellationToken cancellationToken = default) - { - return InvokeStandardAsync("MoveJ", ToArguments(request), cancellationToken); - } - - /// - /// Invokes MoveL by BrowseName. - /// - public Task MoveLAsync( - LinearMoveRequest request, - CancellationToken cancellationToken = default) - { - return InvokeStandardAsync("MoveL", ToArguments(request), cancellationToken); - } - - /// - /// Invokes Grasp by BrowseName. - /// - public Task GraspAsync( - GraspRequest request, - CancellationToken cancellationToken = default) - { - return InvokeStandardAsync("Grasp", ToArguments(request), cancellationToken); - } - - /// - /// Invokes Release by BrowseName. - /// - public Task ReleaseAsync( - ReleaseRequest request, - CancellationToken cancellationToken = default) - { - return InvokeStandardAsync("Release", ToArguments(request), cancellationToken); - } - - /// - /// Invokes PickFrom by BrowseName. - /// - public Task PickFromAsync( - PickPlaceRequest request, - CancellationToken cancellationToken = default) - { - return InvokeStandardAsync("PickFrom", ToArguments(request), cancellationToken); - } - - /// - /// Invokes PlaceAt by BrowseName. - /// - public Task PlaceAtAsync( - PickPlaceRequest request, - CancellationToken cancellationToken = default) - { - return InvokeStandardAsync("PlaceAt", ToArguments(request), cancellationToken); - } - - /// - /// Invokes SwapTool by BrowseName. - /// - public Task SwapToolAsync( - ToolChangeRequest request, - CancellationToken cancellationToken = default) - { - return InvokeStandardAsync("SwapTool", ToArguments(request), cancellationToken); - } - - /// - /// Invokes SetOutput by BrowseName. - /// - public Task SetOutputAsync( - OutputRequest request, - CancellationToken cancellationToken = default) - { - return InvokeStandardAsync("SetOutput", ToArguments(request), cancellationToken); - } - - /// - /// Invokes CallProgram by BrowseName. - /// - public Task CallProgramAsync( - ProgramCallRequest request, - CancellationToken cancellationToken = default) - { - return InvokeStandardAsync("CallProgram", ToArguments(request), cancellationToken); - } - - /// - /// Invokes an application-specific operation by BrowseName. - /// - /// - /// The request CLR type. - /// - /// - /// The response CLR type. - /// - public async Task InvokeAsync( - string name, - TRequest request, - CancellationToken cancellationToken = default) - { - ArrayOf output = await CallAsync( - name, - [new Variant(ToGenericArguments(request))], - cancellationToken).ConfigureAwait(false); - - RoboticsOperationResult result = FromOutput(output); - if (typeof(TResponse) == typeof(RoboticsOperationResult)) - { - return Unsafe.As(ref result); - } - if (typeof(TResponse) == typeof(ArrayOf)) - { - ArrayOf values = result.Outputs ?? []; - return Unsafe.As, TResponse>(ref values); - } - if (typeof(TResponse) == typeof(Variant)) - { - ArrayOf values = result.Outputs ?? []; - Variant value = values.Count == 0 ? Variant.Null : values[0]; - return Unsafe.As(ref value); - } - throw new ServiceResultException(StatusCodes.BadTypeMismatch); - } - - private async Task InvokeStandardAsync( - string name, - ArrayOf inputArguments, - CancellationToken cancellationToken) - { - ArrayOf output = await CallAsync(name, inputArguments, cancellationToken) - .ConfigureAwait(false); - return FromOutput(output); - } - - private async Task> CallAsync( - string name, - ArrayOf inputArguments, - CancellationToken cancellationToken) - { - NodeId methodId = await ResolveMethodAsync(name, cancellationToken).ConfigureAwait(false); - Variant[] arguments = new Variant[inputArguments.Count]; - for (int ii = 0; ii < inputArguments.Count; ii++) - { - arguments[ii] = inputArguments[ii]; - } - return await Session.CallAsync( - OperationsNodeId, - methodId, - cancellationToken, - arguments).ConfigureAwait(false); - } - - private async Task ResolveMethodAsync(string name, CancellationToken cancellationToken) - { - (_, _, ArrayOf references) = await Session.BrowseAsync( - requestHeader: null, - view: null, - nodeToBrowse: OperationsNodeId, - maxResultsToReturn: 0, - browseDirection: BrowseDirection.Forward, - referenceTypeId: Opc.Ua.Types.ReferenceTypeIds.HasComponent, - includeSubtypes: true, - nodeClassMask: (uint)NodeClass.Method, - ct: cancellationToken).ConfigureAwait(false); - - for (int ii = 0; ii < references.Count; ii++) - { - ReferenceDescription reference = references[ii]; - if (string.Equals(reference.BrowseName.Name, name, StringComparison.Ordinal)) - { - return ExpandedNodeId.ToNodeId(reference.NodeId, Session.NamespaceUris); - } - } - throw ServiceResultException.Create( - StatusCodes.BadNodeIdUnknown, - "Operation method '{0}' was not found below '{1}'.", - name, - OperationsNodeId); - } - - private static RoboticsOperationResult FromOutput(ArrayOf output) - { - if (output.Count == 0 || !output[0].TryGetValue(out StatusCode statusCode)) - { - return new RoboticsOperationResult(new ServiceResult(StatusCodes.BadUnexpectedError)); - } - string? message = null; - if (output.Count > 1 && !output[1].IsNull && output[1].TryGetValue(out string text)) - { - message = text; - } - ArrayOf? values = null; - if (output.Count > 2 && !output[2].IsNull && output[2].TryGetValue(out ArrayOf outputs)) - { - values = outputs; - } - return new RoboticsOperationResult(new ServiceResult(statusCode), message, values); - } - - private static ArrayOf ToArguments(MoveToRequest request) - { - return [ - Structure(request.TargetFrame), - Optional(request.SpeedFraction), - Optional(request.BlendRadius), - OptionalStructure(request.BlendRadiusUnits) - ]; - } - - private static ArrayOf ToArguments(JointMoveRequest request) - { - return [ - new Variant(request.JointTargets), - Structure(request.JointUnits), - Optional(request.SpeedFraction) - ]; - } - - private static ArrayOf ToArguments(LinearMoveRequest request) - { - return [ - Structure(request.TargetFrame), - new Variant(request.LinearSpeed), - Structure(request.LinearSpeedUnits), - Optional(request.Acceleration), - OptionalStructure(request.AccelerationUnits) - ]; - } - - private static ArrayOf ToArguments(GraspRequest request) - { - return [ - Optional(request.ForceNewtons), - Optional(request.Width), - OptionalStructure(request.WidthUnits), - new Variant((int)request.Approach) - ]; - } - - private static ArrayOf ToArguments(ReleaseRequest request) - { - return [ - new Variant((int)request.Mode), - OptionalStructure(request.TargetFrame) - ]; - } - - private static ArrayOf ToArguments(PickPlaceRequest request) - { - return [ - new Variant(request.StationOrLocationIdentifier), - new Variant(request.ObjectClass), - request.Attributes.Count == 0 ? Variant.Null : new Variant(ToExtensionObjects(request.Attributes)), - Optional(request.ForceNewtons) - ]; - } - - private static ArrayOf ToArguments(ToolChangeRequest request) - { - return [ - new Variant(request.ToolIdentifier), - request.DockStation == null ? Variant.Null : new Variant(request.DockStation) - ]; - } - - private static ArrayOf ToArguments(OutputRequest request) - { - return [new Variant(request.OutputLineIdentifier), request.Value]; - } - - private static ArrayOf ToArguments(ProgramCallRequest request) - { - return [new Variant(request.ProgramName), new Variant(request.Arguments)]; - } - - private static ArrayOf ToGenericArguments(TRequest request) - { - if (request is ArrayOf arguments) - { - return arguments; - } - if (request is Variant variant) - { - return [variant]; - } - throw new ServiceResultException(StatusCodes.BadTypeMismatch); - } - - private static Variant Structure(IEncodeable value) - { - return new Variant(new ExtensionObject(value)); - } - - private static Variant Optional(double? value) - { - return value.HasValue ? new Variant(value.Value) : Variant.Null; - } - - private static Variant OptionalStructure(IEncodeable? value) - { - return value == null ? Variant.Null : Structure(value); - } - - private static ArrayOf ToExtensionObjects(ArrayOf values) - { - var result = new ExtensionObject[values.Count]; - for (int ii = 0; ii < values.Count; ii++) - { - result[ii] = new ExtensionObject(values[ii]); - } - return result.ToArrayOf(); - } - } -} diff --git a/src/Opc.Ua.Robotics.Client/RoboticsClient.Accessors.cs b/src/Opc.Ua.Robotics.Client/RoboticsClient.Accessors.cs index 0c35354abd..acdaeaa47e 100644 --- a/src/Opc.Ua.Robotics.Client/RoboticsClient.Accessors.cs +++ b/src/Opc.Ua.Robotics.Client/RoboticsClient.Accessors.cs @@ -33,7 +33,7 @@ using Opc.Ua.Client; using Opc.Ua.Client.FileSystem; using Opc.Ua.Client.Subscriptions.Streaming; -using Opc.Ua.Robotics.Client.Operations; +using Opc.Ua.RobotIntent; namespace Opc.Ua.Robotics.Client { @@ -75,23 +75,18 @@ public async Task ProgramsAsync( } /// - /// Opens the non-normative operation convention client for a motion device. + /// Opens the OPC UA - Robot Intent surface for a robot. /// - /// - /// The motion device carrying the application-owned operations object. - /// - /// - /// Cancels the operation. - /// - public async Task OperationsAsync( - NodeId motionDevice, - CancellationToken cancellationToken = default) + /// + /// This is where task-level commanding lives. OPC 40010-1 describes the robot + /// and defines no motion verbs; the intent controller supplies them, and the + /// two are joined by a HasIntentController reference rather than by either + /// model depending on the other. + /// + /// The IntentController object. + public IntentControllerTypeClient IntentController(NodeId intentController) { - NodeId operationsId = await ResolveChildAsync( - motionDevice, - "Operations", - cancellationToken).ConfigureAwait(false); - return new RoboticsOperationsClient(Session, operationsId, Telemetry); + return new IntentControllerTypeClient(Session, intentController, Telemetry); } internal static IStreamingSubscription GetDefaultStreaming(ISession session) diff --git a/src/Opc.Ua.Robotics.Server/Builders/MotionBuilderInterfaces.cs b/src/Opc.Ua.Robotics.Server/Builders/MotionBuilderInterfaces.cs index 3b7cbd2520..0a6bc745ba 100644 --- a/src/Opc.Ua.Robotics.Server/Builders/MotionBuilderInterfaces.cs +++ b/src/Opc.Ua.Robotics.Server/Builders/MotionBuilderInterfaces.cs @@ -131,14 +131,6 @@ IMotionDeviceBuilder WithSpeedOverride( StatusCode statusCode = default, DateTimeUtc timestamp = default); - /// - /// Adds an application-owned, explicitly non-normative operations convention object. - /// - IRoboticsOperationsBuilder AddOperations( - string browseName, - ushort applicationNamespaceIndex, - Action configure); - /// /// Binds asynchronous reads for SpeedOverride. /// diff --git a/src/Opc.Ua.Robotics.Server/Builders/MotionBuilders.cs b/src/Opc.Ua.Robotics.Server/Builders/MotionBuilders.cs index 98337df480..ae8d7913be 100644 --- a/src/Opc.Ua.Robotics.Server/Builders/MotionBuilders.cs +++ b/src/Opc.Ua.Robotics.Server/Builders/MotionBuilders.cs @@ -235,25 +235,6 @@ public IMotionDeviceBuilder WithSpeedOverride( return this; } - public IRoboticsOperationsBuilder AddOperations( - string browseName, - ushort applicationNamespaceIndex, - Action configure) - { - if (configure == null) - { - throw new ArgumentNullException(nameof(configure)); - } - Scope.EnsureMutable(); - var builder = new RoboticsOperationsBuilder( - Scope, - State, - browseName, - applicationNamespaceIndex); - configure(builder); - return builder; - } - public IMotionDeviceBuilder BindSpeedOverrideRead( Func> read) { diff --git a/src/Opc.Ua.Robotics.Server/Builders/RoboticsOperationsBuilders.cs b/src/Opc.Ua.Robotics.Server/Builders/RoboticsOperationsBuilders.cs deleted file mode 100644 index db7e89c02a..0000000000 --- a/src/Opc.Ua.Robotics.Server/Builders/RoboticsOperationsBuilders.cs +++ /dev/null @@ -1,813 +0,0 @@ -/* ======================================================================== - * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. - * - * OPC Foundation MIT License 1.00 - * - * Permission is hereby granted, free of charge, to any person - * obtaining a copy of this software and associated documentation - * files (the "Software"), to deal in the Software without - * restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the - * Software is furnished to do so, subject to the following - * conditions: - * - * The above copyright notice and this permission notice shall be - * included in all copies or substantial portions of the Software. - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES - * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND - * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT - * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, - * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING - * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR - * OTHER DEALINGS IN THE SOFTWARE. - * - * The complete license agreement can be found here: - * http://opcfoundation.org/License/MIT/1.00/ - * ======================================================================*/ - -using System; -using System.Collections.Generic; -using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; -using Opc.Ua.Robotics.Operations; -using UaBrowseNames = global::Opc.Ua.BrowseNames; -using UaDataTypeIds = global::Opc.Ua.DataTypeIds; -using UaObjectTypeIds = global::Opc.Ua.ObjectTypeIds; -using UaReferenceTypeIds = global::Opc.Ua.ReferenceTypeIds; -using UaVariableTypeIds = global::Opc.Ua.VariableTypeIds; - -namespace Opc.Ua.Robotics.Server.Builders -{ - /// - /// Builds an opt-in, non-normative Robotics operation convention object. - /// - public interface IRoboticsOperationsBuilder - { - /// - /// Gets the operation object state after it is materialized. - /// - BaseObjectState? State { get; } - - /// - /// Supplies the dynamic UserExecutable decision for every convention method. - /// - IRoboticsOperationsBuilder WithUserExecutable( - Func isUserExecutable); - - /// - /// Registers the non-normative MoveTo convention handler. - /// - IRoboticsOperationsBuilder OnMoveTo( - Func> handler); - - /// - /// Registers the non-normative MoveJ convention handler. - /// - IRoboticsOperationsBuilder OnMoveJ( - Func> handler); - - /// - /// Registers the non-normative MoveL convention handler. - /// - IRoboticsOperationsBuilder OnMoveL( - Func> handler); - - /// - /// Registers the non-normative Grasp convention handler. - /// - IRoboticsOperationsBuilder OnGrasp( - Func> handler); - - /// - /// Registers the non-normative Release convention handler. - /// - IRoboticsOperationsBuilder OnRelease( - Func> handler); - - /// - /// Registers the non-normative PickFrom convention handler. - /// - IRoboticsOperationsBuilder OnPickFrom( - Func> handler); - - /// - /// Registers the non-normative PlaceAt convention handler. - /// - IRoboticsOperationsBuilder OnPlaceAt( - Func> handler); - - /// - /// Registers the non-normative SwapTool convention handler. - /// - IRoboticsOperationsBuilder OnSwapTool( - Func> handler); - - /// - /// Registers the non-normative SetOutput convention handler. - /// - IRoboticsOperationsBuilder OnSetOutput( - Func> handler); - - /// - /// Registers the non-normative CallProgram fallback handler. - /// - /// - /// Prefer the standard OPC UA Programs plus OPC 40010 TaskControl route for program loading and - /// execution. Use this operation only as an application-specific fallback when that standard model - /// cannot represent the target program invocation. - /// - IRoboticsOperationsBuilder OnCallProgram( - Func> handler); - - /// - /// Adds an application-specific operation outside the industrial convention subset. - /// - /// - /// The request CLR type. - /// - /// - /// The response CLR type. - /// - IRoboticsOperationsBuilder AddOperation( - string name, - Func> handler); - } - - internal sealed class RoboticsOperationsBuilder : IRoboticsOperationsBuilder - { - public RoboticsOperationsBuilder( - RoboticsBuildScope scope, - MotionDeviceState owner, - string browseName, - ushort applicationNamespaceIndex) - { - m_scope = scope ?? throw new ArgumentNullException(nameof(scope)); - m_owner = owner ?? throw new ArgumentNullException(nameof(owner)); - m_browseName = string.IsNullOrWhiteSpace(browseName) - ? throw new ArgumentException("A non-empty browse name is required.", nameof(browseName)) - : browseName; - m_applicationNamespaceIndex = applicationNamespaceIndex; - ValidateApplicationNamespace(scope, applicationNamespaceIndex); - scope.PostRegistrationActions.Add(MaterializeAsync); - } - - public BaseObjectState? State { get; private set; } - - public IRoboticsOperationsBuilder WithUserExecutable( - Func isUserExecutable) - { - m_scope.EnsureMutable(); - m_isUserExecutable = isUserExecutable ?? throw new ArgumentNullException(nameof(isUserExecutable)); - return this; - } - - public IRoboticsOperationsBuilder OnMoveTo( - Func> handler) - { - return AddStandardOperation("MoveTo", handler, CreateMoveTo, MoveToArguments()); - } - - public IRoboticsOperationsBuilder OnMoveJ( - Func> handler) - { - return AddStandardOperation("MoveJ", handler, CreateMoveJ, MoveJArguments()); - } - - public IRoboticsOperationsBuilder OnMoveL( - Func> handler) - { - return AddStandardOperation("MoveL", handler, CreateMoveL, MoveLArguments()); - } - - public IRoboticsOperationsBuilder OnGrasp( - Func> handler) - { - return AddStandardOperation("Grasp", handler, CreateGrasp, GraspArguments()); - } - - public IRoboticsOperationsBuilder OnRelease( - Func> handler) - { - return AddStandardOperation("Release", handler, CreateRelease, ReleaseArguments()); - } - - public IRoboticsOperationsBuilder OnPickFrom( - Func> handler) - { - return AddStandardOperation("PickFrom", handler, CreatePickPlace, PickPlaceArguments()); - } - - public IRoboticsOperationsBuilder OnPlaceAt( - Func> handler) - { - return AddStandardOperation("PlaceAt", handler, CreatePickPlace, PickPlaceArguments()); - } - - public IRoboticsOperationsBuilder OnSwapTool( - Func> handler) - { - return AddStandardOperation("SwapTool", handler, CreateToolChange, ToolChangeArguments()); - } - - public IRoboticsOperationsBuilder OnSetOutput( - Func> handler) - { - return AddStandardOperation("SetOutput", handler, CreateOutput, OutputArguments()); - } - - public IRoboticsOperationsBuilder OnCallProgram( - Func> handler) - { - return AddStandardOperation("CallProgram", handler, CreateProgramCall, ProgramCallArguments()); - } - - public IRoboticsOperationsBuilder AddOperation( - string name, - Func> handler) - { - if (string.IsNullOrWhiteSpace(name)) - { - throw new ArgumentException("A non-empty operation name is required.", nameof(name)); - } - if (handler == null) - { - throw new ArgumentNullException(nameof(handler)); - } - m_scope.EnsureMutable(); - AddRegistration(new GenericOperationRegistration(name, handler)); - return this; - } - - private RoboticsOperationsBuilder AddStandardOperation( - string name, - Func> handler, - Func, TRequest> createRequest, - ArrayOf inputArguments) - { - if (handler == null) - { - throw new ArgumentNullException(nameof(handler)); - } - m_scope.EnsureMutable(); - AddRegistration(new StandardOperationRegistration( - name, - handler, - createRequest, - inputArguments)); - return this; - } - - private void AddRegistration(OperationRegistration registration) - { - for (int ii = 0; ii < m_registrations.Count; ii++) - { - if (string.Equals(m_registrations[ii].Name, registration.Name, StringComparison.Ordinal)) - { - throw ServiceResultException.Create( - StatusCodes.BadBrowseNameDuplicated, - "Operation '{0}' is already registered.", - registration.Name); - } - } - m_registrations.Add(registration); - } - - private async ValueTask MaterializeAsync(CancellationToken cancellationToken) - { - if (m_registrations.Count == 0) - { - return; - } - - BaseObjectState operations = CreateOperationsObject(); - State = operations; - m_owner.AddChild(operations); - for (int ii = 0; ii < m_registrations.Count; ii++) - { - CreateMethod(operations, m_registrations[ii]); - } - await m_scope.BuildContext.Manager - .AddPredefinedNodeAsync(operations, cancellationToken) - .ConfigureAwait(false); - } - - private BaseObjectState CreateOperationsObject() - { - var browseName = new QualifiedName(m_browseName, m_applicationNamespaceIndex); - var state = new BaseObjectState(m_owner) - { - BrowseName = browseName, - DisplayName = new LocalizedText(m_browseName), - Description = new LocalizedText( - "Opt-in, explicitly non-normative industrial operation conventions. " + - "This object is application-owned and is not part of OPC 40010."), - NodeId = ChildNodeId(m_owner.NodeId, m_browseName), - ReferenceTypeId = UaReferenceTypeIds.HasComponent, - SymbolicName = m_browseName, - TypeDefinitionId = UaObjectTypeIds.BaseObjectType - }; - state.AddReference(UaReferenceTypeIds.HasComponent, true, m_owner.NodeId); - return state; - } - - private void CreateMethod(BaseObjectState parent, OperationRegistration registration) - { - var method = new MethodState(parent) - { - BrowseName = new QualifiedName(registration.Name, m_applicationNamespaceIndex), - DisplayName = new LocalizedText(registration.Name), - Description = new LocalizedText( - "Opt-in, explicitly non-normative Robotics operation convention method. " + - "This is not an OPC 40010 method."), - Executable = true, - NodeId = ChildNodeId(parent.NodeId, registration.Name), - ReferenceTypeId = UaReferenceTypeIds.HasComponent, - SymbolicName = registration.Name, - UserExecutable = true - }; - method.AddReference(UaReferenceTypeIds.HasComponent, true, parent.NodeId); - method.OnReadUserExecutable = OnReadUserExecutable; - method.OnCallMethod2Async = registration.InvokeAsync; - parent.AddChild(method); - AddArgumentProperty(method, UaBrowseNames.InputArguments, "InputArguments", registration.InputArguments); - AddArgumentProperty(method, UaBrowseNames.OutputArguments, "OutputArguments", registration.OutputArguments); - } - - private ServiceResult OnReadUserExecutable(ISystemContext context, NodeState node, ref bool value) - { - if (node is MethodState method && m_isUserExecutable != null) - { - value = m_isUserExecutable(context, method); - } - return ServiceResult.Good; - } - - private void AddArgumentProperty( - MethodState method, - string browseName, - string suffix, - ArrayOf arguments) - { - var property = PropertyState>.With>(method); - property.BrowseName = new QualifiedName(browseName); - property.DataType = UaDataTypeIds.Argument; - property.DisplayName = new LocalizedText(browseName); - property.NodeId = ChildNodeId(method.NodeId, suffix); - property.ReferenceTypeId = UaReferenceTypeIds.HasProperty; - property.TypeDefinitionId = UaVariableTypeIds.PropertyType; - property.Value = arguments; - property.ValueRank = ValueRanks.OneDimension; - if (browseName == UaBrowseNames.InputArguments) - { - method.InputArguments = property; - } - else - { - method.OutputArguments = property; - } - method.AddChild(property); - } - - private NodeId ChildNodeId(NodeId parentNodeId, string name) - { - return new NodeId( - $"{parentNodeId.IdentifierAsString}_{name}", - m_applicationNamespaceIndex); - } - - private static void ValidateApplicationNamespace( - RoboticsBuildScope scope, - ushort applicationNamespaceIndex) - { - string? namespaceUri = scope.Context.NamespaceUris.GetString(applicationNamespaceIndex); - if (namespaceUri == null || - applicationNamespaceIndex == 0 || - namespaceUri == Opc.Ua.Di.Namespaces.OpcUaDi || - namespaceUri == Opc.Ua.IA.Namespaces.IA || - namespaceUri == Opc.Ua.Robotics.Namespaces.Robotics) - { - throw ServiceResultException.Create( - StatusCodes.BadConfigurationError, - "Robotics operation conventions must be created in an application-owned namespace."); - } - } - - private static Argument Argument(string name, NodeId dataType, int valueRank = ValueRanks.Scalar) - { - return new Argument - { - Name = name, - DataType = dataType, - ValueRank = valueRank - }; - } - - private static ArrayOf ResultArguments() - { - return [ - Argument("StatusCode", UaDataTypeIds.StatusCode), - Argument("Message", UaDataTypeIds.String), - Argument("Outputs", UaDataTypeIds.BaseDataType, ValueRanks.OneDimension) - ]; - } - - private static ArrayOf MoveToArguments() - { - return [ - Argument("TargetFrame", UaDataTypeIds.Structure), - Argument("SpeedFraction", UaDataTypeIds.Double), - Argument("BlendRadius", UaDataTypeIds.Double), - Argument("BlendRadiusUnits", UaDataTypeIds.EUInformation) - ]; - } - - private static ArrayOf MoveJArguments() - { - return [ - Argument("JointTargets", UaDataTypeIds.Double, ValueRanks.OneDimension), - Argument("JointUnits", UaDataTypeIds.EUInformation), - Argument("SpeedFraction", UaDataTypeIds.Double) - ]; - } - - private static ArrayOf MoveLArguments() - { - return [ - Argument("TargetFrame", UaDataTypeIds.Structure), - Argument("LinearSpeed", UaDataTypeIds.Double), - Argument("LinearSpeedUnits", UaDataTypeIds.EUInformation), - Argument("Acceleration", UaDataTypeIds.Double), - Argument("AccelerationUnits", UaDataTypeIds.EUInformation) - ]; - } - - private static ArrayOf GraspArguments() - { - return [ - Argument("ForceNewtons", UaDataTypeIds.Double), - Argument("Width", UaDataTypeIds.Double), - Argument("WidthUnits", UaDataTypeIds.EUInformation), - Argument("Approach", UaDataTypeIds.Int32) - ]; - } - - private static ArrayOf ReleaseArguments() - { - return [ - Argument("Mode", UaDataTypeIds.Int32), - Argument("TargetFrame", UaDataTypeIds.Structure) - ]; - } - - private static ArrayOf PickPlaceArguments() - { - return [ - Argument("StationOrLocationIdentifier", UaDataTypeIds.String), - Argument("ObjectClass", UaDataTypeIds.String), - Argument("Attributes", UaDataTypeIds.Structure, ValueRanks.OneDimension), - Argument("ForceNewtons", UaDataTypeIds.Double) - ]; - } - - private static ArrayOf ToolChangeArguments() - { - return [ - Argument("ToolIdentifier", UaDataTypeIds.String), - Argument("DockStation", UaDataTypeIds.String) - ]; - } - - private static ArrayOf OutputArguments() - { - return [ - Argument("OutputLineIdentifier", UaDataTypeIds.String), - Argument("Value", UaDataTypeIds.BaseDataType) - ]; - } - - private static ArrayOf ProgramCallArguments() - { - return [ - Argument("ProgramName", UaDataTypeIds.String), - Argument("Arguments", UaDataTypeIds.BaseDataType, ValueRanks.OneDimension) - ]; - } - - private static MoveToRequest CreateMoveTo(ArrayOf input) - { - return new MoveToRequest( - RequiredStructure(input, 0), - OptionalDouble(input, 1), - OptionalDouble(input, 2), - OptionalStructure(input, 3)); - } - - private static JointMoveRequest CreateMoveJ(ArrayOf input) - { - return new JointMoveRequest( - RequiredDoubleArray(input, 0), - RequiredStructure(input, 1), - OptionalDouble(input, 2)); - } - - private static LinearMoveRequest CreateMoveL(ArrayOf input) - { - return new LinearMoveRequest( - RequiredStructure(input, 0), - RequiredDouble(input, 1), - RequiredStructure(input, 2), - OptionalDouble(input, 3), - OptionalStructure(input, 4)); - } - - private static GraspRequest CreateGrasp(ArrayOf input) - { - return new GraspRequest( - OptionalDouble(input, 0), - OptionalDouble(input, 1), - OptionalStructure(input, 2), - (RoboticsApproach)RequiredInt32(input, 3)); - } - - private static ReleaseRequest CreateRelease(ArrayOf input) - { - return new ReleaseRequest( - (RoboticsReleaseMode)RequiredInt32(input, 0), - OptionalStructure(input, 1)); - } - - private static PickPlaceRequest CreatePickPlace(ArrayOf input) - { - return new PickPlaceRequest( - RequiredString(input, 0), - RequiredString(input, 1), - OptionalKeyValueArray(input, 2) ?? [], - OptionalDouble(input, 3)); - } - - private static ToolChangeRequest CreateToolChange(ArrayOf input) - { - return new ToolChangeRequest(RequiredString(input, 0), OptionalString(input, 1)); - } - - private static OutputRequest CreateOutput(ArrayOf input) - { - return new OutputRequest(RequiredString(input, 0), input[1]); - } - - private static ProgramCallRequest CreateProgramCall(ArrayOf input) - { - return new ProgramCallRequest(RequiredString(input, 0), OptionalVariantArray(input, 1) ?? []); - } - - private static T RequiredStructure(ArrayOf input, int index) - where T : class, IEncodeable - { -#pragma warning disable CS8600 // TODO: update when Variant.TryGetStructure carries class nullability. - if (input[index].TryGetStructure(out T value)) -#pragma warning restore CS8600 - { - return value!; - } - throw new ServiceResultException(StatusCodes.BadTypeMismatch); - } - - private static T? OptionalStructure(ArrayOf input, int index) - where T : class, IEncodeable - { - if (input[index].IsNull) - { - return null; - } - return RequiredStructure(input, index); - } - - private static ArrayOf RequiredDoubleArray(ArrayOf input, int index) - { - if (input[index].TryGetValue(out ArrayOf value)) - { - return value; - } - throw new ServiceResultException(StatusCodes.BadTypeMismatch); - } - - private static ArrayOf? OptionalKeyValueArray(ArrayOf input, int index) - { - if (input[index].IsNull) - { - return null; - } - if (input[index].TryGetValue(out ArrayOf value, null)) - { - return value; - } - throw new ServiceResultException(StatusCodes.BadTypeMismatch); - } - - private static ArrayOf? OptionalVariantArray(ArrayOf input, int index) - { - if (input[index].IsNull) - { - return null; - } - if (input[index].TryGetValue(out ArrayOf value)) - { - return value; - } - throw new ServiceResultException(StatusCodes.BadTypeMismatch); - } - - private static ArrayOf RequiredVariantArray(ArrayOf input, int index) - { - if (input[index].TryGetValue(out ArrayOf value)) - { - return value; - } - throw new ServiceResultException(StatusCodes.BadTypeMismatch); - } - - private static double RequiredDouble(ArrayOf input, int index) - { - if (input[index].TryGetValue(out double value)) - { - return value; - } - throw new ServiceResultException(StatusCodes.BadTypeMismatch); - } - - private static double? OptionalDouble(ArrayOf input, int index) - { - if (input[index].IsNull) - { - return null; - } - return RequiredDouble(input, index); - } - - private static int RequiredInt32(ArrayOf input, int index) - { - if (input[index].TryGetValue(out int value)) - { - return value; - } - throw new ServiceResultException(StatusCodes.BadTypeMismatch); - } - - private static string RequiredString(ArrayOf input, int index) - { - if (input[index].TryGetValue(out string value)) - { - return value; - } - throw new ServiceResultException(StatusCodes.BadTypeMismatch); - } - - private static string? OptionalString(ArrayOf input, int index) - { - if (input[index].IsNull) - { - return null; - } - return RequiredString(input, index); - } - - private readonly ushort m_applicationNamespaceIndex; - private readonly string m_browseName; - private readonly MotionDeviceState m_owner; - private readonly List m_registrations = []; - private readonly RoboticsBuildScope m_scope; - private Func? m_isUserExecutable; - - private abstract class OperationRegistration - { - protected OperationRegistration(string name, ArrayOf inputArguments) - { - Name = name; - InputArguments = inputArguments; - } - - public string Name { get; } - - public ArrayOf InputArguments { get; } - - public ArrayOf OutputArguments => ResultArguments(); - - public abstract ValueTask InvokeAsync( - ISystemContext context, - MethodState method, - NodeId objectId, - ArrayOf inputArguments, - List outputArguments, - CancellationToken cancellationToken = default); - } - - private sealed class StandardOperationRegistration : OperationRegistration - { - public StandardOperationRegistration( - string name, - Func> handler, - Func, TRequest> createRequest, - ArrayOf inputArguments) - : base(name, inputArguments) - { - m_handler = handler; - m_createRequest = createRequest; - } - - public override async ValueTask InvokeAsync( - ISystemContext context, - MethodState method, - NodeId objectId, - ArrayOf inputArguments, - List outputArguments, - CancellationToken cancellationToken = default) - { - TRequest request = m_createRequest(inputArguments); - RoboticsOperationResult result = await m_handler(request, cancellationToken) - .ConfigureAwait(false); - AddResultOutputs(result, outputArguments); - return result.ServiceResult; - } - - private readonly Func, TRequest> m_createRequest; - private readonly Func> m_handler; - } - - private sealed class GenericOperationRegistration : OperationRegistration - { - public GenericOperationRegistration( - string name, - Func> handler) - : base(name, [Argument("Arguments", UaDataTypeIds.BaseDataType, ValueRanks.OneDimension)]) - { - m_handler = handler; - } - - public override async ValueTask InvokeAsync( - ISystemContext context, - MethodState method, - NodeId objectId, - ArrayOf inputArguments, - List outputArguments, - CancellationToken cancellationToken = default) - { - TRequest request = CreateGenericRequest(inputArguments); - TResponse response = await m_handler(request, cancellationToken).ConfigureAwait(false); - if (response is RoboticsOperationResult operationResult) - { - AddResultOutputs(operationResult, outputArguments); - return operationResult.ServiceResult; - } - if (response is ArrayOf variants) - { - var result = new RoboticsOperationResult(ServiceResult.Good, Outputs: variants); - AddResultOutputs(result, outputArguments); - return ServiceResult.Good; - } - outputArguments[0] = new Variant(StatusCodes.Good); - outputArguments[1] = Variant.Null; - outputArguments[2] = Variant.Null; - return ServiceResult.Good; - } - - private static TRequest CreateGenericRequest(ArrayOf inputArguments) - { - if (typeof(TRequest) == typeof(ArrayOf)) - { - ArrayOf arguments = RequiredVariantArray(inputArguments, 0); - return Unsafe.As, TRequest>(ref arguments); - } - if (typeof(TRequest) == typeof(Variant)) - { - ArrayOf arguments = RequiredVariantArray(inputArguments, 0); - Variant value = arguments.Count == 0 ? Variant.Null : arguments[0]; - return Unsafe.As(ref value); - } - throw new ServiceResultException(StatusCodes.BadTypeMismatch); - } - - private readonly Func> m_handler; - } - - private static void AddResultOutputs( - RoboticsOperationResult result, - List outputArguments) - { - outputArguments[0] = new Variant(result.ServiceResult.StatusCode); - outputArguments[1] = result.Message == null ? Variant.Null : new Variant(result.Message); - ArrayOf? outputs = result.Outputs; - if (outputs == null) - { - outputArguments[2] = Variant.Null; - } - else - { - ArrayOf values = outputs.Value; - outputArguments[2] = new Variant(values); - } - } - } -} diff --git a/src/Opc.Ua.Robotics.Server/Intent/IntentControllerHost.cs b/src/Opc.Ua.Robotics.Server/Intent/IntentControllerHost.cs new file mode 100644 index 0000000000..c26ab6bf3b --- /dev/null +++ b/src/Opc.Ua.Robotics.Server/Intent/IntentControllerHost.cs @@ -0,0 +1,1764 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.RobotIntent.Server +{ + /// + /// The execution engine behind one . + /// + /// + /// + /// This is the half of OPC UA - Robot Intent that the .NET stack previously had no + /// answer for. An OPC UA Call cannot stay open for the length of a real motion - + /// OPC 10000-4 discards a method result when the Session ends "independent of the + /// task actually performed at the Server" - so submission returns a handle and the + /// work is tracked on a Part 10 program instance the client watches. + /// + /// + /// Intents execute SERIALLY here. That 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, so a serial host + /// is conformant. Parallelism would be an optimisation, not a correction. + /// + /// + public sealed class IntentControllerHost : IDisposable + { + private const uint StateReady = 1; + private const uint StateRunning = 2; + private const uint StateSuspended = 3; + private const uint StateHalted = 4; + + private readonly object m_lock = new(); + private readonly IntentControllerState m_controller; + private readonly IIntentExecutor m_executor; + private readonly IntentControllerHostOptions m_options; + private readonly Func m_addNode; + private readonly Func? m_removeNode; + private readonly Dictionary m_intents = []; + private readonly Dictionary m_missions = []; + private readonly LinkedList m_queue = new(); + private readonly Dictionary m_capabilities = []; + private NamespaceTable m_namespaceUris = new(); + private readonly Dictionary m_channels = []; + private SafetyStatus m_safety = SafetyStatus.Nominal; + private FolderState? m_intentsFolder; + private FolderState? m_missionsFolder; + private readonly SemaphoreSlim m_pump = new(0); + private readonly CancellationTokenSource m_shutdown = new(); + + private IntentEntry? m_current; + private Task? m_pumpTask; + private long m_nextId; + private bool m_paused; + private bool m_disposed; + + /// + /// Creates a host over an already-materialised controller node. + /// + /// The controller node this host drives. + /// The application code that moves the robot. + /// Adds a per-invocation node to the address space. + /// Host options; defaults are used when null. + /// Removes a node again, when the host can delete. + public IntentControllerHost( + IntentControllerState controller, + IIntentExecutor executor, + Func addNode, + IntentControllerHostOptions? options = null, + Func? removeNode = null) + { + m_controller = controller ?? throw new ArgumentNullException(nameof(controller)); + m_executor = executor ?? throw new ArgumentNullException(nameof(executor)); + m_addNode = addNode ?? throw new ArgumentNullException(nameof(addNode)); + m_options = options ?? new IntentControllerHostOptions(); + m_removeNode = removeNode; + } + + /// + /// The controller node this host drives. + /// + public IntentControllerState Controller => m_controller; + + /// + /// The Session that currently holds command authority, or null. + /// + public NodeId? ControlOwner { get; private set; } + + /// + /// Starts the execution pump and wires the controller's Methods. + /// + public void Start(ISystemContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + m_namespaceUris = context.NamespaceUris; + m_intentsFolder = EnsureFolder(context, m_controller.Intents, BrowseNames.Intents); + if (m_options.MissionsSupported) + { + m_missionsFolder = EnsureFolder(context, m_controller.Missions, BrowseNames.Missions); + } + ResolveCapabilities(context); + if (m_options.RealTimeChannelsSupported) + { + CreateChannels(context); + } + PublishSafetyLocked(context); + WireMethods(context); + PublishControllerState(context); + m_pumpTask = Task.Run(() => PumpAsync(context, m_shutdown.Token)); + } + + // ---------------------------------------------------------------- admission + + /// + /// Admits one intent, per OPC UA - Robot Intent clause 6.2. + /// + /// + /// The order of the checks is normative and is preserved here, because a + /// caller that lacks authority must be told that rather than being told its + /// parameters are wrong. A refusal creates no operation instance and moves + /// nothing. + /// + public IntentAdmission SubmitIntent( + ISystemContext context, + NodeId? sessionId, + IntentDataType? intent, + string missionId = "") + { + return SubmitCore(context, sessionId, intent, missionId, forceNewId: false); + } + + private IntentAdmission SubmitCore( + ISystemContext context, + NodeId? sessionId, + IntentDataType? intent, + string missionId, + bool forceNewId) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (!HoldsAuthority(sessionId)) + { + return IntentAdmission.Refused(IntentFailureEnum.ControlNotOwned, + "The calling Session does not hold command authority."); + } + if (!IsSubmissionPermittedInMode()) + { + return IntentAdmission.Refused(IntentFailureEnum.NotPermittedInMode, + "Intents are accepted only in Automatic or AutomaticExternal mode."); + } + 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.")); + } + + IntentCapabilityDataType? capability = FindCapability(intent); + if (capability == null) + { + return IntentAdmission.Refused(IntentFailureEnum.CapabilityNotSupported, + $"This Server does not accept {intent.GetType().Name}."); + } + if (!Permits(capability.SupportedBufferModes, intent.BufferMode)) + { + return IntentAdmission.Refused(IntentFailureEnum.CapabilityNotSupported, + $"BufferMode {intent.BufferMode} is not accepted for this intent type."); + } + if (!Permits(capability.SupportedBlockingModes, intent.BlockingMode)) + { + return IntentAdmission.Refused(IntentFailureEnum.CapabilityNotSupported, + $"BlockingMode {intent.BlockingMode} is not accepted for this intent type."); + } + + Check validation = IntentValidation.Validate(intent, m_options); + if (!validation.Ok) + { + return IntentAdmission.Refused(IntentFailureEnum.ParameterInvalid, + validation.Message ?? "The intent is not valid."); + } + + lock (m_lock) + { + string id = forceNewId ? string.Empty : intent.IntentId ?? string.Empty; + if (string.IsNullOrEmpty(id)) + { + id = FormattableString.Invariant( + $"intent-{Interlocked.Increment(ref m_nextId)}"); + } + else if (m_intents.TryGetValue(id, out IntentEntry? existing) && + !IntentOutcome.IsTerminal(existing.State)) + { + return IntentAdmission.Refused(IntentFailureEnum.ParameterInvalid, + $"IntentId '{id}' is already outstanding."); + } + + if (intent.BufferMode != BufferModeEnum.Aborting && + m_queue.Count >= m_options.MaxQueueDepth) + { + return IntentAdmission.Refused(IntentFailureEnum.QueueFull, + "The queue is at MaxQueueDepth."); + } + + var entry = new IntentEntry(id, intent, missionId); + m_intents[id] = entry; + CreateOperationNode(context, entry); + + if (intent.BufferMode == BufferModeEnum.Aborting) + { + // Everything already queued is superseded, and whatever is + // executing is asked to stop. The aborted work terminates as + // Cancelled with Superseded, which is what tells a client the + // difference between "you cancelled it" and "you replaced it". + SupersedeQueuedLocked(context); + m_current?.RequestCancel(IntentFailureEnum.Superseded); + } + + m_queue.AddLast(entry); + SetExecutionStateLocked(context, entry, ExecutionStateEnum.Accepted); + RenumberQueueLocked(context); + m_pump.Release(); + return IntentAdmission.Admitted(id, entry.Node!.NodeId); + } + } + + // ------------------------------------------------------------- cancellation + + /// + /// Asks the Server to end an intent early. + /// + /// + /// This is NOT the OPC UA Cancel Service, which discards a pending service + /// response and leaves the robot moving. The Server may refuse: some motions + /// cannot be abandoned part-way without leaving the cell in a worse state than + /// completing them. + /// + public bool CancelIntent(ISystemContext context, NodeId? sessionId, string intentId) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + if (!HoldsAuthority(sessionId)) + { + return false; + } + lock (m_lock) + { + if (!m_intents.TryGetValue(intentId ?? string.Empty, out IntentEntry? entry) || + IntentOutcome.IsTerminal(entry.State)) + { + return false; + } + return CancelLocked(context, entry, IntentFailureEnum.None); + } + } + + /// + /// Asks the Server to end every outstanding intent and mission. + /// + public uint CancelAll(ISystemContext context, NodeId? sessionId) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + if (!HoldsAuthority(sessionId)) + { + return 0; + } + lock (m_lock) + { + uint count = 0; + foreach (MissionEntry mission in m_missions.Values.ToList()) + { + if (!IntentOutcome.IsTerminal(mission.State)) + { + FinishMissionLocked(context, mission, ExecutionStateEnum.Cancelled); + } + } + foreach (IntentEntry entry in m_intents.Values.ToList()) + { + if (!IntentOutcome.IsTerminal(entry.State) && + CancelLocked(context, entry, IntentFailureEnum.None)) + { + count++; + } + } + return count; + } + } + + private bool CancelLocked(ISystemContext context, IntentEntry entry, IntentFailureEnum reason) + { + if (entry == m_current) + { + if (!m_executor.CanCancel(entry.Execution!)) + { + return false; + } + SetExecutionStateLocked(context, entry, ExecutionStateEnum.Cancelling); + entry.RequestCancel(reason); + return true; + } + + // Queued work has not started, so there is nothing to bring to a + // controlled end: it goes straight to the terminal state. + m_queue.Remove(entry); + CompleteLocked(context, entry, new IntentOutcome + { + State = ExecutionStateEnum.Cancelled, + Failure = reason + }); + RenumberQueueLocked(context); + return true; + } + + private void SupersedeQueuedLocked(ISystemContext context) + { + while (m_queue.First is { } node) + { + m_queue.RemoveFirst(); + CompleteLocked(context, node.Value, new IntentOutcome + { + State = ExecutionStateEnum.Cancelled, + Failure = IntentFailureEnum.Superseded, + Message = "Replaced by an Aborting submission." + }); + } + } + + // -------------------------------------------------------- pause and resume + + /// + /// Suspends execution, retaining position. + /// + public bool Pause(ISystemContext context, NodeId? sessionId) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + if (!HoldsAuthority(sessionId)) + { + return false; + } + lock (m_lock) + { + if (m_paused) + { + return true; + } + m_paused = true; + if (m_current is { } cur && cur.State == ExecutionStateEnum.Executing) + { + SetExecutionStateLocked(context, cur, ExecutionStateEnum.Suspended); + } + return true; + } + } + + /// + /// Continues execution suspended by . + /// + public bool Resume(ISystemContext context, NodeId? sessionId) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + if (!HoldsAuthority(sessionId)) + { + return false; + } + lock (m_lock) + { + if (!m_paused) + { + return true; + } + m_paused = false; + if (m_current is { } cur && cur.State == ExecutionStateEnum.Suspended) + { + SetExecutionStateLocked(context, cur, ExecutionStateEnum.Executing); + } + m_pump.Release(); + return true; + } + } + + /// + /// Re-attempts an intent that terminated Retriable. + /// + /// + /// The new attempt is a NEW operation instance. The original stays where it is, + /// terminal, with its own result, so the history of what was tried survives. + /// + public IntentAdmission Retry(ISystemContext context, NodeId? sessionId, string intentId) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + IntentDataType? intent; + string missionId; + lock (m_lock) + { + if (!m_intents.TryGetValue(intentId ?? string.Empty, out IntentEntry? entry) || + entry.State != ExecutionStateEnum.Retriable) + { + return IntentAdmission.Refused(IntentFailureEnum.ParameterInvalid, + "No intent with that identifier terminated Retriable."); + } + intent = entry.Intent; + missionId = entry.MissionId; + } + + return SubmitCore(context, sessionId, intent!, missionId, forceNewId: true); + } + + // ------------------------------------------------------------------ authority + + /// + /// Takes command authority. + /// + /// + /// This arbitrates between OPC UA clients so two of them cannot interleave + /// motion. It is NOT the single point of control that ISO 10218-2 requires, + /// which concerns remote command against local manual control and is enforced + /// by safety-rated means outside this interface. + /// + public bool RequestControl(ISystemContext context, NodeId? sessionId, out NodeId? owner) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + lock (m_lock) + { + if (ControlOwner == null || ControlOwner == sessionId) + { + ControlOwner = sessionId; + PublishControllerState(context); + owner = ControlOwner; + return true; + } + owner = ControlOwner; + return false; + } + } + + /// + /// Gives up command authority. Outstanding intents are unaffected. + /// + public void ReleaseControl(ISystemContext context, NodeId? sessionId) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + lock (m_lock) + { + if (ControlOwner == sessionId) + { + ControlOwner = null; + PublishControllerState(context); + } + } + } + + /// + /// Releases authority held by a Session that has closed. + /// + /// + /// Without this a crashed client locks the robot for good. + /// + public void OnSessionClosed(ISystemContext context, NodeId sessionId) + { + ReleaseControl(context, sessionId); + } + + private bool HoldsAuthority(NodeId? sessionId) + { + lock (m_lock) + { + return !m_options.RequireControlAuthority || ControlOwner == sessionId; + } + } + + /// + /// Reports what the safety system is enforcing, and publishes it. + /// + /// + /// The application calls this; the host does not infer safety state. Admission + /// then refuses on the same values a client can read, so the refusal is + /// explainable from the address space rather than from Server-internal state. + /// + public void UpdateSafetyState(ISystemContext context, SafetyStatus status) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + if (status == null) + { + throw new ArgumentNullException(nameof(status)); + } + lock (m_lock) + { + m_safety = status; + PublishSafetyLocked(context); + PublishControllerState(context); + } + } + + /// + /// The safety state the host is refusing against. + /// + public SafetyStatus SafetyState + { + get + { + lock (m_lock) + { + return m_safety; + } + } + } + + /// + /// Whether the intent asks to move faster than the safety system permits. + /// + /// + /// Only an explicit Cartesian speed can be compared: a speed FRACTION is of a + /// configured maximum the host does not know, so it is left to the robot, which + /// does. Refusing what cannot be judged would reject legitimate work. + /// + private bool ExceedsSafeSpeed(IntentDataType intent) + { + if (!m_safety.SafeSpeedLimitActive || m_safety.SafeSpeedLimit <= 0) + { + return false; + } + return intent is MotionIntentDataType motion && + motion.Constraints is { } constraints && + constraints.CartesianSpeed > m_safety.SafeSpeedLimit; + } + + private void PublishSafetyLocked(ISystemContext context) + { + if (m_controller.SafetyState is not { } node) + { + return; + } + SetValue(node.ActiveFunction, m_safety.ActiveFunction); + SetValue(node.EmergencyStopActive, m_safety.EmergencyStopActive); + SetValue(node.ProtectiveStopActive, m_safety.ProtectiveStopActive); + SetValue(node.SafeSpeedLimitActive, m_safety.SafeSpeedLimitActive); + SetValue(node.SafeSpeedLimit, m_safety.SafeSpeedLimit); + SetValue(node.SafetyControllerOk, m_safety.SafetyControllerOk); + SetValue(node.LastStopReason, new LocalizedText(m_safety.LastStopReason ?? string.Empty)); + node.ClearChangeMasks(context, true); + } + + private bool IsSubmissionPermittedInMode() + { + OperationalModeEnum mode = m_options.OperationalMode; + return mode is OperationalModeEnum.Automatic or OperationalModeEnum.AutomaticExternal; + } + + /// + /// Resolves the declared capabilities against the Server's namespace table and + /// publishes them, so the declaration a client reads is the one the host + /// enforces rather than a parallel description of it. + /// + private void ResolveCapabilities(ISystemContext context) + { + m_capabilities.Clear(); + var published = new List(m_options.Capabilities.Count); + foreach (DeclaredCapability declared in m_options.Capabilities) + { + IntentCapabilityDataType resolved = declared.Resolve(context.NamespaceUris); + if (resolved.IntentType.IsNull) + { + continue; + } + m_capabilities[resolved.IntentType] = resolved; + published.Add(resolved); + } + + if (m_controller.Capabilities is { } capabilities) + { + SetValue(capabilities.SupportedIntents, new ArrayOf(published.ToArray())); + SetValue(capabilities.MissionsSupported, m_options.MissionsSupported); + SetValue(capabilities.MissionHorizonSupported, m_options.MissionHorizonSupported); + SetValue(capabilities.BlendingSupported, m_options.BlendingSupported); + SetValue(capabilities.AxisCount, m_options.AxisCount); + SetValue(capabilities.TrajectorySupported, m_options.TrajectorySupported); + SetValue(capabilities.ForceControlSupported, m_options.ForceControlSupported); + SetValue(capabilities.RealTimeChannelsSupported, + m_options.RealTimeChannelsSupported); + SetValue(capabilities.MissionBranchingSupported, + m_options.MissionBranchingSupported); + SetValue(capabilities.MaxTrajectoryPoints, m_options.MaxTrajectoryPoints); + capabilities.ClearChangeMasks(context, true); + } + } + + private IntentCapabilityDataType? FindCapability(IntentDataType intent) + { + NodeId? typeId = ExpandedNodeId.ToNodeId(intent.TypeId, m_namespaceUris); + if (typeId is not { } resolved || resolved.IsNull) + { + return null; + } + return m_capabilities.TryGetValue(resolved, out IntentCapabilityDataType? capability) + ? capability + : null; + } + + private static bool Permits(ArrayOf modes, T value) where T : struct, Enum + { + if (modes.IsNull || modes.IsEmpty) + { + return true; + } + for (int ii = 0; ii < modes.Count; ii++) + { + if (EqualityComparer.Default.Equals(modes[ii], value)) + { + return true; + } + } + return false; + } + + // ----------------------------------------------------------------- the pump + + private async Task PumpAsync(ISystemContext context, CancellationToken shutdown) + { + while (!shutdown.IsCancellationRequested) + { + try + { + await m_pump.WaitAsync(shutdown).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return; + } + + while (!shutdown.IsCancellationRequested) + { + IntentEntry? next; + lock (m_lock) + { + if (m_paused || m_queue.First == null) + { + m_current = null; + PublishControllerState(context); + break; + } + next = m_queue.First.Value; + m_queue.RemoveFirst(); + m_current = next; + SetExecutionStateLocked(context, next, ExecutionStateEnum.Executing); + RenumberQueueLocked(context); + PublishControllerState(context); + } + await RunOneAsync(context, next!).ConfigureAwait(false); + } + } + } + + private async Task RunOneAsync(ISystemContext context, IntentEntry entry) + { + var progress = new ProgressSink(this, context, entry); + entry.Execution = new IntentExecution(entry.IntentId, entry.Intent, progress) + { + MissionId = entry.MissionId + }; + + IntentOutcome outcome; + try + { + outcome = await m_executor + .ExecuteAsync(entry.Execution, entry.CancellationToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) + { + outcome = new IntentOutcome + { + State = ExecutionStateEnum.Cancelled, + Failure = entry.CancelReason + }; + } +#pragma warning disable CA1031 // an executor is application code; a fault must terminate + catch (Exception ex) // the intent, not the pump + { + outcome = IntentOutcome.Fail(IntentFailureEnum.Other, ex.Message); + } +#pragma warning restore CA1031 + + if (entry.CancelRequested && outcome.State != ExecutionStateEnum.Failed) + { + // A cancel was accepted, so the outcome is Cancelled however the + // executor chose to return. An executor that failed on the way out + // keeps its failure, which is more informative. + outcome = outcome with + { + State = ExecutionStateEnum.Cancelled, + Failure = outcome.Failure == IntentFailureEnum.None + ? entry.CancelReason + : outcome.Failure + }; + } + + lock (m_lock) + { + CompleteLocked(context, entry, outcome); + m_current = null; + AdvanceMissionLocked(context, entry, outcome); + PublishControllerState(context); + } + } + + // ------------------------------------------------------- real-time channels + + /// + /// Takes a lease on a brokered real-time channel. + /// + /// + /// This hands over what a client needs in order to connect and nothing else. + /// The samples travel on that channel; clause 4.3 explains why they cannot + /// travel here. A lease that is not renewed lapses, so a client that dies does + /// not hold the channel for good - the same reasoning as command authority. + /// + public RealTimeLease OpenRealTimeChannel( + ISystemContext context, NodeId? sessionId, string channelId, double requestedLeaseMs) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + if (!m_options.RealTimeChannelsSupported) + { + return RealTimeLease.Refused("This Server brokers no real-time channels."); + } + if (!HoldsAuthority(sessionId)) + { + return RealTimeLease.Refused( + "The calling Session does not hold command authority."); + } + lock (m_lock) + { + if (!m_channels.TryGetValue(channelId ?? string.Empty, out ChannelEntry? channel)) + { + return RealTimeLease.Refused("No channel with that identifier is offered."); + } + if (!channel.Available) + { + return RealTimeLease.Refused("The channel is not available."); + } + if (channel.RequiredMode != m_options.OperationalMode) + { + return RealTimeLease.Refused( + $"The channel requires {channel.RequiredMode} mode."); + } + bool held = channel.Leased && channel.Expiry > DateTime.UtcNow; + if (held && channel.Holder != sessionId) + { + return RealTimeLease.Refused("Another Session holds the lease."); + } + + double lease = requestedLeaseMs > 0 + ? Math.Min(requestedLeaseMs, m_options.MaxChannelLeaseMs) + : m_options.MaxChannelLeaseMs; + channel.Holder = sessionId; + channel.Leased = true; + channel.Expiry = DateTime.UtcNow.AddMilliseconds(lease); + PublishChannelLocked(context, channel); + return new RealTimeLease + { + Granted = true, + EndpointUrl = channel.EndpointUrl, + PayloadDescriptor = channel.PayloadDescriptor, + Expiry = channel.Expiry + }; + } + } + + /// + /// Gives up a lease on a brokered channel. + /// + public bool CloseRealTimeChannel(ISystemContext context, NodeId? sessionId, string channelId) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + lock (m_lock) + { + if (!m_channels.TryGetValue(channelId ?? string.Empty, out ChannelEntry? channel) || + !channel.Leased || channel.Holder != sessionId) + { + return false; + } + channel.Holder = null; + channel.Leased = false; + channel.Expiry = DateTime.MinValue; + PublishChannelLocked(context, channel); + return true; + } + } + + /// + /// Whether any channel lease is currently held. + /// + /// + /// Clause 6.9 forbids accepting motion intents alongside a held channel unless + /// the host can genuinely arbitrate: two things commanding one robot with no + /// arbitration is the failure that rule exists to prevent. + /// + private bool AnyChannelHeldLocked() + { + foreach (ChannelEntry channel in m_channels.Values) + { + if (channel.Leased && channel.Expiry > DateTime.UtcNow) + { + return true; + } + } + return false; + } + + /// + /// Materialises the declared channels so a client can browse and read them + /// before it asks for a lease. + /// + private void CreateChannels(ISystemContext context) + { + FolderState folder = EnsureFolder( + context, m_controller.RealTimeChannels, BrowseNames.RealTimeChannels); + foreach (DeclaredChannel declared in m_options.Channels) + { + var node = new RealTimeChannelState(folder) + { + NodeId = ChildNodeId(folder.NodeId, declared.ChannelId), + BrowseName = new QualifiedName( + declared.ChannelId, folder.BrowseName.NamespaceIndex), + DisplayName = new LocalizedText(declared.ChannelId), + SymbolicName = declared.ChannelId, + ReferenceTypeId = global::Opc.Ua.ReferenceTypeIds.HasComponent, + TypeDefinitionId = ExpandedNodeId.ToNodeId( + ObjectTypeIds.RealTimeChannelType, context.NamespaceUris) + }; + node.Create(context, node.NodeId, node.BrowseName, node.DisplayName, false); + node.AddReference(global::Opc.Ua.ReferenceTypeIds.HasComponent, true, folder.NodeId); + folder.AddReference(global::Opc.Ua.ReferenceTypeIds.HasComponent, false, node.NodeId); + + SetValue(node.ChannelId, declared.ChannelId); + SetValue(node.Transport, declared.Transport); + SetValue(node.EndpointUrl, declared.EndpointUrl); + SetValue(node.Initiator, declared.Initiator); + SetValue(node.NominalRate, declared.NominalRate); + SetValue(node.PayloadDescriptor, declared.PayloadDescriptor); + SetValue(node.RequiredMode, declared.RequiredMode); + + var entry = new ChannelEntry + { + ChannelId = declared.ChannelId, + EndpointUrl = declared.EndpointUrl, + PayloadDescriptor = declared.PayloadDescriptor, + RequiredMode = declared.RequiredMode, + Node = node + }; + m_channels[declared.ChannelId] = entry; + PublishChannelLocked(context, entry); + AddNode(node); + } + } + + private void PublishChannelLocked(ISystemContext context, ChannelEntry channel) + { + if (channel.Node is not { } node) + { + return; + } + SetValue(node.LeaseHolder, channel.Holder ?? global::Opc.Ua.NodeId.Null); + SetValue(node.LeaseExpiry, channel.Expiry); + SetValue(node.Available, channel.Available); + node.ClearChangeMasks(context, true); + } + + // ------------------------------------------------------------------ missions + + /// + /// Submits an ordered sequence of intents tracked as one unit. + /// + public MissionAdmission SubmitMission( + ISystemContext context, + NodeId? sessionId, + MissionDataType? mission) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + if (!m_options.MissionsSupported) + { + return MissionAdmission.Refused(MissionUpdateResultEnum.Rejected, + "This Server does not implement missions."); + } + if (!HoldsAuthority(sessionId)) + { + return MissionAdmission.Refused(MissionUpdateResultEnum.Rejected, + "The calling Session does not hold command authority."); + } + if (!IsSubmissionPermittedInMode()) + { + return MissionAdmission.Refused(MissionUpdateResultEnum.Rejected, + "Missions are accepted only in Automatic or AutomaticExternal mode."); + } + if (mission == null || mission.Steps.IsNull || mission.Steps.IsEmpty) + { + return MissionAdmission.Refused(MissionUpdateResultEnum.Rejected, + "A mission must carry at least one step."); + } + + Check ordering = MissionRules.ValidateSteps(mission.Steps); + if (!ordering.Ok) + { + return MissionAdmission.Refused(MissionUpdateResultEnum.Rejected, + ordering.Message ?? "The mission steps are not valid."); + } + Check graph = MissionRules.ValidateTransitions(mission.Steps, mission.Transitions); + if (!graph.Ok) + { + return MissionAdmission.Refused(MissionUpdateResultEnum.Rejected, + graph.Message ?? "The mission graph is not valid."); + } + + lock (m_lock) + { + string id = mission.MissionId ?? string.Empty; + if (string.IsNullOrEmpty(id)) + { + id = FormattableString.Invariant( + $"mission-{Interlocked.Increment(ref m_nextId)}"); + } + else if (m_missions.TryGetValue(id, out MissionEntry? existing) && + !IntentOutcome.IsTerminal(existing.State)) + { + return MissionAdmission.Refused(MissionUpdateResultEnum.Rejected, + $"MissionId '{id}' is already outstanding."); + } + + var entry = new MissionEntry(id, mission); + m_missions[id] = entry; + CreateMissionNode(context, entry); + SetMissionStateLocked(context, entry, ExecutionStateEnum.Executing); + StartNextStepLocked(context, entry, sessionId); + return MissionAdmission.Admitted(id, entry.Node!.NodeId); + } + } + + /// + /// Replaces the horizon of a mission already submitted. + /// + /// + /// The base is untouchable. It has been committed and may already have + /// executed, so an update that would alter a released step is refused rather + /// than partly applied, and the whole update is applied atomically. + /// + public MissionUpdateOutcome UpdateMission( + ISystemContext context, + NodeId? sessionId, + string missionId, + uint missionUpdateId, + ArrayOf steps) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + if (!m_options.MissionHorizonSupported) + { + return new MissionUpdateOutcome(MissionUpdateResultEnum.Rejected, + "This Server does not implement horizon updates."); + } + if (!HoldsAuthority(sessionId)) + { + return new MissionUpdateOutcome(MissionUpdateResultEnum.Rejected, + "The calling Session does not hold command authority."); + } + + lock (m_lock) + { + if (!m_missions.TryGetValue(missionId ?? string.Empty, out MissionEntry? entry)) + { + return new MissionUpdateOutcome(MissionUpdateResultEnum.UnknownMission, + "No mission with that identifier is held."); + } + if (missionUpdateId <= entry.Mission.MissionUpdateId) + { + // Two updates that crossed in flight: the later one wins and the + // earlier is rejected rather than applied out of order. + return new MissionUpdateOutcome(MissionUpdateResultEnum.Outdated, + "MissionUpdateId must be greater than the mission's current value."); + } + + Check conflict = MissionRules.ValidateBasePreserved(entry.Mission.Steps, steps); + if (!conflict.Ok) + { + return new MissionUpdateOutcome(MissionUpdateResultEnum.BaseConflict, + conflict.Message ?? "The update would alter a released step."); + } + Check ordering = MissionRules.ValidateSteps(steps); + if (!ordering.Ok) + { + return new MissionUpdateOutcome(MissionUpdateResultEnum.Rejected, + ordering.Message ?? "The replacement steps are not valid."); + } + Check graph = MissionRules.ValidateTransitions(steps, entry.Mission.Transitions); + if (!graph.Ok) + { + return new MissionUpdateOutcome(MissionUpdateResultEnum.Rejected, + graph.Message ?? "The mission graph is not valid."); + } + + entry.Mission.Steps = steps; + entry.Mission.MissionUpdateId = missionUpdateId; + PublishMissionLocked(context, entry); + return new MissionUpdateOutcome(MissionUpdateResultEnum.Accepted, null); + } + } + + /// + /// Ends a mission and every intent belonging to it. + /// + public bool CancelMission(ISystemContext context, NodeId? sessionId, string missionId) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + if (!HoldsAuthority(sessionId)) + { + return false; + } + lock (m_lock) + { + if (!m_missions.TryGetValue(missionId ?? string.Empty, out MissionEntry? entry) || + IntentOutcome.IsTerminal(entry.State)) + { + return false; + } + foreach (IntentEntry intent in m_intents.Values.ToList()) + { + if (intent.MissionId == entry.MissionId && + !IntentOutcome.IsTerminal(intent.State)) + { + CancelLocked(context, intent, IntentFailureEnum.None); + } + } + FinishMissionLocked(context, entry, ExecutionStateEnum.Cancelled); + return true; + } + } + + private void StartNextStepLocked(ISystemContext context, MissionEntry mission, NodeId? sessionId) + { + MissionStepDataType? step = MissionRules.NextPending(mission.Mission.Steps, mission.NextIndex); + if (step == null) + { + FinishMissionLocked(context, mission, ExecutionStateEnum.Succeeded); + return; + } + + mission.CurrentStepId = step.StepId ?? string.Empty; + IntentAdmission admission = + SubmitCore(context, sessionId, step.Intent, mission.MissionId, forceNewId: true); + if (!admission.Accepted) + { + FinishMissionLocked(context, mission, ExecutionStateEnum.Failed); + return; + } + mission.CurrentIntentId = admission.IntentId; + PublishMissionLocked(context, mission); + } + + private void AdvanceMissionLocked(ISystemContext context, IntentEntry entry, IntentOutcome outcome) + { + if (string.IsNullOrEmpty(entry.MissionId) || + !m_missions.TryGetValue(entry.MissionId, out MissionEntry? mission) || + IntentOutcome.IsTerminal(mission.State)) + { + return; + } + if (mission.CurrentIntentId != entry.IntentId) + { + return; + } + + MissionRules.SetStatus(mission.Mission.Steps, mission.NextIndex, outcome.State, + entry.Node?.NodeId); + + if (outcome.State == ExecutionStateEnum.Succeeded) + { + if (mission.Compensating) + { + // The compensation ran; the mission still ends, because that is + // what distinguishes Compensate from Fallback. + FinishMissionLocked(context, mission, ExecutionStateEnum.Failed); + return; + } + mission.RetriesUsed = 0; + if (!AdvanceToNextStepLocked(context, mission)) + { + FinishMissionLocked(context, mission, ExecutionStateEnum.Succeeded); + } + return; + } + + if (outcome.State == ExecutionStateEnum.Cancelled) + { + FinishMissionLocked(context, mission, ExecutionStateEnum.Cancelled); + return; + } + + ApplyErrorPolicyLocked(context, mission); + } + + /// + /// Chooses the step that follows one that succeeded. + /// + /// + /// Where the mission carries a step graph and this host evaluates it, the graph + /// decides; otherwise the steps run in order, which is what a mission without + /// transitions has always done. + /// + private bool AdvanceToNextStepLocked(ISystemContext context, MissionEntry mission) + { + ArrayOf transitions = mission.Mission.Transitions; + bool graphed = m_options.MissionBranchingSupported && + !transitions.IsNull && !transitions.IsEmpty; + + if (graphed) + { + MissionTransitionDataType? edge = MissionRules.SelectTransition( + transitions, mission.CurrentStepId, m_options.EvaluateCondition); + if (edge == null) + { + return false; + } + int next = MissionRules.IndexOfStep(mission.Mission.Steps, edge.ToStepId ?? string.Empty); + if (next < 0) + { + return false; + } + mission.NextIndex = next; + } + else + { + mission.NextIndex++; + } + StartNextStepLocked(context, mission, ControlOwner); + return !IntentOutcome.IsTerminal(mission.State); + } + + /// + /// Applies a failed step's error policy, per clause 7.4. + /// + private void ApplyErrorPolicyLocked(ISystemContext context, MissionEntry mission) + { + MissionStepDataType? step = + MissionRules.NextPending(mission.Mission.Steps, mission.NextIndex); + ErrorPolicyEnum policy = step?.ErrorPolicy ?? ErrorPolicyEnum.Abort; + + switch (policy) + { + case ErrorPolicyEnum.Retry: + if (mission.RetriesUsed < m_options.MaxStepRetries) + { + mission.RetriesUsed++; + StartNextStepLocked(context, mission, ControlOwner); + return; + } + FinishMissionLocked(context, mission, ExecutionStateEnum.Failed); + return; + case ErrorPolicyEnum.Skip: + mission.RetriesUsed = 0; + if (!AdvanceToNextStepLocked(context, mission)) + { + FinishMissionLocked(context, mission, ExecutionStateEnum.Succeeded); + } + return; + case ErrorPolicyEnum.Fallback: + case ErrorPolicyEnum.Compensate: + int target = MissionRules.IndexOfStep( + mission.Mission.Steps, step?.FallbackStepId ?? string.Empty); + if (target < 0) + { + FinishMissionLocked(context, mission, ExecutionStateEnum.Failed); + return; + } + mission.RetriesUsed = 0; + mission.Compensating = policy == ErrorPolicyEnum.Compensate; + mission.NextIndex = target; + StartNextStepLocked(context, mission, ControlOwner); + return; + default: + FinishMissionLocked(context, mission, ExecutionStateEnum.Failed); + return; + } + } + + private void FinishMissionLocked( + ISystemContext context, MissionEntry mission, ExecutionStateEnum state) + { + SetMissionStateLocked(context, mission, state); + mission.CurrentStepId = string.Empty; + PublishMissionLocked(context, mission); + } + + // ------------------------------------------------------------ node plumbing + + private void CreateOperationNode(ISystemContext context, IntentEntry entry) + { + FolderState folder = m_intentsFolder + ?? throw new InvalidOperationException("The controller has no Intents folder."); + var node = new IntentOperationState(folder) + { + NodeId = ChildNodeId(folder.NodeId, entry.IntentId), + BrowseName = new QualifiedName(entry.IntentId, folder.BrowseName.NamespaceIndex), + DisplayName = new LocalizedText(entry.IntentId), + SymbolicName = entry.IntentId, + ReferenceTypeId = global::Opc.Ua.ReferenceTypeIds.HasComponent, + TypeDefinitionId = ExpandedNodeId.ToNodeId( + ObjectTypeIds.IntentOperationType, context.NamespaceUris), + EventNotifier = global::Opc.Ua.EventNotifiers.SubscribeToEvents + }; + node.Create(context, node.NodeId, node.BrowseName, node.DisplayName, false); + node.AddReference(global::Opc.Ua.ReferenceTypeIds.HasComponent, true, folder.NodeId); + folder.AddReference(global::Opc.Ua.ReferenceTypeIds.HasComponent, false, node.NodeId); + + SetValue(node.IntentId, entry.IntentId); + SetValue(node.Intent, entry.Intent); + SetValue(node.MissionId, entry.MissionId); + SetValue(node.Progress, -1.0); + SetValue(node.QueuePosition, (uint)0); + SetValue(node.Deletable, true); + SetValue(node.AutoDelete, false); + SetValue(node.RecycleCount, 0); + node.SetState(context, StateReady); + + EnsureFinalResultData(context, node); + entry.Node = node; + AddNode(node); + } + + /// + /// Part 10 declares FinalResultData Optional, so it is not materialised by + /// default. This host always produces a result and clause 6.7 says a Part 10 + /// client must find it here, so the object is created rather than skipped. + /// + private static void EnsureFinalResultData(ISystemContext context, IntentOperationState node) + { + if (node.FinalResultData != null) + { + return; + } + const string browseName = "FinalResultData"; + var final = new BaseObjectState(node) + { + NodeId = ChildNodeId(node.NodeId, browseName), + BrowseName = new QualifiedName(browseName, node.BrowseName.NamespaceIndex), + DisplayName = new LocalizedText(browseName), + SymbolicName = browseName, + ReferenceTypeId = global::Opc.Ua.ReferenceTypeIds.HasComponent, + TypeDefinitionId = global::Opc.Ua.ObjectTypeIds.BaseObjectType + }; + node.AddChild(final); + final.AddReference(global::Opc.Ua.ReferenceTypeIds.HasComponent, true, node.NodeId); + node.AddReference(global::Opc.Ua.ReferenceTypeIds.HasComponent, false, final.NodeId); + node.FinalResultData = final; + _ = context; + } + + private void CreateMissionNode(ISystemContext context, MissionEntry entry) + { + FolderState folder = m_missionsFolder + ?? throw new InvalidOperationException("The controller has no Missions folder."); + var node = new MissionObjectState(folder) + { + NodeId = ChildNodeId(folder.NodeId, entry.MissionId), + BrowseName = new QualifiedName(entry.MissionId, folder.BrowseName.NamespaceIndex), + DisplayName = new LocalizedText(entry.MissionId), + SymbolicName = entry.MissionId, + ReferenceTypeId = global::Opc.Ua.ReferenceTypeIds.HasComponent, + TypeDefinitionId = ExpandedNodeId.ToNodeId( + ObjectTypeIds.MissionType, context.NamespaceUris), + EventNotifier = global::Opc.Ua.EventNotifiers.SubscribeToEvents + }; + node.Create(context, node.NodeId, node.BrowseName, node.DisplayName, false); + node.AddReference(global::Opc.Ua.ReferenceTypeIds.HasComponent, true, folder.NodeId); + folder.AddReference(global::Opc.Ua.ReferenceTypeIds.HasComponent, false, node.NodeId); + + SetValue(node.MissionId, entry.MissionId); + SetValue(node.Deletable, true); + SetValue(node.AutoDelete, false); + SetValue(node.RecycleCount, 0); + node.SetState(context, StateReady); + + entry.Node = node; + AddNode(node); + PublishMissionLocked(context, entry); + } + + private void SetExecutionStateLocked( + ISystemContext context, IntentEntry entry, ExecutionStateEnum state) + { + entry.State = state; + if (entry.Node is not { } node) + { + return; + } + SetValue(node.ExecutionState, state); + node.SetState(context, MapToProgramState(state)); + node.ClearChangeMasks(context, true); + } + + private void SetMissionStateLocked( + ISystemContext context, MissionEntry entry, ExecutionStateEnum state) + { + entry.State = state; + if (entry.Node is not { } node) + { + return; + } + SetValue(node.ExecutionState, state); + node.SetState(context, MapToProgramState(state)); + node.ClearChangeMasks(context, true); + } + + /// + /// The clause 6.3 table, in code. A pairing not listed there is not legal, so + /// this mapping is total and has no default arm that guesses. + /// + internal static uint MapToProgramState(ExecutionStateEnum state) + { + return state switch + { + ExecutionStateEnum.Accepted => StateReady, + ExecutionStateEnum.Queued => StateReady, + ExecutionStateEnum.Executing => StateRunning, + ExecutionStateEnum.Cancelling => StateRunning, + ExecutionStateEnum.Suspended => StateSuspended, + ExecutionStateEnum.Succeeded => StateHalted, + ExecutionStateEnum.Failed => StateHalted, + ExecutionStateEnum.Cancelled => StateHalted, + ExecutionStateEnum.Retriable => StateHalted, + _ => throw new ArgumentOutOfRangeException(nameof(state), state, + "ExecutionStateEnum has no clause 6.3 pairing.") + }; + } + + private void CompleteLocked(ISystemContext context, IntentEntry entry, IntentOutcome outcome) + { + var result = new IntentResultDataType + { + IntentId = entry.IntentId, + State = outcome.State, + Failure = outcome.Failure, + Message = new LocalizedText(outcome.Message ?? string.Empty), + HasAchievedPose = outcome.AchievedPose != null, + AchievedPose = outcome.AchievedPose ?? new Pose3DDataType(), + StartTime = entry.StartTime, + EndTime = DateTime.UtcNow, + Outputs = outcome.Outputs + }; + entry.Result = result; + // The result is published BEFORE the state goes terminal. A client watching + // the state machine acts the moment it sees a terminal state, and would + // otherwise read a result that is not there yet. + if (entry.Node is { } node) + { + SetValue(node.Result, result); + SetValue(node.QueuePosition, (uint)0); + PublishFinalResult(context, node, result); + node.ClearChangeMasks(context, true); + } + SetExecutionStateLocked(context, entry, outcome.State); + } + + /// + /// Places the result under the inherited FinalResultData object as well, so a + /// client written against Part 10 finds it where Part 10 says it will be. + /// + private static void PublishFinalResult( + ISystemContext context, IntentOperationState node, IntentResultDataType result) + { + BaseObjectState? final = node.FinalResultData; + if (final == null) + { + return; + } + var browseName = new QualifiedName( + nameof(IntentOperationState.Result), node.BrowseName.NamespaceIndex); + var value = new Variant(new ExtensionObject(result)); + if (final.FindChild(context, browseName) is BaseDataVariableState existing) + { + existing.Value = value; + existing.ClearChangeMasks(context, false); + return; + } + var variable = new BaseDataVariableState(final) + { + NodeId = ChildNodeId(final.NodeId, browseName.Name ?? "Result"), + BrowseName = browseName, + DisplayName = new LocalizedText(browseName.Name ?? "Result"), + SymbolicName = browseName.Name ?? "Result", + ReferenceTypeId = global::Opc.Ua.ReferenceTypeIds.HasComponent, + TypeDefinitionId = global::Opc.Ua.VariableTypeIds.BaseDataVariableType, + DataType = ExpandedNodeId.ToNodeId( + DataTypeIds.IntentResultDataType, context.NamespaceUris), + ValueRank = global::Opc.Ua.ValueRanks.Scalar, + Value = value + }; + final.AddChild(variable); + } + + private void RenumberQueueLocked(ISystemContext context) + { + uint position = 1; + foreach (IntentEntry entry in m_queue) + { + if (entry.State != ExecutionStateEnum.Queued) + { + SetExecutionStateLocked(context, entry, ExecutionStateEnum.Queued); + } + if (entry.Node is { } node) + { + SetValue(node.QueuePosition, position); + node.ClearChangeMasks(context, false); + } + position++; + } + } + + private void PublishMissionLocked(ISystemContext context, MissionEntry entry) + { + if (entry.Node is not { } node) + { + return; + } + SetValue(node.Mission, entry.Mission); + SetValue(node.MissionUpdateId, entry.Mission.MissionUpdateId); + SetValue(node.CurrentStepId, entry.CurrentStepId); + SetValue(node.ReleasedStepCount, MissionRules.ReleasedCount(entry.Mission.Steps)); + node.ClearChangeMasks(context, true); + } + + private void PublishControllerState(ISystemContext context) + { + SetValue(m_controller.OperationalMode, m_options.OperationalMode); + SetValue(m_controller.Ready, + IsSubmissionPermittedInMode() && !m_paused && m_safety.PermitsSubmission); + SetValue(m_controller.ControlOwner, ControlOwner ?? global::Opc.Ua.NodeId.Null); + SetValue(m_controller.MaxQueueDepth, m_options.MaxQueueDepth); + SetValue(m_controller.ActiveIntent, m_current?.Node?.NodeId ?? NodeId.Null); + m_controller.ClearChangeMasks(context, true); + } + + // PropertyState and BaseDataVariableState derive from different + // non-generic bases, so there is no one generic type to constrain on. + private static void SetValue(PropertyState? variable, T value) + { + if (variable != null) + { + variable.Value = value; + } + } + + private static void SetValue(BaseDataVariableState? variable, T value) + { + if (variable != null) + { + variable.Value = value; + } + } + + /// + /// Returns the declared folder, creating it when the type declared it Optional + /// and it was not materialised. A Server that implements a facet exposes its + /// optional members; leaving them absent would make the facet unclaimable. + /// + private FolderState EnsureFolder(ISystemContext context, FolderState? declared, string browseName) + { + if (declared != null) + { + return declared; + } + var folder = new FolderState(m_controller) + { + NodeId = ChildNodeId(m_controller.NodeId, browseName), + BrowseName = new QualifiedName(browseName, m_controller.BrowseName.NamespaceIndex), + DisplayName = new LocalizedText(browseName), + SymbolicName = browseName, + ReferenceTypeId = global::Opc.Ua.ReferenceTypeIds.HasComponent, + TypeDefinitionId = global::Opc.Ua.ObjectTypeIds.FolderType, + EventNotifier = global::Opc.Ua.EventNotifiers.None + }; + m_controller.AddChild(folder); + folder.AddReference(global::Opc.Ua.ReferenceTypeIds.HasComponent, true, m_controller.NodeId); + m_controller.AddReference(global::Opc.Ua.ReferenceTypeIds.HasComponent, false, folder.NodeId); + AddNode(folder); + return folder; + } + + /// + /// Publishes a per-invocation node. The add usually completes synchronously; + /// when it does not, the task is observed so a failure surfaces instead of + /// leaving a node the client can never browse and no word about why. + /// + private void AddNode(NodeState node) + { + ValueTask task = m_addNode(node, CancellationToken.None); + if (task.IsCompletedSuccessfully) + { + return; + } + _ = task.AsTask().ContinueWith( + t => NodeAddFailed?.Invoke(this, new IntentNodeAddFailure(node, t.Exception!)), + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + + /// + /// Raised when a per-invocation node could not be published. + /// + public event EventHandler? NodeAddFailed; + + private static NodeId ChildNodeId(NodeId parent, string name) + { + return new NodeId($"{parent.IdentifierAsString}_{name}", parent.NamespaceIndex); + } + + private void WireMethods(ISystemContext context) + { + if (m_controller.RequestControl is { } requestControl) + { + requestControl.OnCallAsync = (ctx, method, objectId, ct) => + { + bool granted = RequestControl(context, SessionOf(ctx), out NodeId? owner); + return new ValueTask(new RequestControlMethodStateResult + { + Granted = granted, + CurrentOwner = owner ?? global::Opc.Ua.NodeId.Null + }); + }; + } + if (m_controller.SubmitIntent is { } submit) + { + submit.OnCallAsync = (ctx, method, objectId, intent, ct) => + { + IntentAdmission admission = SubmitIntent(context, SessionOf(ctx), intent); + if (!admission.Accepted) + { + throw ServiceResultException.Create( + StatusCodes.BadUserAccessDenied, admission.Message ?? "Refused."); + } + return new ValueTask(new SubmitIntentMethodStateResult + { + IntentId = admission.IntentId, + Operation = admission.Operation + }); + }; + } + } + + /// + /// The Session behind a Method call, which is what command authority is held + /// by. A context without one is an internal call, and holds no authority. + /// + private static NodeId? SessionOf(ISystemContext? context) + { + if (context is SessionSystemContext + { + OperationContext: Opc.Ua.Server.OperationContext operation + }) + { + return operation.SessionId; + } + return null; + } + + /// + public void Dispose() + { + if (m_disposed) + { + return; + } + m_disposed = true; + m_shutdown.Cancel(); + try + { + m_pumpTask?.Wait(TimeSpan.FromSeconds(5)); + } +#pragma warning disable CA1031 // shutdown must not throw + catch (Exception) + { + } +#pragma warning restore CA1031 + m_shutdown.Dispose(); + m_pump.Dispose(); + foreach (IntentEntry entry in m_intents.Values) + { + entry.Dispose(); + } + } + + private sealed class ProgressSink( + IntentControllerHost host, ISystemContext context, IntentEntry entry) : IIntentProgress + { + public void ReportProgress(double fraction) + { + if (entry.Node is { } node) + { + SetValue(node.Progress, fraction); + node.Progress?.ClearChangeMasks(context, false); + } + _ = host; + } + + public void ReportPose(Pose3DDataType pose) + { + if (entry.Node is { } node) + { + SetValue(node.CurrentPose, pose); + node.CurrentPose?.ClearChangeMasks(context, false); + } + } + } + + private sealed class IntentEntry(string intentId, IntentDataType intent, string missionId) + : IDisposable + { + private readonly CancellationTokenSource m_cts = new(); + + public string IntentId { get; } = intentId; + public IntentDataType Intent { get; } = intent; + public string MissionId { get; } = missionId; + public IntentOperationState? Node { get; set; } + public IntentExecution? Execution { get; set; } + public ExecutionStateEnum State { get; set; } = ExecutionStateEnum.Accepted; + public IntentResultDataType? Result { get; set; } + public DateTime StartTime { get; } = DateTime.UtcNow; + public bool CancelRequested { get; private set; } + public IntentFailureEnum CancelReason { get; private set; } = IntentFailureEnum.None; + public CancellationToken CancellationToken => m_cts.Token; + + public void RequestCancel(IntentFailureEnum reason) + { + CancelRequested = true; + CancelReason = reason; + if (!m_cts.IsCancellationRequested) + { + m_cts.Cancel(); + } + } + + public void Dispose() + { + m_cts.Dispose(); + } + } + + private sealed class ChannelEntry + { + public string ChannelId { get; init; } = string.Empty; + public string EndpointUrl { get; init; } = string.Empty; + public string PayloadDescriptor { get; init; } = string.Empty; + public OperationalModeEnum RequiredMode { get; init; } + public bool Available { get; set; } = true; + public RealTimeChannelState? Node { get; set; } + public NodeId? Holder { get; set; } + public bool Leased { get; set; } + public DateTime Expiry { get; set; } = DateTime.MinValue; + } + + private sealed class MissionEntry(string missionId, MissionDataType mission) + { + public string MissionId { get; } = missionId; + public MissionDataType Mission { get; } = mission; + public MissionObjectState? Node { get; set; } + public ExecutionStateEnum State { get; set; } = ExecutionStateEnum.Accepted; + public int NextIndex { get; set; } + public uint RetriesUsed { get; set; } + public bool Compensating { get; set; } + public string CurrentStepId { get; set; } = string.Empty; + public string CurrentIntentId { get; set; } = string.Empty; + } + } +} diff --git a/src/Opc.Ua.Robotics.Server/Intent/IntentControllerHostOptions.cs b/src/Opc.Ua.Robotics.Server/Intent/IntentControllerHostOptions.cs new file mode 100644 index 0000000000..e15ee8da1d --- /dev/null +++ b/src/Opc.Ua.Robotics.Server/Intent/IntentControllerHostOptions.cs @@ -0,0 +1,901 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace Opc.Ua.RobotIntent.Server +{ + /// + /// What a Server will accept, and under what constraints. + /// + /// + /// The capability list is a CONTRACT, not documentation: the host refuses anything + /// not declared here, so a client that reads it once knows what it may submit + /// instead of submitting to find out. + /// + public sealed class IntentControllerHostOptions + { + /// + /// The operational mode the robot reports. Submission is permitted only in + /// Automatic and AutomaticExternal, and this specification defines no way to + /// command a change: mode selection is a safety function performed by + /// safety-rated means. + /// + public OperationalModeEnum OperationalMode { get; set; } = OperationalModeEnum.AutomaticExternal; + + /// + /// How many intents may queue behind the executing one. Zero accepts only + /// Aborting submissions. + /// + public uint MaxQueueDepth { get; set; } = 8; + + /// + /// Whether a caller must hold command authority to submit. Defaults to true; + /// turning it off is for single-client test hosts only. + /// + public bool RequireControlAuthority { get; set; } = true; + + /// + /// Whether SubmitMission is implemented. + /// + public bool MissionsSupported { get; set; } = true; + + /// + /// Whether UpdateMission can revise the horizon of a running mission. + /// + public bool MissionHorizonSupported { get; set; } = true; + + /// + /// Whether the blending buffer modes actually blend. A host that treats them + /// as Buffered reports false, so a client is not misled about the path. + /// + public bool BlendingSupported { get; set; } + + /// + /// How many axes a JointMoveIntentDataType must carry. + /// + public uint AxisCount { get; set; } = 6; + + /// + /// Whether trajectory and Cartesian path intents are accepted. + /// + public bool TrajectorySupported { get; set; } = true; + + /// + /// Whether force intents are accepted AND the robot genuinely regulates force. + /// A host that would ignore the force reports false rather than accepting an + /// intent it cannot honour. + /// + public bool ForceControlSupported { get; set; } + + /// + /// Whether the host brokers real-time channels. + /// + public bool RealTimeChannelsSupported { get; set; } + + /// + /// Whether mission transitions are evaluated. A host that reports false runs + /// the steps in order and ignores any transitions supplied. + /// + public bool MissionBranchingSupported { get; set; } = true; + + /// + /// Largest trajectory accepted, in points. Zero states no limit. + /// + public uint MaxTrajectoryPoints { get; set; } + + /// + /// How many times a step whose ErrorPolicy is Retry may be re-attempted. + /// + public uint MaxStepRetries { get; set; } = 2; + + /// + /// Longest lease a real-time channel is granted, in milliseconds. + /// + public double MaxChannelLeaseMs { get; set; } = 30000; + + /// + /// Whether this host can arbitrate between an intent and a held real-time + /// channel. Defaults to false, which is the safe answer: clause 6.9 then + /// requires motion intents to be refused while a lease is held. + /// + public bool ArbitratesWithRealTimeChannel { get; set; } + + /// + /// The real-time channels this host offers. + /// + public IList Channels { get; } = []; + + /// + /// Evaluates a transition condition. + /// + /// + /// When none is supplied, an EMPTY ContentFilter is true and a non-empty one is + /// false. That is deterministic and states its own limitation: an + /// unconditional edge works, and an edge nobody can evaluate is simply not + /// taken rather than being taken by accident. + /// + public Func? ConditionEvaluator { get; set; } + + /// + /// The evaluator actually used, defaulted as described above. + /// + public bool EvaluateCondition(ContentFilter? condition) + { + // A filter with no elements is unconditional. Testing only for a NULL + // filter is not enough: an encoded ContentFilter arrives with an empty + // element array, and treating that as unevaluatable makes every + // unconditional transition silently untaken. + if (condition == null || condition.Elements.IsNull || condition.Elements.IsEmpty) + { + return true; + } + return ConditionEvaluator?.Invoke(condition) ?? false; + } + + /// + /// One entry per intent type this host accepts. + /// + /// + /// The intent type is held as an and resolved + /// against the Server's namespace table when the host starts. Resolving it at + /// declaration time would bind it to whatever table happened to exist then, + /// which is how a capability list silently stops matching anything. + /// + public IList Capabilities { get; } = []; + + /// + /// Declares support for an intent type. + /// + public IntentControllerHostOptions Accept( + ExpandedNodeId intentType, + bool cancelSupported = true, + bool pauseSupported = true, + bool retrySupported = false, + string? description = null) + { + Capabilities.Add(new DeclaredCapability + { + IntentType = intentType, + Description = description, + CancelSupported = cancelSupported, + PauseSupported = pauseSupported, + RetrySupported = retrySupported + }); + return this; + } + } + + /// + /// One declared intent type, before it is resolved against a namespace table. + /// + public sealed record DeclaredCapability + { + /// The intent DataType this host accepts. + public ExpandedNodeId IntentType { get; init; } = ExpandedNodeId.Null; + + /// What this host does with it. + public string? Description { get; init; } + + /// Whether Cancel is honoured for it. + public bool CancelSupported { get; init; } = true; + + /// Whether Pause and Resume are honoured for it. + public bool PauseSupported { get; init; } = true; + + /// Whether it can terminate Retriable. + public bool RetrySupported { get; init; } + + /// Buffer modes accepted for it. Aborting is always accepted. + public ArrayOf SupportedBufferModes { get; init; } = new[] + { + BufferModeEnum.Aborting, + BufferModeEnum.Buffered + }; + + /// Blocking modes accepted for it. + public ArrayOf SupportedBlockingModes { get; init; } = new[] + { + BlockingModeEnum.None, + BlockingModeEnum.Soft, + BlockingModeEnum.Single, + BlockingModeEnum.Hard + }; + + /// + /// Resolves this declaration into the value published in the address space. + /// + public IntentCapabilityDataType Resolve(NamespaceTable namespaceUris) + { + return new IntentCapabilityDataType + { + IntentType = ExpandedNodeId.ToNodeId(IntentType, namespaceUris), + Description = new LocalizedText(Description ?? string.Empty), + CancelSupported = CancelSupported, + PauseSupported = PauseSupported, + RetrySupported = RetrySupported, + SupportedBufferModes = SupportedBufferModes, + SupportedBlockingModes = SupportedBlockingModes + }; + } + } + + /// + /// One real-time channel this host can broker. + /// + /// + /// The host describes and leases it. It defines no transport, opens no socket and + /// inspects no payload: the descriptor is passed through to the client in whatever + /// form the transport itself uses. + /// + public sealed record DeclaredChannel + { + /// Identifier unique within the controller. + public string ChannelId { get; init; } = string.Empty; + + /// The transport this channel speaks. + public RealTimeTransportEnum Transport { get; init; } + + /// Where the channel is reached. + public string EndpointUrl { get; init; } = string.Empty; + + /// Which end opens the connection. + public ChannelInitiatorEnum Initiator { get; init; } + + /// The rate the channel runs at, in hertz. + public double NominalRate { get; init; } + + /// The transport's own recipe or signal list. + public string PayloadDescriptor { get; init; } = string.Empty; + + /// The operational mode required before it will carry motion. + public OperationalModeEnum RequiredMode { get; init; } = OperationalModeEnum.AutomaticExternal; + } + + /// + /// The outcome of asking for a channel lease. + /// + public sealed record RealTimeLease + { + /// Whether the lease was taken. + public bool Granted { get; init; } + + /// Where to connect. + public string EndpointUrl { get; init; } = string.Empty; + + /// The transport's own configuration. + public string PayloadDescriptor { get; init; } = string.Empty; + + /// When the lease lapses. + public DateTime Expiry { get; init; } + + /// Why it was refused. + public string? Message { get; init; } + + /// Creates a refusal. + public static RealTimeLease Refused(string message) + { + return new RealTimeLease { Message = message }; + } + } + + /// + /// What the safety system is enforcing, as the application reports it. + /// + /// + /// Every field is a REPORT. The safety system enforces these independently and + /// remains effective when this Server is unreachable; the host reads them only so + /// that it can refuse work the safety system would then have to reject, which is a + /// courtesy and not a protective measure. See OPC UA - Robot Intent clause 10.4. + /// + public sealed record SafetyStatus + { + /// The safe motion function currently enforced. + public SafeMotionFunctionEnum ActiveFunction { get; init; } = SafeMotionFunctionEnum.None; + + /// True while an emergency stop is asserted. + public bool EmergencyStopActive { get; init; } + + /// True while a protective stop is asserted. + public bool ProtectiveStopActive { get; init; } + + /// True while a safely limited speed is being enforced. + public bool SafeSpeedLimitActive { get; init; } + + /// The enforced tool centre point speed limit, in metres per second. + public double SafeSpeedLimit { get; init; } + + /// False when the safety system reports its own fault. + public bool SafetyControllerOk { get; init; } = true; + + /// Why the last stop occurred, for a human. + public string? LastStopReason { get; init; } + + /// Nothing asserted and the safety controller healthy. + public static SafetyStatus Nominal { get; } = new(); + + /// + /// Whether this state permits an intent to be admitted at all. + /// + public bool PermitsSubmission => + SafetyControllerOk && !EmergencyStopActive && !ProtectiveStopActive; + } + + /// + /// The outcome of admitting one intent. + /// + public sealed record IntentAdmission + { + /// Whether the intent was admitted. + public bool Accepted { get; init; } + + /// The identifier it was admitted under. + public string IntentId { get; init; } = string.Empty; + + /// The IntentOperation that tracks it. + public NodeId Operation { get; init; } = NodeId.Null; + + /// Why it was refused. + public IntentFailureEnum Failure { get; init; } = IntentFailureEnum.None; + + /// Human-readable detail on a refusal. + public string? Message { get; init; } + + /// Creates an accepted admission. + public static IntentAdmission Admitted(string intentId, NodeId operation) + { + return new IntentAdmission + { + Accepted = true, + IntentId = intentId, + Operation = operation + }; + } + + /// Creates a refusal. + public static IntentAdmission Refused(IntentFailureEnum failure, string message) + { + return new IntentAdmission { Failure = failure, Message = message }; + } + } + + /// + /// The outcome of admitting one mission. + /// + public sealed record MissionAdmission + { + /// Whether the mission was admitted. + public bool Accepted { get; init; } + + /// The identifier it was admitted under. + public string MissionId { get; init; } = string.Empty; + + /// The Mission that tracks it. + public NodeId Operation { get; init; } = NodeId.Null; + + /// Why it was refused. + public MissionUpdateResultEnum Result { get; init; } = MissionUpdateResultEnum.Accepted; + + /// Human-readable detail on a refusal. + public string? Message { get; init; } + + /// Creates an accepted admission. + public static MissionAdmission Admitted(string missionId, NodeId operation) + { + return new MissionAdmission + { + Accepted = true, + MissionId = missionId, + Operation = operation + }; + } + + /// Creates a refusal. + public static MissionAdmission Refused(MissionUpdateResultEnum result, string message) + { + return new MissionAdmission { Result = result, Message = message }; + } + } + + /// + /// The outcome of a mission update. + /// + /// What happened. + /// Human-readable detail on a refusal. + public sealed record MissionUpdateOutcome(MissionUpdateResultEnum Result, string? Message); + + /// + /// The result of one admission rule: whether it passed and, when it did not, the + /// text a client is told. A StatusCode alone cannot say WHICH parameter was wrong. + /// + /// Whether the rule passed. + /// Why it did not. + internal readonly record struct Check(bool Ok, string? Message) + { + /// A rule that passed. + public static Check Pass { get; } = new(true, null); + + /// A rule that failed. + public static Check Fail(string message) + { + return new Check(false, message); + } + } + + /// + /// The parameter rules of clause 5, applied before an intent is admitted. + /// + internal static class IntentValidation + { + /// + /// A quaternion whose norm differs from 1 by more than this is not a rotation. + /// + private const double OrientationTolerance = 1e-6; + + public static Check Validate(IntentDataType intent, IntentControllerHostOptions options) + { + switch (intent) + { + case JointMoveIntentDataType joint: + if (joint.HasJointTargets) + { + if (joint.JointTargets.IsNull || + joint.JointTargets.Count != (int)options.AxisCount) + { + return Bad($"JointTargets must carry {options.AxisCount} values."); + } + } + else + { + Check pose = ValidatePose(joint.TargetPose, nameof(joint.TargetPose)); + if (!pose.Ok) + { + return pose; + } + } + break; + case LinearMoveIntentDataType linear: + return ValidatePose(linear.Target, nameof(linear.Target)); + case CircularMoveIntentDataType circular: + Check via = ValidatePose(circular.ViaPoint, nameof(circular.ViaPoint)); + return !via.Ok + ? via + : ValidatePose(circular.Target, nameof(circular.Target)); + case PickIntentDataType pick: + return pick.Source.IsNull + ? Bad("Pick requires a Source Location.") + : Check.Pass; + case PlaceIntentDataType place: + return place.Destination.IsNull + ? Bad("Place requires a Destination Location.") + : Check.Pass; + case SetOutputIntentDataType output: + return output.Output.IsNull + ? Bad("SetOutput requires an OutputSignal.") + : Check.Pass; + case CallProgramIntentDataType program: + return program.Program.IsNull + ? Bad("CallProgram requires a Program.") + : Check.Pass; + case TrajectoryIntentDataType trajectory: + return ValidateTrajectory(trajectory, options); + case CartesianPathIntentDataType path: + if (path.Waypoints.IsNull || path.Waypoints.IsEmpty) + { + return Bad("A Cartesian path requires at least one waypoint."); + } + for (int ii = 0; ii < path.Waypoints.Count; ii++) + { + Check wp = ValidatePose(path.Waypoints[ii]?.Pose, $"Waypoints[{ii}].Pose"); + if (!wp.Ok) + { + return wp; + } + } + return Check.Pass; + case ForceIntentDataType force: + if (force.Direction.IsNull || force.Direction.Count != 3) + { + return Bad("ForceIntent.Direction must carry three values."); + } + double magnitude = 0; + for (int ii = 0; ii < 3; ii++) + { + magnitude += force.Direction[ii] * force.Direction[ii]; + } + if (magnitude <= 0) + { + return Bad("ForceIntent.Direction must not be the zero vector."); + } + if (force.ContactForce <= 0) + { + return Bad("ForceIntent.ContactForce must be greater than zero."); + } + return force.MaxDistance <= 0 + ? Bad("ForceIntent.MaxDistance must be greater than zero.") + : Check.Pass; + default: + break; + } + return Check.Pass; + } + + /// + /// A trajectory is handed over whole, so everything about it has to be right + /// at admission: there is no later exchange in which to complain. + /// + private static Check ValidateTrajectory( + TrajectoryIntentDataType trajectory, IntentControllerHostOptions options) + { + if (trajectory.Points.IsNull || trajectory.Points.IsEmpty) + { + return Bad("A trajectory requires at least one point."); + } + if (options.MaxTrajectoryPoints > 0 && + trajectory.Points.Count > (int)options.MaxTrajectoryPoints) + { + return Bad(FormattableString.Invariant( + $"A trajectory may carry at most {options.MaxTrajectoryPoints} points.")); + } + double previous = double.NegativeInfinity; + for (int ii = 0; ii < trajectory.Points.Count; ii++) + { + TrajectoryPointDataType point = trajectory.Points[ii]; + if (point == null) + { + return Bad($"Points[{ii}] is null."); + } + if (point.TimeFromStart <= previous) + { + return Bad($"Points[{ii}].TimeFromStart must exceed its predecessor's."); + } + previous = point.TimeFromStart; + if (point.Positions.IsNull || point.Positions.Count != (int)options.AxisCount) + { + return Bad(FormattableString.Invariant( + $"Points[{ii}].Positions must carry {options.AxisCount} values.")); + } + } + return Check.Pass; + } + + /// + /// Orientation is a UNIT quaternion. A Server that accepted an unnormalised one + /// would be commanding a rotation nobody specified. + /// + private static Check ValidatePose(Pose3DDataType? pose, string name) + { + if (pose == null) + { + return Bad($"{name} is required."); + } + if (pose.Position.IsNull || pose.Position.Count != 3) + { + return Bad($"{name}.Position must carry three values."); + } + if (pose.Orientation.IsNull || pose.Orientation.Count != 4) + { + return Bad($"{name}.Orientation must carry four values."); + } + double norm = 0; + for (int ii = 0; ii < 4; ii++) + { + norm += pose.Orientation[ii] * pose.Orientation[ii]; + } + if (Math.Abs(Math.Sqrt(norm) - 1.0) > OrientationTolerance) + { + return Bad(FormattableString.Invariant( + $"{name}.Orientation must be a unit quaternion; its norm is {Math.Sqrt(norm)}.")); + } + return Check.Pass; + } + + private static Check Bad(string message) + { + return Check.Fail(message); + } + } + + /// + /// The base and horizon rules of clause 7. + /// + internal static class MissionRules + { + /// + /// Steps ascend by SequenceId and carry unique StepIds, and the released ones + /// form a PREFIX - a released step after an unreleased one would make "the + /// base" meaningless. + /// + public static Check ValidateSteps(ArrayOf steps) + { + if (steps.IsNull || steps.IsEmpty) + { + return Check.Fail("A mission must carry at least one step."); + } + var ids = new HashSet(StringComparer.Ordinal); + uint previous = 0; + bool seenHorizon = false; + for (int ii = 0; ii < steps.Count; ii++) + { + MissionStepDataType step = steps[ii]; + if (step == null || step.Intent == null) + { + return Check.Fail("Every mission step must carry an intent."); + } + if (!ids.Add(step.StepId ?? string.Empty)) + { + return Check.Fail($"StepId '{step.StepId}' is not unique within the mission."); + } + if (ii > 0 && step.SequenceId <= previous) + { + return Check.Fail("SequenceId must ascend across the steps of a mission."); + } + previous = step.SequenceId; + if (!step.Released) + { + seenHorizon = true; + } + else if (seenHorizon) + { + return Check.Fail("Released steps must form a prefix: the base cannot follow the horizon."); + } + } + return Check.Pass; + } + + /// + /// The base is committed and may already have executed, so an update that would + /// alter, remove or reorder a released step is refused rather than partly + /// applied. + /// + public static Check ValidateBasePreserved( + ArrayOf current, ArrayOf replacement) + { + uint released = ReleasedCount(current); + if (replacement.IsNull || (uint)replacement.Count < released) + { + return Check.Fail("The update would remove a released step."); + } + for (int ii = 0; ii < (int)released; ii++) + { + MissionStepDataType was = current[ii]; + MissionStepDataType now = replacement[ii]; + if (now == null || + !string.Equals(was.StepId, now.StepId, StringComparison.Ordinal) || + was.SequenceId != now.SequenceId || + !now.Released) + { + return Check.Fail($"The update would alter released step '{was.StepId}'."); + } + } + return Check.Pass; + } + + /// + /// How many steps are in the base. + /// + public static uint ReleasedCount(ArrayOf steps) + { + if (steps.IsNull) + { + return 0; + } + uint count = 0; + for (int ii = 0; ii < steps.Count; ii++) + { + if (steps[ii] is { Released: true }) + { + count++; + } + else + { + break; + } + } + return count; + } + + /// + /// Checks the step graph against clause 7.4. + /// + /// + /// A transition naming a step that does not exist, or a step whose outgoing + /// transitions mix Alternative with Parallel, is refused rather than resolved + /// by guessing: a mission that branches somewhere the author did not intend is + /// worse than one that will not start. + /// + public static Check ValidateTransitions( + ArrayOf steps, ArrayOf transitions) + { + var ids = new HashSet(StringComparer.Ordinal); + for (int ii = 0; ii < steps.Count; ii++) + { + ids.Add(steps[ii]?.StepId ?? string.Empty); + } + + for (int ii = 0; ii < steps.Count; ii++) + { + MissionStepDataType step = steps[ii]; + if (step == null) + { + continue; + } + bool needsFallback = step.ErrorPolicy is ErrorPolicyEnum.Fallback + or ErrorPolicyEnum.Compensate; + if (needsFallback && !ids.Contains(step.FallbackStepId ?? string.Empty)) + { + return Check.Fail( + $"Step '{step.StepId}' declares {step.ErrorPolicy} but its " + + "FallbackStepId names no step of this mission."); + } + } + + if (transitions.IsNull || transitions.IsEmpty) + { + return Check.Pass; + } + + var divergence = new Dictionary(StringComparer.Ordinal); + for (int ii = 0; ii < transitions.Count; ii++) + { + MissionTransitionDataType edge = transitions[ii]; + if (edge == null) + { + return Check.Fail($"Transitions[{ii}] is null."); + } + if (!ids.Contains(edge.FromStepId ?? string.Empty)) + { + return Check.Fail( + $"Transitions[{ii}].FromStepId '{edge.FromStepId}' names no step " + + "of this mission."); + } + if (!ids.Contains(edge.ToStepId ?? string.Empty)) + { + return Check.Fail( + $"Transitions[{ii}].ToStepId '{edge.ToStepId}' names no step " + + "of this mission."); + } + string from = edge.FromStepId ?? string.Empty; + if (divergence.TryGetValue(from, out DivergenceKindEnum seen)) + { + if (seen != edge.DivergenceKind) + { + return Check.Fail( + $"Step '{from}' mixes {seen} and {edge.DivergenceKind} " + + "divergence on its outgoing transitions."); + } + } + else + { + divergence[from] = edge.DivergenceKind; + } + } + return Check.Pass; + } + + /// + /// The first transition out of a step whose condition holds. + /// + /// + /// Evaluated in array order so that two clients reading one mission predict the + /// same branch. An empty ContentFilter is always true, which is what makes a + /// default branch expressible without a special case. + /// + public static MissionTransitionDataType? SelectTransition( + ArrayOf transitions, + string fromStepId, + Func evaluate) + { + if (transitions.IsNull) + { + return null; + } + for (int ii = 0; ii < transitions.Count; ii++) + { + MissionTransitionDataType edge = transitions[ii]; + if (edge != null && + string.Equals(edge.FromStepId, fromStepId, StringComparison.Ordinal) && + evaluate(edge.Condition)) + { + return edge; + } + } + return null; + } + + /// + /// The index of the step with the given identifier, or -1. + /// + public static int IndexOfStep(ArrayOf steps, string stepId) + { + if (steps.IsNull) + { + return -1; + } + for (int ii = 0; ii < steps.Count; ii++) + { + if (string.Equals(steps[ii]?.StepId, stepId, StringComparison.Ordinal)) + { + return ii; + } + } + return -1; + } + + /// + /// The step at the given index, when there is one. + /// + public static MissionStepDataType? NextPending(ArrayOf steps, int index) + { + if (steps.IsNull || index < 0 || index >= steps.Count) + { + return null; + } + return steps[index]; + } + + /// + /// Records a step's reported status. + /// + /// + /// Status is a HINT. Where Operation is not null the IntentOperation's state + /// machine decides, and this keeps the hint faithful to it. + /// + public static void SetStatus( + ArrayOf steps, int index, ExecutionStateEnum state, NodeId? operation) + { + if (steps.IsNull || index < 0 || index >= steps.Count) + { + return; + } + MissionStepDataType step = steps[index]; + if (step == null) + { + return; + } + step.Status = state; + if (operation.HasValue) + { + step.Operation = operation.Value; + } + } + } +} + +namespace Opc.Ua.RobotIntent.Server +{ + /// + /// Reports a per-invocation node that could not be published. + /// + /// The node that could not be added. + /// Why it could not. + public sealed record IntentNodeAddFailure(NodeState Node, Exception Error); +} diff --git a/src/Opc.Ua.Robotics/Intent/IntentContracts.cs b/src/Opc.Ua.Robotics/Intent/IntentContracts.cs new file mode 100644 index 0000000000..8f9d97d6af --- /dev/null +++ b/src/Opc.Ua.Robotics/Intent/IntentContracts.cs @@ -0,0 +1,227 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.RobotIntent +{ + /// + /// Reports the progress of an executing intent back to the address space. + /// + /// + /// Everything here is a status report published at whatever rate a client's + /// Subscription asks for. OPC UA is not a real-time control channel, and the + /// specification excludes servo-level use as a normative limit rather than a + /// caution; see OPC UA - Robot Intent clause 4.3. + /// + public interface IIntentProgress + { + /// + /// Reports the fraction of the intent completed, in the range 0 to 1. + /// A negative value states that the Server cannot estimate it. + /// + void ReportProgress(double fraction); + + /// + /// Reports where the driven tool centre point is now. + /// + void ReportPose(Pose3DDataType pose); + } + + /// + /// What an executor is given when it is asked to carry out one intent. + /// + public sealed class IntentExecution + { + /// + /// Creates an execution context. + /// + public IntentExecution(string intentId, IntentDataType intent, IIntentProgress progress) + { + IntentId = intentId ?? throw new ArgumentNullException(nameof(intentId)); + Intent = intent ?? throw new ArgumentNullException(nameof(intent)); + Progress = progress ?? throw new ArgumentNullException(nameof(progress)); + } + + /// + /// The identifier the intent was admitted under. + /// + public string IntentId { get; } + + /// + /// The intent as admitted, after the Server applied its defaults. + /// + public IntentDataType Intent { get; } + + /// + /// Progress and pose reporting for this execution. + /// + public IIntentProgress Progress { get; } + + /// + /// The mission this intent belongs to, or an empty string when it was + /// submitted on its own. + /// + public string MissionId { get; init; } = string.Empty; + } + + /// + /// How an intent ended. + /// + /// + /// The failure is deliberately a small, diagnosable set: a client decides whether + /// to retry, re-plan or escalate from that value alone, and reads the message only + /// to show a human. + /// + public sealed record IntentOutcome + { + /// + /// The terminal state reached. Only Succeeded, Failed, Cancelled and Retriable + /// are terminal. + /// + public ExecutionStateEnum State { get; init; } = ExecutionStateEnum.Succeeded; + + /// + /// Why it did not succeed, or None. + /// + public IntentFailureEnum Failure { get; init; } = IntentFailureEnum.None; + + /// + /// Human-readable detail. Never parsed. + /// + public string? Message { get; init; } + + /// + /// Where the driven tool centre point came to rest, or was when blending + /// began. Null when the intent moved nothing. + /// + public Pose3DDataType? AchievedPose { get; init; } + + /// + /// Named results the intent produced, for example the identity of a picked + /// object. + /// + public ArrayOf Outputs { get; init; } + + /// + /// A successful outcome that moved nothing. + /// + public static IntentOutcome Success { get; } = new(); + + /// + /// A successful outcome that came to rest at the given pose. + /// + public static IntentOutcome SucceededAt(Pose3DDataType pose) + { + return new IntentOutcome { AchievedPose = pose }; + } + + /// + /// A failed outcome. + /// + public static IntentOutcome Fail(IntentFailureEnum failure, string? message = null) + { + return new IntentOutcome + { + State = ExecutionStateEnum.Failed, + Failure = failure, + Message = message + }; + } + + /// + /// A failed outcome the Server is willing to re-attempt on Retry. + /// + public static IntentOutcome Retriable(IntentFailureEnum failure, string? message = null) + { + return new IntentOutcome + { + State = ExecutionStateEnum.Retriable, + Failure = failure, + Message = message + }; + } + + /// + /// Gets a value indicating whether a state is terminal. + /// + public static bool IsTerminal(ExecutionStateEnum state) + { + return state is ExecutionStateEnum.Succeeded + or ExecutionStateEnum.Failed + or ExecutionStateEnum.Cancelled + or ExecutionStateEnum.Retriable; + } + } + + /// + /// Carries out intents on the robot. + /// + /// + /// The host owns admission, queueing, the state machine, cancellation and the + /// result; an implementation of this interface owns only the doing. Translating an + /// intent into whatever the controller actually executes - URScript, RAPID, KRL, a + /// TP program - is the whole of its job. + /// + public interface IIntentExecutor + { + /// + /// Executes one intent. + /// + /// + /// The cancellation token is signalled when a cancel has been ACCEPTED, which + /// is the point at which the operation enters Cancelling. An implementation + /// brings motion to a controlled end and then returns; it need not return + /// Cancelled, because the host records the cancellation itself. + /// + ValueTask ExecuteAsync( + IntentExecution execution, + CancellationToken cancellationToken); + + /// + /// Decides whether a cancel may be accepted for an intent that is executing. + /// + /// + /// Some motions cannot be abandoned part-way without leaving the cell in a + /// worse state than completing them - a tool change mid-exchange, a placement + /// mid-release. Returning false refuses this one occasion; declaring + /// CancelSupported false in the capability refuses the whole intent type in + /// advance. + /// + /// This has no default implementation because the library targets .NET + /// Framework, which has none, and because whether a motion can be safely + /// abandoned is a decision worth making deliberately. An executor that has no + /// such motions returns true. + /// + /// + bool CanCancel(IntentExecution execution); + } +} diff --git a/src/Opc.Ua.Robotics/Model/Opc.Ua.RobotIntent.NodeSet2.xml b/src/Opc.Ua.Robotics/Model/Opc.Ua.RobotIntent.NodeSet2.xml new file mode 100644 index 0000000000..8f74dbcb9d --- /dev/null +++ b/src/Opc.Ua.Robotics/Model/Opc.Ua.RobotIntent.NodeSet2.xml @@ -0,0 +1,2473 @@ + + + + + http://opcfoundation.org/UA/RobotIntent/ + + + + + + + + i=1 + i=6 + i=7 + i=9 + i=11 + i=12 + i=14 + i=15 + i=17 + i=20 + i=21 + i=294 + i=290 + i=296 + i=887 + i=14533 + i=24 + i=47 + i=46 + i=45 + i=35 + i=40 + i=37 + i=17603 + i=38 + i=78 + i=80 + i=11508 + i=11510 + + + ExecutionStateEnum + Fine-grained execution state of an intent or a mission. This REFINES the Part 10 program state machine rather than restating it: Queued, Cancelling and the three terminal outcomes cannot be told apart from CurrentState alone. Clause 6.3 fixes which ExecutionState may accompany which Part 10 state, and a Server shall satisfy that table. + RobotIntent DataTypes + + i=29 + ns=1;i=3901 + + Admitted and validated, not yet queued or executing.Waiting behind another intent because BufferMode is Buffered or a blending mode. Corresponds to PLCopen Busy without Active.Commanding the robot now.Paused by request; position is retained and execution can resume.A cancel was accepted and the Server is bringing the motion to a controlled end. Not yet terminal.Terminal. Completed as requested.Terminal. Did not complete; Failure carries the reason.Terminal. Ended early because a cancel was accepted.Terminal for now, but the Server can re-attempt it on Retry. A Server that does not offer Retry never enters this state and reports Failed instead. + + + EnumStrings + + i=78 + i=68 + ns=1;i=3001 + + AcceptedQueuedExecutingSuspendedCancellingSucceededFailedCancelledRetriable + + + BufferModeEnum + How a newly submitted intent relates to the one already executing. The values and their meanings are those of PLCopen Motion Control MC_BufferMode, adopted unchanged because every motion runtime already implements them. In all blending modes the robot does not decelerate to a stop at the boundary, and the predecessor reaches Succeeded when blending begins rather than when its target is exactly attained. + RobotIntent DataTypes + + i=29 + ns=1;i=3902 + + Abort what is executing and start immediately. The aborted intent terminates as Cancelled. This is the default.Queue; start when the predecessor succeeds.Blend at the lower of the two boundary speeds.Blend at the predecessor's boundary speed.Blend at the successor's boundary speed.Blend at the higher of the two boundary speeds. + + + EnumStrings + + i=78 + i=68 + ns=1;i=3002 + + AbortingBufferedBlendingLowBlendingPreviousBlendingNextBlendingHigh + + + BlockingModeEnum + Whether an intent tolerates motion and other intents running alongside it. The four values are the two-by-two matrix of VDA 5050 blockingType, adopted because it is the only widely deployed concurrency annotation for robot actions. + RobotIntent DataTypes + + i=29 + ns=1;i=3903 + + Runs in the background; motion may continue and other intents may run concurrently.Motion stops for the duration; other intents may still run.Motion may continue; no other intent may run concurrently.Motion stops and no other intent may run concurrently. The intent has exclusive use of the robot. + + + EnumStrings + + i=78 + i=68 + ns=1;i=3003 + + NoneSoftSingleHard + + + TerminationModeEnum + Whether a motion ends exactly on its target or is blended into the next one. This is the only distinction every vendor expresses identically; the blend magnitude in BlendDataType.Radius is a request, not a guarantee. + RobotIntent DataTypes + + i=29 + ns=1;i=3904 + + Come to rest on the target before the next motion begins. ABB fine, FANUC FINE, Yaskawa PL=0, KUKA no approximation.Round the corner into the next motion without stopping. + + + EnumStrings + + i=78 + i=68 + ns=1;i=3004 + + ExactBlend + + + ReleaseModeEnum + How a held object is given up. + RobotIntent DataTypes + + i=29 + ns=1;i=3905 + + Open the end effector where it is, without placing.Set the object down under control at the target.Retain the object until the Server judges a receiving party has taken it. + + + EnumStrings + + i=78 + i=68 + ns=1;i=3005 + + DropPlaceHandover + + + ApproachModeEnum + Direction from which an end effector approaches an object or a placement. + RobotIntent DataTypes + + i=29 + ns=1;i=3906 + + The Server chooses.Along the tool's own Z axis.From above, in the frame the target is expressed in.Laterally, in the frame the target is expressed in. + + + EnumStrings + + i=78 + i=68 + ns=1;i=3006 + + DefaultToolZTopSide + + + FrameRoleEnum + Role of a coordinate frame, following the coordinate systems ISO 9787 standardises. The roles say WHICH frames exist; no standard says how to calibrate between them, which is why CoordinateFrameType carries the transform explicitly. + RobotIntent DataTypes + + i=29 + ns=1;i=3907 + + The cell-level reference frame.The robot base frame.The flange at the end of the last link, to which an end effector is fitted.A tool frame, whose origin is a tool centre point.A workpiece or work-object frame.A frame whose role is none of the above. + + + EnumStrings + + i=78 + i=68 + ns=1;i=3007 + + WorldBaseMechanicalInterfaceToolObjectOther + + + OperationalModeEnum + Operational mode of the robot system, as defined by ISO 10218-1 and reported identically by OPC 40010-1. It is READ-ONLY here: mode selection is a safety function performed by safety-rated means, and this specification defines no way to command it. Clause 10 restricts intent submission to Automatic and AutomaticExternal. + RobotIntent DataTypes + + i=29 + ns=1;i=3908 + + Booting, uncalibrated, or a safety system fault.Teaching mode with a speed ceiling and a held enabling device.Program verification with an enabling device.Automatic operation with the safeguarded space secured.Automatic operation commanded by an external system. This is the mode this specification is written for. + + + EnumStrings + + i=78 + i=68 + ns=1;i=3008 + + OtherManualReducedSpeedManualHighSpeedAutomaticAutomaticExternal + + + IntentFailureEnum + Why an intent did not succeed. The set is deliberately small and diagnosable: a client decides whether to retry, re-plan or escalate from this value alone, and reads Message only to show a human. + RobotIntent DataTypes + + i=29 + ns=1;i=3909 + + No failure. Reported on a successful outcome.The target lies outside the reachable workspace.No kinematic solution, or a singularity on the path.A collision was predicted or detected.A joint limit would be or was exceeded.The requested speed or acceleration is not permitted in the active mode.The required tool is not fitted or not identified.The object to act on was not present.The object was not acquired, or was lost in transit.The intent did not complete within its permitted time.Refused because the operational mode does not permit it. See clause 10.Refused because the caller does not hold command authority. See clause 8.The Server does not implement this intent type, or this combination of options.A parameter was missing, malformed or out of range.The queue is at MaxQueueDepth.An Aborting submission or a mission update replaced it before it could run.A fault in the robot, the end effector or the controller.A safety function acted. The safety system, not this interface, decided this.A reason none of the above describes; see Message.Refused because the request would exceed a limit the safety system is enforcing. See clause 10.3. + + + EnumStrings + + i=78 + i=68 + ns=1;i=3009 + + NoneUnreachableKinematicsCollisionJointLimitSpeedLimitToolMissingObjectNotFoundGraspFailedTimeoutNotPermittedInModeControlNotOwnedCapabilityNotSupportedParameterInvalidQueueFullSupersededHardwareFaultSafetyStopOtherSafetyLimitExceeded + + + StopModeEnum + How urgently a cancellation should bring motion to an end. The values are those of PossibleStopModes in OPC 40010-1, so a Server implementing both reports one vocabulary. This is an APPLICATION-LEVEL request: it does not select, imply or guarantee any IEC 60204-1 stop category, which only the robot's safety system determines. See clause 10. + RobotIntent DataTypes + + i=29 + ns=1;i=3910 + + Decelerate along the programmed path.Stop when the current cycle completes.Stop at a point the process defines as safe.Decelerate as quickly as the drives allow.Stop when the current instruction completes. + + + EnumStrings + + i=78 + i=68 + ns=1;i=3010 + + OnPathEndOfCycleProcessStopQuickStopEndOfInstruction + + + AxisKindEnum + Whether an axis rotates or translates. This fixes the unit of the corresponding entry of JointMoveIntentDataType.JointTargets. + RobotIntent DataTypes + + i=29 + ns=1;i=3911 + + Rotates. Its joint target is in radians.Translates. Its joint target is in metres. + + + EnumStrings + + i=78 + i=68 + ns=1;i=3011 + + RevolutePrismatic + + + MissionUpdateResultEnum + Outcome of a mission update, reported so a client can tell a stale update from a rejected one without parsing a StatusCode. + RobotIntent DataTypes + + i=29 + ns=1;i=3912 + + The horizon was replaced as requested.MissionUpdateId was not greater than the current one.The update would have altered a released step.No mission with that MissionId is held.Refused for a reason the Server states in a message. + + + EnumStrings + + i=78 + i=68 + ns=1;i=3012 + + AcceptedOutdatedBaseConflictUnknownMissionRejected + + + Pose3DDataType + A rigid-body pose. Position is metres; Orientation is a UNIT QUATERNION ordered (x, y, z, w). Quaternions are used because OPC UA defines no quaternion type and the Euler triple in ThreeDOrientation is ambiguous without an external convention; Annex C gives the normative conversion to and from ThreeDFrame. All frames are right-handed. FrameId names the CoordinateFrame the pose is expressed in; an empty FrameId means the Server's default work frame. + RobotIntent DataTypes + + i=22 + ns=1;i=5001 + + FrameId of the CoordinateFrame this pose is expressed in; empty for the default work frame.Translation (x, y, z) in metres.Unit quaternion (x, y, z, w). + + + Default Binary + Default Binary encoding of the structure. + + i=76 + ns=1;i=3050 + + + + MotionConstraintsDataType + Limits a motion is to respect. Every field is a REQUEST bounded by what the robot is configured to permit: a Server clamps rather than refuses, except where clause 10 requires refusal. A value of zero or less means the field is unspecified and the Server chooses. + RobotIntent DataTypes + + i=22 + ns=1;i=5002 + + Fraction of the configured maximum speed, in the range 0 to 1. This is the portable speed control: every vendor supports it.Tool centre point speed in metres per second. Ignored by joint moves.Tool centre point acceleration in metres per second squared.Rate of change of acceleration in metres per second cubed. + + + Default Binary + Default Binary encoding of the structure. + + i=76 + ns=1;i=3051 + + + + BlendDataType + How a motion ends. Radius is interpreted only when Termination is Blend, and is a request: controllers that expose a unitless blend scale rather than a distance map it as best they can, and a Server that cannot honour the exact radius still succeeds. + RobotIntent DataTypes + + i=22 + ns=1;i=5003 + + Exact stop, or blend into the next motion.Blend radius in metres, measured from the target. Zero or less means the Server chooses. + + + Default Binary + Default Binary encoding of the structure. + + i=76 + ns=1;i=3052 + + + + IntentDataType + Abstract base of every intent. An intent is a single task-level request; it is what a client submits and what a mission step holds, so the two are the same shape. Extension is by SUBTYPING this structure, which keeps new intents discoverable through IntentCapabilitiesType rather than by probing for BrowseNames. + RobotIntent DataTypes + + i=22 + + Client-assigned identifier, unique among the intents the client has outstanding. Empty asks the Server to assign one, which it returns.Human-readable description. Never interpreted.How this intent relates to one already executing.Whether motion and other intents may proceed alongside it. + + + MotionIntentDataType + Abstract base of the intents that move the robot. ToolFrame names the frame whose origin is driven to the target - without it a pose target is meaningless, and OPC 40010-1 defines no tool centre point at all. + RobotIntent DataTypes + + ns=1;i=3053 + + The CoordinateFrame, of role Tool, whose origin is driven to the target. Null means the tool currently fitted.Speed and acceleration limits for this motion.How this motion ends. + + + JointMoveIntentDataType + Move by interpolating in joint space. This is the fastest way between two configurations and the path the tool centre point takes is not controlled. It is the portable equivalent of PTP, MoveJ, J and MOVJ. Giving a pose rather than joint values asks the Server to solve the kinematics itself, which is the 'move to this pose, you choose how' case. + RobotIntent DataTypes + + ns=1;i=3054 + ns=1;i=5004 + + True when JointTargets is meaningful; False when TargetPose is.One value per axis, in the order the axes are declared under the controller: radians for a Revolute axis, metres for a Prismatic one.Pose to reach, used when HasJointTargets is False. + + + Default Binary + Default Binary encoding of the structure. + + i=76 + ns=1;i=3055 + + + + LinearMoveIntentDataType + Move the tool centre point along a straight line to the target. The portable equivalent of LIN, MoveL, L and MOVL. + RobotIntent DataTypes + + ns=1;i=3054 + ns=1;i=5005 + + Pose to reach. + + + Default Binary + Default Binary encoding of the structure. + + i=76 + ns=1;i=3056 + + + + CircularMoveIntentDataType + Move the tool centre point along the circular arc that passes through ViaPoint and ends at Target. The portable equivalent of CIRC, MoveC, C and MOVC. Only the position of ViaPoint defines the arc; its orientation is ignored. + RobotIntent DataTypes + + ns=1;i=3054 + ns=1;i=5006 + + A pose on the arc between the start and the target. Only its position is used.Pose to reach. + + + Default Binary + Default Binary encoding of the structure. + + i=76 + ns=1;i=3057 + + + + GraspIntentDataType + Close the end effector on an object. Force and Width are requests; an end effector that cannot regulate force ignores Force and still succeeds. + RobotIntent DataTypes + + ns=1;i=3053 + ns=1;i=5007 + + The Tool to actuate. Null means the tool currently fitted.Grasp force in newtons. Zero or less means the Server chooses.Opening at which to close, in metres. Zero or less means the Server chooses.Direction of approach. + + + Default Binary + Default Binary encoding of the structure. + + i=76 + ns=1;i=3058 + + + + ReleaseIntentDataType + Give up a held object. + RobotIntent DataTypes + + ns=1;i=3053 + ns=1;i=5008 + + The Tool to actuate. Null means the tool currently fitted.How the object is given up.True when Target is meaningful.Where to place the object, when Mode is Place. + + + Default Binary + Default Binary encoding of the structure. + + i=76 + ns=1;i=3059 + + + + PickIntentDataType + Take an object from a location. Source is a REFERENCE TO A LOCATION NODE, not a name: the location's pose and its properties are then read from the address space, so the station identity has exactly one definition. + RobotIntent DataTypes + + ns=1;i=3053 + ns=1;i=5009 + + The Location to pick from.The Tool to use. Null means the tool currently fitted.What to pick, when the location can hold more than one kind. Empty means whatever is there.Grasp force in newtons. Zero or less means the Server chooses.Direction of approach.Further named parameters the Server declares in its capabilities. + + + Default Binary + Default Binary encoding of the structure. + + i=76 + ns=1;i=3060 + + + + PlaceIntentDataType + Put a held object at a location. Destination is a reference to a Location node, for the same reason as PickIntentDataType.Source. + RobotIntent DataTypes + + ns=1;i=3053 + ns=1;i=5010 + + The Location to place at.The Tool to use. Null means the tool currently fitted.Direction of approach.Further named parameters the Server declares in its capabilities. + + + Default Binary + Default Binary encoding of the structure. + + i=76 + ns=1;i=3061 + + + + ToolChangeIntentDataType + Exchange the fitted end effector. + RobotIntent DataTypes + + ns=1;i=3053 + ns=1;i=5011 + + The Tool to fit. Null means release the fitted tool and fit nothing.The Location of the tool changer. Null lets the Server choose. + + + Default Binary + Default Binary encoding of the structure. + + i=76 + ns=1;i=3062 + + + + SetOutputIntentDataType + Set a discrete or analogue output. Output references an OutputSignal node, so the signal's meaning, range and unit are described once in the address space instead of being implied by a string. + RobotIntent DataTypes + + ns=1;i=3053 + ns=1;i=5012 + + The OutputSignal to write.Value to write, of the signal's own DataType. + + + Default Binary + Default Binary encoding of the structure. + + i=76 + ns=1;i=3063 + + + + CallProgramIntentDataType + Run a program that already exists on the controller. This is the escape hatch for capability this specification does not model, and the bridge to the OPC 40010-1 task control surface. + RobotIntent DataTypes + + ns=1;i=3053 + ns=1;i=5013 + + The Program to run.Named arguments for the program. + + + Default Binary + Default Binary encoding of the structure. + + i=76 + ns=1;i=3064 + + + + WaitIntentDataType + Do nothing for a while, or until released. A mission needs this to express a rendezvous with something the robot does not control; without it a client has to hold the queue open from outside. + RobotIntent DataTypes + + ns=1;i=3053 + ns=1;i=5014 + + How long to wait, in milliseconds. Zero or less waits until Signal is released.An OutputSignal or other node whose becoming true ends the wait. Null waits only for Duration. + + + Default Binary + Default Binary encoding of the structure. + + i=76 + ns=1;i=3065 + + + + IntentResultDataType + The outcome of one intent, preserved after it terminates. AchievedPose records where the tool centre point actually ended, which is what lets a client tell a blended corner from an exact stop and audit a placement. + RobotIntent DataTypes + + i=22 + ns=1;i=5015 + + The intent this describes.Terminal state reached.Reason, or None on success.Human-readable detail. Never parsed.True when AchievedPose is meaningful.Where the tool centre point came to rest, or was when blending began.When execution began.When the terminal state was reached.Named results the intent produced, for example the identity of a picked object. + + + Default Binary + Default Binary encoding of the structure. + + i=76 + ns=1;i=3066 + + + + MissionStepDataType + One step of a mission. Released is what splits a mission into its immutable base and its revisable horizon: a released step has been committed and may already be executing, so an update may not touch it. Status is a HINT - where Operation is not null, that IntentOperation's state machine decides. + RobotIntent DataTypes + + i=22 + ns=1;i=5016 + + Identifier unique within the mission.Execution order within the mission, ascending.True for a base step, which is committed and immutable. False for a horizon step, which an update may replace or remove.The intent this step executes.Reported status. Where Operation is not null its state machine is authoritative and this reflects it.The IntentOperation executing this step, or null while the step has not begun.What the mission does when this step does not succeed.The step to continue at, when ErrorPolicy is Fallback or Compensate. + + + Default Binary + Default Binary encoding of the structure. + + i=76 + ns=1;i=3067 + + + + MissionDataType + An ordered sequence of intents submitted and tracked as a unit. MissionUpdateId increases with every update, so a Server can reject an update that crossed with another in flight instead of applying it out of order. + RobotIntent DataTypes + + i=22 + ns=1;i=5017 + + Client-assigned identifier. Empty asks the Server to assign one, which it returns.Revision of this mission, increasing. The first submission is 0.Human-readable description. Never interpreted.The steps, in ascending SequenceId order.The step graph. An empty array means the steps run in order, which is the flat sequence a mission without branching has always been. + + + Default Binary + Default Binary encoding of the structure. + + i=76 + ns=1;i=3068 + + + + IntentCapabilityDataType + What the Server will accept for one intent type. This is the machine-readable declaration that makes an intent surface discoverable: a client reads it once and knows what it may submit, instead of submitting to find out. It is the analogue of the VDA 5050 factsheet. + RobotIntent DataTypes + + i=22 + ns=1;i=5018 + + The DataType of the intent, a subtype of IntentDataType.What this Server does with it.True when Cancel is honoured for it. A Server may still refuse a particular cancel; see clause 6.5.True when Pause and Resume are honoured.True when it can terminate Retriable and be re-attempted.Buffer modes accepted for it. Aborting is always accepted and always listed.Blocking modes accepted for it.Named parameters this Server recognises in the intent's Attributes field. + + + Default Binary + Default Binary encoding of the structure. + + i=76 + ns=1;i=3069 + + + + HasIntentController + Binds an intent surface to the thing it commands. This is how the model attaches to a robot described by another specification - an OPC 40010-1 MotionDeviceSystem, say - without depending on it. Annex B defines that binding. + RobotIntent ReferenceTypes + IntentControllerOf + + i=32 + + + + HasFrameParent + From a CoordinateFrame to the frame its Transform is expressed in. Frames form a tree, so a pose given in one frame can be re-expressed in another by composing the transforms along the path between them. + RobotIntent ReferenceTypes + FrameParentOf + + i=32 + + + + RobotIntentRootType + Server-level entry point. A client that has just connected browses here to find every robot it can command, without knowing the Server's layout. + RobotIntent + + i=58 + ns=1;i=6001 + ns=1;i=6002 + + + + Controllers + The intent surfaces this Server offers, one per commandable robot. + + i=78 + i=61 + ns=1;i=1001 + + + + SpecificationVersion + Release of this specification the Server implements, for example '0.1.0'. + + i=78 + i=68 + ns=1;i=1001 + + + + IntentControllerType + The intent surface for one robot: what it can be asked to do, the frames and objects those requests refer to, and the intents and missions currently outstanding. Everything a client needs in order to command the robot hangs from here. + RobotIntent + + i=58 + ns=1;i=6003 + ns=1;i=6004 + ns=1;i=6005 + ns=1;i=6006 + ns=1;i=6007 + ns=1;i=6008 + ns=1;i=6009 + ns=1;i=6010 + ns=1;i=6011 + ns=1;i=6012 + ns=1;i=6013 + ns=1;i=6014 + ns=1;i=6015 + ns=1;i=6016 + ns=1;i=6017 + ns=1;i=6018 + ns=1;i=6020 + ns=1;i=6021 + ns=1;i=6024 + ns=1;i=6027 + ns=1;i=6030 + ns=1;i=6032 + ns=1;i=6034 + ns=1;i=6037 + ns=1;i=6040 + ns=1;i=6043 + ns=1;i=6123 + ns=1;i=6124 + ns=1;i=6125 + ns=1;i=6126 + ns=1;i=6129 + + + + OperationalMode + Operational mode reported by the robot. Read-only: mode selection is a safety function and this specification defines no way to command it. Clause 10 restricts submission to Automatic and AutomaticExternal. + + i=78 + i=68 + ns=1;i=1002 + + + + Ready + True when the robot will accept intents now. A client checks this before submitting rather than inferring readiness from OperationalMode alone. + + i=78 + i=68 + ns=1;i=1002 + + + + ControlOwner + SessionId of the client that currently holds command authority, or null when no client does. Only that client may submit; see clause 8. + + i=78 + i=68 + ns=1;i=1002 + + + + MaxQueueDepth + How many intents may be queued behind the executing one. Zero means the Server accepts only Aborting submissions. + + i=78 + i=68 + ns=1;i=1002 + + + + ActiveIntent + The IntentOperation executing now, or null. + + i=78 + i=63 + ns=1;i=1002 + + + + ActiveMission + The Mission executing now, or null. + + i=80 + i=63 + ns=1;i=1002 + + + + Capabilities + What this robot will accept. + + i=78 + ns=1;i=1005 + ns=1;i=1002 + + + + Frames + The coordinate frames poses may be expressed in. + + i=78 + i=61 + ns=1;i=1002 + + + + Tools + The end effectors this robot can use. + + i=78 + i=61 + ns=1;i=1002 + + + + Locations + The named places intents refer to. + + i=78 + i=61 + ns=1;i=1002 + + + + Axes + The axes, in the order JointMoveIntentDataType.JointTargets uses. + + i=78 + i=61 + ns=1;i=1002 + + + + Outputs + The signals SetOutput can write. + + i=80 + i=61 + ns=1;i=1002 + + + + Programs + The controller programs CallProgram can run. + + i=80 + i=61 + ns=1;i=1002 + + + + Intents + Outstanding and recently completed intents, one Object each. + + i=78 + i=61 + ns=1;i=1002 + + + + Missions + Outstanding and recently completed missions, one Object each. + + i=80 + i=61 + ns=1;i=1002 + + + + RequestControl + Take command authority. A Server grants it only when no other Session holds it, or when the holder's Session has closed. Holding authority is a precondition for submitting, and exists so that two clients cannot interleave motion; it is NOT the single point of control that ISO 10218-2 requires, which is enforced by safety-rated means outside this interface. + + i=78 + ns=1;i=1002 + ns=1;i=6019 + + + + OutputArguments + + i=78 + i=68 + ns=1;i=6018 + + i=297Grantedi=1-1True when the caller now holds authority.i=297CurrentOwneri=17-1SessionId of the holder after the call. + + + ReleaseControl + Give up command authority. Outstanding intents are unaffected; use CancelAll to stop them. + + i=78 + ns=1;i=1002 + + + + SubmitIntent + Submit one intent. Returns as soon as the intent is admitted - NOT when the robot has finished, which may be minutes later. The returned Operation is a node the client subscribes to for progress and reads for the result. This is the whole reason the model is built on the Part 10 program lifecycle: an OPC UA Call cannot stay open for the duration of a motion. + + i=78 + ns=1;i=1002 + ns=1;i=6022 + ns=1;i=6023 + + + + InputArguments + + i=78 + i=68 + ns=1;i=6021 + + i=297Intentns=1;i=3053-1The intent to execute. + + + OutputArguments + + i=78 + i=68 + ns=1;i=6021 + + i=297IntentIdi=12-1Identifier of the intent, assigned by the Server when the request left it empty.i=297Operationi=17-1The IntentOperation that tracks it. + + + CancelIntent + Ask the Server to end an intent early. The Server MAY refuse, and says so in Accepted, because some motions cannot be abandoned safely part-way. This is not the OPC UA Cancel Service, which discards a pending response and leaves the robot moving; see clause 6.5. + + i=78 + ns=1;i=1002 + ns=1;i=6025 + ns=1;i=6026 + + + + InputArguments + + i=78 + i=68 + ns=1;i=6024 + + i=297IntentIdi=12-1The intent to cancel.i=297StopModens=1;i=3010-1How urgently to stop. + + + OutputArguments + + i=78 + i=68 + ns=1;i=6024 + + i=297Acceptedi=1-1True when the Server will act on it. + + + CancelAll + Ask the Server to end every outstanding intent and mission. + + i=78 + ns=1;i=1002 + ns=1;i=6028 + ns=1;i=6029 + + + + InputArguments + + i=78 + i=68 + ns=1;i=6027 + + i=297StopModens=1;i=3010-1How urgently to stop. + + + OutputArguments + + i=78 + i=68 + ns=1;i=6027 + + i=297Cancelledi=7-1How many were acted on. + + + Pause + Suspend execution, retaining position so it can be resumed. + + i=80 + ns=1;i=1002 + ns=1;i=6031 + + + + OutputArguments + + i=78 + i=68 + ns=1;i=6030 + + i=297Acceptedi=1-1True when execution is suspending. + + + Resume + Continue execution suspended by Pause. + + i=80 + ns=1;i=1002 + ns=1;i=6033 + + + + OutputArguments + + i=78 + i=68 + ns=1;i=6032 + + i=297Acceptedi=1-1True when execution is resuming. + + + Retry + Re-attempt an intent that terminated Retriable. + + i=80 + ns=1;i=1002 + ns=1;i=6035 + ns=1;i=6036 + + + + InputArguments + + i=78 + i=68 + ns=1;i=6034 + + i=297IntentIdi=12-1The intent to re-attempt. + + + OutputArguments + + i=78 + i=68 + ns=1;i=6034 + + i=297Operationi=17-1The IntentOperation that tracks the new attempt. + + + SubmitMission + Submit an ordered sequence of intents as one unit. Steps marked Released form the base and are committed; the rest form the horizon and may still be revised by UpdateMission. + + i=80 + ns=1;i=1002 + ns=1;i=6038 + ns=1;i=6039 + + + + InputArguments + + i=78 + i=68 + ns=1;i=6037 + + i=297Missionns=1;i=3068-1The mission to execute. + + + OutputArguments + + i=78 + i=68 + ns=1;i=6037 + + i=297MissionIdi=12-1Identifier of the mission, assigned by the Server when the request left it empty.i=297Operationi=17-1The Mission that tracks it. + + + UpdateMission + Replace the horizon of a mission already submitted. The base is untouchable: it has been committed and may already have executed, so an update that would alter a released step is refused rather than partly applied. + + i=80 + ns=1;i=1002 + ns=1;i=6041 + ns=1;i=6042 + + + + InputArguments + + i=78 + i=68 + ns=1;i=6040 + + i=297MissionIdi=12-1The mission to update.i=297MissionUpdateIdi=7-1Revision of the update. Must be greater than the mission's current value.i=297Stepsns=1;i=306710The steps that replace the horizon. + + + OutputArguments + + i=78 + i=68 + ns=1;i=6040 + + i=297Resultns=1;i=3012-1Outcome of the update.i=297Messagei=21-1Human-readable detail on a refusal. + + + CancelMission + Ask the Server to end a mission and every intent belonging to it. + + i=80 + ns=1;i=1002 + ns=1;i=6044 + ns=1;i=6045 + + + + InputArguments + + i=78 + i=68 + ns=1;i=6043 + + i=297MissionIdi=12-1The mission to cancel.i=297StopModens=1;i=3010-1How urgently to stop. + + + OutputArguments + + i=78 + i=68 + ns=1;i=6043 + + i=297Acceptedi=1-1True when the Server will act on it. + + + IntentOperationType + One submitted intent, tracked to completion. It is a Part 10 program instance, so its lifecycle is the one OPC UA already defines for work that outlives a service call: transitions raise ProgramTransitionEvents, the terminal result survives in FinalResultData, and ProgramDiagnostic2DataType records which Session commanded it without this specification having to model provenance itself. + RobotIntent + + i=2391 + ns=1;i=6046 + ns=1;i=6047 + ns=1;i=6048 + ns=1;i=6049 + ns=1;i=6050 + ns=1;i=6051 + ns=1;i=6052 + ns=1;i=6053 + + + + IntentId + Identifier of the intent this instance executes. + + i=78 + i=68 + ns=1;i=1003 + + + + Intent + The intent as admitted, after the Server applied its defaults. A client reads this to learn what it actually asked for. + + i=78 + i=63 + ns=1;i=1003 + + + + ExecutionState + Fine-grained state. It refines CurrentState rather than restating it; clause 6.3 fixes which pairs are legal. + + i=78 + i=68 + ns=1;i=1003 + + + + Progress + Fraction of the intent completed, 0 to 1, where the Server can estimate it. Negative means it cannot. + + i=80 + i=68 + ns=1;i=1003 + + + + CurrentPose + Where the driven tool centre point is now. Subscribe for tracking; this is a status report at the sampling rate the client asks for, not a control signal, and clause 4.3 explains why it must not be used as one. + + i=80 + i=63 + ns=1;i=1003 + + + + Result + Outcome, meaningful once ExecutionState is terminal. The same value is placed under FinalResultData so a Part 10 client finds it where Part 10 says it will be. + + i=78 + i=63 + ns=1;i=1003 + + + + MissionId + The mission this intent belongs to, or empty when it was submitted alone. + + i=80 + i=68 + ns=1;i=1003 + + + + QueuePosition + Place in the queue while ExecutionState is Queued, 1 being next. Zero once it is no longer queued. + + i=80 + i=68 + ns=1;i=1003 + + + + MissionType + One submitted mission, tracked to completion. It is a Part 10 program instance for the same reasons an IntentOperation is, and it owns the IntentOperations of its steps. + RobotIntent + + i=2391 + ns=1;i=6054 + ns=1;i=6055 + ns=1;i=6056 + ns=1;i=6057 + ns=1;i=6058 + ns=1;i=6059 + + + + MissionId + Identifier of the mission this instance executes. + + i=78 + i=68 + ns=1;i=1004 + + + + MissionUpdateId + Revision currently in force. + + i=78 + i=68 + ns=1;i=1004 + + + + Mission + The mission as it now stands, base and horizon together. + + i=78 + i=63 + ns=1;i=1004 + + + + ExecutionState + Fine-grained state, refining CurrentState as clause 6.3 fixes. + + i=78 + i=68 + ns=1;i=1004 + + + + CurrentStepId + The step executing now, or empty. + + i=78 + i=68 + ns=1;i=1004 + + + + ReleasedStepCount + How many steps are in the base. The first this many steps of Mission.Steps are committed; the rest are the horizon. + + i=78 + i=68 + ns=1;i=1004 + + + + IntentCapabilitiesType + What one robot will accept. A client reads this once, before it submits anything, and knows what the robot can do and under what constraints. + RobotIntent + + i=58 + ns=1;i=6060 + ns=1;i=6061 + ns=1;i=6062 + ns=1;i=6063 + ns=1;i=6064 + ns=1;i=6065 + ns=1;i=6132 + ns=1;i=6133 + ns=1;i=6134 + ns=1;i=6135 + ns=1;i=6136 + + + + SupportedIntents + One entry per intent type this Server accepts. + + i=78 + i=63 + ns=1;i=1005 + + + + MissionsSupported + True when SubmitMission is implemented. + + i=78 + i=68 + ns=1;i=1005 + + + + MissionHorizonSupported + True when UpdateMission can revise the horizon of a running mission. + + i=78 + i=68 + ns=1;i=1005 + + + + BlendingSupported + True when the blending buffer modes actually blend. A Server that treats them as Buffered reports False, so a client is not misled about the path. + + i=78 + i=68 + ns=1;i=1005 + + + + MaxBlendRadius + Largest blend radius the robot will honour, in metres. Zero or less means it is not bounded here. + + i=80 + i=68 + ns=1;i=1005 + + + + AxisCount + How many axes JointMoveIntentDataType.JointTargets must carry. + + i=78 + i=68 + ns=1;i=1005 + + + + CoordinateFrameType + A named right-handed Cartesian frame. Frames form a tree through HasFrameParent, so a client can compose a chain from a tool frame to a world frame. Roles follow ISO 9787, which standardises which frames exist; the transform between them is carried explicitly because no standard says how to calibrate it. + RobotIntent + + i=58 + ns=1;i=6066 + ns=1;i=6067 + ns=1;i=6068 + + + + FrameId + Identifier referenced by Pose3DDataType.FrameId. Unique among the frames of one controller. + + i=78 + i=68 + ns=1;i=1006 + + + + Role + Role of this frame. + + i=78 + i=68 + ns=1;i=1006 + + + + Transform + Pose of this frame within its parent. Ignored for a root frame. + + i=80 + i=63 + ns=1;i=1006 + + + + ToolType + An end effector. Its tool centre point is a CoordinateFrame of role Tool, which is what a motion intent drives to a target - OPC 40010-1 models robot topology in detail but has no tool centre point at all, so this specification supplies one. + RobotIntent + + i=58 + ns=1;i=6069 + ns=1;i=6070 + ns=1;i=6071 + ns=1;i=6072 + ns=1;i=6073 + ns=1;i=6074 + ns=1;i=6075 + + + + ToolId + Identifier unique within the controller. + + i=78 + i=68 + ns=1;i=1007 + + + + Name + Human-readable name. + + i=78 + i=68 + ns=1;i=1007 + + + + Fitted + True when this tool is on the robot now. + + i=78 + i=68 + ns=1;i=1007 + + + + TcpFrame + The CoordinateFrame, of role Tool, that is this tool's centre point. + + i=78 + i=68 + ns=1;i=1007 + + + + Mass + Mass in kilograms. Zero or less when not stated. + + i=80 + i=68 + ns=1;i=1007 + + + + MaxGraspForce + Largest grasp force in newtons, or zero when the tool does not grasp. + + i=80 + i=68 + ns=1;i=1007 + + + + MaxOpening + Largest opening in metres, or zero when the tool does not grasp. + + i=80 + i=68 + ns=1;i=1007 + + + + LocationType + A named place an intent can refer to. Pick and Place reference these nodes rather than naming a station in a string, so a location has one definition that a client can read, subscribe to and reason about. + RobotIntent + + i=58 + ns=1;i=6076 + ns=1;i=6077 + ns=1;i=6078 + ns=1;i=6079 + ns=1;i=6080 + ns=1;i=6081 + + + + LocationId + Identifier unique within the controller. + + i=78 + i=68 + ns=1;i=1008 + + + + Name + Human-readable name. + + i=78 + i=68 + ns=1;i=1008 + + + + Pose + Where the location is. + + i=78 + i=63 + ns=1;i=1008 + + + + Occupied + True when the Server believes something is there. + + i=80 + i=68 + ns=1;i=1008 + + + + ObjectClass + What the location holds, when it holds one kind of thing. Empty otherwise. + + i=80 + i=68 + ns=1;i=1008 + + + + Capacity + How many objects it can hold. Zero means unstated. + + i=80 + i=68 + ns=1;i=1008 + + + + AxisType + One axis of the robot. The order of these nodes under the controller's Axes folder fixes the order of JointMoveIntentDataType.JointTargets, and Kind fixes each entry's unit. + RobotIntent + + i=58 + ns=1;i=6082 + ns=1;i=6083 + ns=1;i=6084 + ns=1;i=6085 + ns=1;i=6086 + ns=1;i=6087 + ns=1;i=6088 + + + + AxisId + Identifier unique within the controller. + + i=78 + i=68 + ns=1;i=1009 + + + + Index + Position in JointTargets, counting from zero. + + i=78 + i=68 + ns=1;i=1009 + + + + Kind + Whether it rotates or translates, which fixes the unit of its joint target. + + i=78 + i=68 + ns=1;i=1009 + + + + MinPosition + Lower limit, in radians or metres by Kind. + + i=78 + i=68 + ns=1;i=1009 + + + + MaxPosition + Upper limit, in radians or metres by Kind. + + i=78 + i=68 + ns=1;i=1009 + + + + MaxSpeed + Largest speed, in radians or metres per second by Kind. + + i=80 + i=68 + ns=1;i=1009 + + + + Position + Where the axis is now, in radians or metres by Kind. + + i=80 + i=63 + ns=1;i=1009 + + + + OutputSignalType + A signal SetOutput can write. Modelling it as a node means the range, the unit and the meaning are described once, instead of every client having to know what a line name implies. + RobotIntent + + i=58 + ns=1;i=6089 + ns=1;i=6090 + ns=1;i=6091 + ns=1;i=6092 + ns=1;i=6093 + + + + SignalId + Identifier unique within the controller. + + i=78 + i=68 + ns=1;i=1010 + + + + Name + Human-readable name. + + i=78 + i=68 + ns=1;i=1010 + + + + Value + Current value. + + i=78 + i=63 + ns=1;i=1010 + + + + Writable + True when SetOutput may write it. + + i=78 + i=68 + ns=1;i=1010 + + + + EngineeringUnits + Unit of an analogue signal. + + i=80 + i=68 + ns=1;i=1010 + + + + ProgramType + A program held on the controller that CallProgram can run. This is the bridge to capability this specification does not model, and to the programs an OPC 40010-1 task control already exposes. + RobotIntent + + i=58 + ns=1;i=6094 + ns=1;i=6095 + ns=1;i=6096 + ns=1;i=6097 + + + + ProgramId + Identifier unique within the controller. + + i=78 + i=68 + ns=1;i=1011 + + + + Name + Human-readable name. + + i=78 + i=68 + ns=1;i=1011 + + + + Description + What the program does. + + i=80 + i=68 + ns=1;i=1011 + + + + Parameters + Named parameters it accepts, with their default values. + + i=80 + i=63 + ns=1;i=1011 + + + + RobotIntent + Entry point for robot intent on this Server. A client browses Server/RobotIntent/Controllers to find every robot it can command. + + ns=1;i=1001 + i=2253 + + + + SafeMotionFunctionEnum + The safe motion function a safety system is enforcing, as defined by IEC 61800-5-2. This is a REPORT. The safety system enforces these independently of this interface, and a client reading them has not thereby obtained any safety function - see clause 10. + RobotIntent DataTypes + + i=29 + ns=1;i=3913 + + No safe motion function is active.Safe Torque Off: torque is removed.Safe Stop 1: a controlled ramp to standstill, then Safe Torque Off.Safe Stop 2: a controlled ramp to standstill, which is then held under power.Safe Operating Stop: standstill is monitored while the drive remains energised.Safely Limited Speed: speed is monitored against a limit.Safely Limited Position: position is monitored against a limit.Safe Direction: motion is permitted in one direction only.Safe Brake Control: a brake is commanded safely. + + + EnumStrings + + i=78 + i=68 + ns=1;i=3013 + + NoneStoSs1Ss2SosSlsSlpSdiSbc + + + RealTimeTransportEnum + The transport of a brokered real-time channel. This specification defines none of these: it describes them so a client can find and open one, and the samples never traverse this interface. + RobotIntent DataTypes + + i=29 + ns=1;i=3914 + + Universal Robots Real-Time Data Exchange.ABB Externally Guided Motion.KUKA Fast Research Interface.KUKA Robot Sensor Interface.Yaskawa MotoROS2.OPC UA FX (OPC 10000-80 to -84). The open path, and the only one in this list that is an OPC Foundation specification.A transport identified by the channel's own descriptor. + + + EnumStrings + + i=78 + i=68 + ns=1;i=3014 + + RtdeEgmFriRsiMotoRos2OpcUaFxOther + + + ChannelInitiatorEnum + Which end opens the transport connection of a brokered channel. Getting this wrong is the usual reason a first connection attempt fails, so it is stated rather than left to the reader. + RobotIntent DataTypes + + i=29 + ns=1;i=3915 + + The Server connects to an endpoint the client is listening on.The client connects to the endpoint the Server publishes. + + + EnumStrings + + i=78 + i=68 + ns=1;i=3015 + + ServerClient + + + ErrorPolicyEnum + What a mission does when one of its steps does not succeed. Without this a mission can only abort, which forces every recovery out into the client. + RobotIntent DataTypes + + i=29 + ns=1;i=3916 + + End the mission. This is the default and the behaviour of a mission that declares no policy.Re-attempt the step. A Server bounds the attempts and reports Failed when they are exhausted.Record the failure and continue with the next step.Continue at FallbackStepId instead.Run the fallback step to undo the work already done, then end the mission. + + + EnumStrings + + i=78 + i=68 + ns=1;i=3016 + + AbortRetrySkipFallbackCompensate + + + DivergenceKindEnum + How the transitions leaving one step relate to each other, following the divergence of an IEC 61131-3 sequential function chart. + RobotIntent DataTypes + + i=29 + ns=1;i=3917 + + Exactly one transition is taken - the first whose condition holds. An OR divergence.Every transition is taken and the branches run concurrently. An AND divergence. + + + EnumStrings + + i=78 + i=68 + ns=1;i=3017 + + AlternativeParallel + + + WeaveShapeEnum + The oscillation applied across an arc weld seam. + RobotIntent DataTypes + + i=29 + ns=1;i=3918 + + No weave.Sinusoidal oscillation.Triangular oscillation.Trapezoidal oscillation, with a dwell at each edge. + + + EnumStrings + + i=78 + i=68 + ns=1;i=3018 + + NoneSineZigzagTrapezoid + + + TrajectoryPointDataType + One point of a time-parameterised path. Positions are per axis in the order the axes are declared, in radians or metres by AxisKind. TimeFromStart is measured from the start of the trajectory, which is what makes the path a trajectory rather than a list of waypoints. Velocities and Accelerations are optional and may be empty. + RobotIntent DataTypes + + i=22 + ns=1;i=5019 + + Milliseconds from the start of the trajectory.One value per axis.One value per axis, or empty.One value per axis, or empty. + + + Default Binary + Default Binary encoding of the structure. + + i=76 + ns=1;i=3070 + + + + MotionToleranceDataType + How far execution may deviate before it is a failure. A tolerance of zero or less means the Server applies its own. + RobotIntent DataTypes + + i=22 + ns=1;i=5020 + + Positional tolerance in metres.Orientation tolerance in radians.Timing tolerance in milliseconds. + + + Default Binary + Default Binary encoding of the structure. + + i=76 + ns=1;i=3071 + + + + TrajectoryIntentDataType + Execute a time-parameterised path. The Server's own motion kernel runs it; this interface hands the whole trajectory over in one submission and does not stream it. That is what makes trajectory execution expressible here when real-time control is not - see clause 4.3. + RobotIntent DataTypes + + ns=1;i=3054 + ns=1;i=5021 + + The trajectory, in ascending TimeFromStart order.Permitted deviation while following the path.Permitted deviation at the final point.How much later than the final point's TimeFromStart completion may be, in milliseconds. + + + Default Binary + Default Binary encoding of the structure. + + i=76 + ns=1;i=3072 + + + + PathWaypointDataType + One waypoint of a Cartesian path, with the blend that applies at it. Per-waypoint blending is what distinguishes a path from a sequence of separate linear moves: the robot need not stop between them. + RobotIntent DataTypes + + i=22 + ns=1;i=5022 + + Where the tool centre point passes.How this waypoint is left. + + + Default Binary + Default Binary encoding of the structure. + + i=76 + ns=1;i=3073 + + + + CartesianPathIntentDataType + Follow a list of Cartesian waypoints. This is the portable form of a taught path, and unlike a trajectory it carries no timing: the Server paces it from the motion constraints. + RobotIntent DataTypes + + ns=1;i=3054 + ns=1;i=5023 + + The path, in order. + + + Default Binary + Default Binary encoding of the structure. + + i=76 + ns=1;i=3074 + + + + ForceIntentDataType + Move until contact. The portable subset of the force-controlled moves every vendor offers: travel along a direction until a contact force is reached or a distance is exhausted, whichever comes first. Reaching the distance without contact is a failure, because the intent was to touch something. + RobotIntent DataTypes + + ns=1;i=3054 + ns=1;i=5024 + + Unit direction of travel (x, y, z) in the frame of the target.The CoordinateFrame Direction is expressed in; empty for the default work frame.The force in newtons at which contact is declared.How far to travel before giving up, in metres.True to keep pressing at ContactForce after contact rather than stopping. + + + Default Binary + Default Binary encoding of the structure. + + i=76 + ns=1;i=3075 + + + + ProcessIntentDataType + Abstract base of the intents that run an application process along a path. Every process needs the same two things beyond its own parameters: a reference to the process program or procedure the equipment holds, and room for the parameters this specification has not standardised. + RobotIntent DataTypes + + ns=1;i=3054 + + The Program or equipment-side procedure to run, or null when the parameters here are sufficient.Further named parameters the Server declares in its capabilities. + + + ArcWeldIntentDataType + Lay an arc weld along the path. The parameters are the subset ABB seamdata/welddata/weavedata, FANUC weld schedules and KUKA ArcTech all carry. WeldProcedureRef points at a welding procedure specification (ISO 15609) where the installation works to one; this specification does not restate its content. + RobotIntent DataTypes + + ns=1;i=3076 + ns=1;i=5025 + + Arc voltage in volts.Wire feed speed in metres per minute.Tool centre point speed in metres per second.Shielding gas pre-flow, in milliseconds.Shielding gas post-flow, in milliseconds.Delay before travel begins, in milliseconds.Crater fill at the end of the seam, in milliseconds.Oscillation across the seam.Peak-to-peak weave width in metres.Weave frequency in hertz.True to enable seam tracking where the equipment provides it.Identifier of the welding procedure specification this weld works to. + + + Default Binary + Default Binary encoding of the structure. + + i=76 + ns=1;i=3077 + + + + SpotWeldIntentDataType + Make a resistance spot weld at the target. WeldSchedule selects the weld controller's own program: current and time are the weld controller's business, and are carried here only where the installation drives them from the robot. + RobotIntent DataTypes + + ns=1;i=3076 + ns=1;i=5026 + + The weld controller program to run.Electrode force in newtons.Gun open distance before the weld, in metres.Gun open distance after the weld, in metres.Total stack thickness in metres, where the schedule is chosen adaptively.True to dress the tips after this weld. + + + Default Binary + Default Binary encoding of the structure. + + i=76 + ns=1;i=3078 + + + + DispenseIntentDataType + Lay a bead of adhesive, sealant or paint along the path. The trigger distances exist because material does not start and stop instantly: a Server begins dispensing before the path and stops before its end. + RobotIntent DataTypes + + ns=1;i=3076 + ns=1;i=5027 + + Nominal flow in millilitres per minute.Distance before the path start at which dispensing begins, in metres.Distance before the path end at which dispensing stops, in metres.Target bead width in metres.Material temperature in degrees Celsius, for hot-melt work.Nozzle purge cycles before starting. + + + Default Binary + Default Binary encoding of the structure. + + i=76 + ns=1;i=3079 + + + + FastenIntentDataType + Drive a fastener at the target. This intent is deliberately THIN: OPC 40450 and OPC 40451 already define joining and tightening in full, so Joint references the joint in that model and the result belongs there. Restating those parameters here would create a second definition of the same fact. + RobotIntent DataTypes + + ns=1;i=3076 + ns=1;i=5028 + + The joint being fastened, in an OPC UA joining or tightening model where one is implemented.The tightening program the tool is to run.Target torque in newton metres, where the robot supplies it rather than the tool.Target angle in radians, for angle-controlled strategies.Torque at which angle counting begins, in newton metres. + + + Default Binary + Default Binary encoding of the structure. + + i=76 + ns=1;i=3080 + + + + PalletiseIntentDataType + Place an item into a pattern. The pattern itself is a Location, so its geometry has one definition that a client can read rather than being recomputed from indices on both sides. + RobotIntent DataTypes + + ns=1;i=3076 + ns=1;i=5029 + + The Location describing the pallet or pattern.Layer index, counting from zero.Row index within the layer, counting from zero.Column index within the row, counting from zero.Rotation of the item about the pattern normal, in radians. + + + Default Binary + Default Binary encoding of the structure. + + i=76 + ns=1;i=3081 + + + + SurfaceFinishIntentDataType + Follow the path pressing into the surface - grinding, polishing, deburring or sanding. ContactForce is what distinguishes it from a plain path: the robot yields normal to the surface to hold that force. + RobotIntent DataTypes + + ns=1;i=3076 + ns=1;i=5030 + + Force normal to the surface, in newtons.Travel speed along the surface, in metres per second.Rotational speed of the tool, in revolutions per minute.Lateral offset between adjacent passes, in metres. + + + Default Binary + Default Binary encoding of the structure. + + i=76 + ns=1;i=3082 + + + + MissionTransitionDataType + One edge of a mission's step graph, following the step-and-transition form of an IEC 61131-3 sequential function chart. Condition is an OPC UA ContentFilter - the base specification's own filter grammar, reused so that this specification does not invent an expression language for implementers to write a parser for. + RobotIntent DataTypes + + i=22 + ns=1;i=5031 + + The step this transition leaves.The step this transition enters.The condition under which it is taken. An empty filter is always true.How this transition relates to the others leaving the same step. + + + Default Binary + Default Binary encoding of the structure. + + i=76 + ns=1;i=3083 + + + + KinematicJointDataType + One joint of the kinematic chain: where it sits relative to its predecessor and which way it acts. OPC 40010-1 describes a robot's topology and its axes but defines no kinematic chain, so this is additive rather than a second account of the same thing. + RobotIntent DataTypes + + i=22 + ns=1;i=5032 + + The AxisType this joint corresponds to.Whether it rotates or translates.Pose of this joint's frame within its predecessor's, at zero position.Unit vector (x, y, z) the joint rotates about or translates along, in its own frame. + + + Default Binary + Default Binary encoding of the structure. + + i=76 + ns=1;i=3084 + + + + SafetyStateType + What the robot's safety system is doing, reported so a client can act sensibly around it. Every member is READ-ONLY and every one is a report: the safety system enforces these independently, remains effective when this interface is unreachable, and is not commanded from here. Clause 10 states the boundary and this type does not cross it. + RobotIntent + + i=58 + ns=1;i=6098 + ns=1;i=6099 + ns=1;i=6100 + ns=1;i=6101 + ns=1;i=6102 + ns=1;i=6103 + ns=1;i=6104 + + + + ActiveFunction + The safe motion function currently enforced, per IEC 61800-5-2. + + i=78 + i=68 + ns=1;i=1012 + + + + EmergencyStopActive + True while an emergency stop is asserted. + + i=78 + i=68 + ns=1;i=1012 + + + + ProtectiveStopActive + True while a protective stop is asserted. + + i=78 + i=68 + ns=1;i=1012 + + + + SafeSpeedLimitActive + True while a safely limited speed is being enforced. + + i=78 + i=68 + ns=1;i=1012 + + + + SafeSpeedLimit + The tool centre point speed limit being enforced, in metres per second. Meaningful only while SafeSpeedLimitActive. Clause 10.3 requires a Server to refuse an intent that asks to exceed it. + + i=78 + i=68 + ns=1;i=1012 + + + + SafetyControllerOk + False when the safety system reports its own fault. A Server accepts no intent while it is false. + + i=78 + i=68 + ns=1;i=1012 + + + + LastStopReason + Why the last stop occurred, for a human. Never parsed. + + i=80 + i=68 + ns=1;i=1012 + + + + RealTimeChannelType + A high-rate channel this Server can offer, described so a client can open it. The samples never traverse OPC UA: this brokers the endpoint and nothing more, in the same way the Vision model brokers a media endpoint rather than carrying pixels. Clause 4.3 explains why the alternative is not available. + RobotIntent + + i=58 + ns=1;i=6105 + ns=1;i=6106 + ns=1;i=6107 + ns=1;i=6108 + ns=1;i=6109 + ns=1;i=6110 + ns=1;i=6111 + ns=1;i=6112 + ns=1;i=6113 + ns=1;i=6114 + + + + ChannelId + Identifier unique within the controller. + + i=78 + i=68 + ns=1;i=1013 + + + + Transport + The transport this channel speaks. + + i=78 + i=68 + ns=1;i=1013 + + + + EndpointUrl + Where the channel is reached. Its scheme and form are the transport's, not this specification's. + + i=78 + i=68 + ns=1;i=1013 + + + + Initiator + Which end opens the connection. + + i=78 + i=68 + ns=1;i=1013 + + + + NominalRate + The rate the channel runs at, in hertz. + + i=78 + i=68 + ns=1;i=1013 + + + + PayloadDescriptor + The recipe, signal list or configuration the transport requires, in the transport's own form. + + i=80 + i=68 + ns=1;i=1013 + + + + RequiredMode + The operational mode the robot must be in before the channel will carry motion. + + i=78 + i=68 + ns=1;i=1013 + + + + Available + True when the channel can be opened now. + + i=78 + i=68 + ns=1;i=1013 + + + + LeaseHolder + SessionId of the client currently holding the channel, or null. + + i=78 + i=68 + ns=1;i=1013 + + + + LeaseExpiry + When the current lease lapses. A lease that is not renewed frees the channel, so a client that dies does not hold it for good. + + i=80 + i=68 + ns=1;i=1013 + + + + RobotDescriptionType + Enough of the robot's construction for a client to plan against it without a second specification: the kinematic chain, the space it can reach, and what it can carry. OPC 40010-1 describes topology and axes and defines no kinematic chain and no tool centre point, so this adds rather than restates - Annex B says which side decides where both are present. + RobotIntent + + i=58 + ns=1;i=6115 + ns=1;i=6116 + ns=1;i=6117 + ns=1;i=6118 + ns=1;i=6119 + ns=1;i=6120 + ns=1;i=6121 + ns=1;i=6122 + + + + Manufacturer + Who made the robot. + + i=80 + i=68 + ns=1;i=1014 + + + + Model + The robot's model designation. + + i=80 + i=68 + ns=1;i=1014 + + + + KinematicChain + The joints from the base outwards, in order. + + i=78 + i=63 + ns=1;i=1014 + + + + MountingPose + Pose of the robot base within the world frame. + + i=80 + i=63 + ns=1;i=1014 + + + + ReachRadius + Radius of the reachable workspace from the base, in metres. + + i=78 + i=68 + ns=1;i=1014 + + + + PayloadLimit + Largest payload at the mechanical interface, in kilograms. + + i=78 + i=68 + ns=1;i=1014 + + + + MaxCartesianSpeed + Largest tool centre point speed the robot will produce, in metres per second. + + i=78 + i=68 + ns=1;i=1014 + + + + MaxCartesianAcceleration + Largest tool centre point acceleration, in metres per second squared. + + i=80 + i=68 + ns=1;i=1014 + + + + SafetyState + What the safety system is doing. A client reads this before it plans, and subscribes to it so that it learns of a stop rather than inferring one from a refusal. + + i=78 + ns=1;i=1012 + ns=1;i=1002 + + + + Description + The robot's kinematics and limits. + + i=80 + ns=1;i=1014 + ns=1;i=1002 + + + + RealTimeChannels + The high-rate channels this Server can broker. + + i=80 + i=61 + ns=1;i=1002 + + + + OpenRealTimeChannel + Take a lease on a brokered real-time channel. The Server prepares the transport and returns what the client needs in order to connect; it does not carry the samples. A lease that is not renewed lapses, which is what stops a dead client from holding the channel. + + i=80 + ns=1;i=1002 + ns=1;i=6127 + ns=1;i=6128 + + + + InputArguments + + i=78 + i=68 + ns=1;i=6126 + + i=297ChannelIdi=12-1The channel to open.i=297RequestedLeasei=290-1How long the lease is wanted for, in milliseconds. + + + OutputArguments + + i=78 + i=68 + ns=1;i=6126 + + i=297Grantedi=1-1True when the lease was taken.i=297EndpointUrli=12-1Where to connect.i=297PayloadDescriptori=12-1The transport's own configuration.i=297LeaseExpiryi=294-1When the lease lapses. + + + CloseRealTimeChannel + Give up a lease on a brokered channel. + + i=80 + ns=1;i=1002 + ns=1;i=6130 + ns=1;i=6131 + + + + InputArguments + + i=78 + i=68 + ns=1;i=6129 + + i=297ChannelIdi=12-1The channel to release. + + + OutputArguments + + i=78 + i=68 + ns=1;i=6129 + + i=297Releasedi=1-1True when the lease was held and is now released. + + + TrajectorySupported + True when trajectory and Cartesian path intents are accepted. + + i=78 + i=68 + ns=1;i=1005 + + + + ForceControlSupported + True when force intents are accepted and the robot can actually regulate force. A Server that would ignore the force reports false rather than accepting an intent it cannot honour. + + i=78 + i=68 + ns=1;i=1005 + + + + RealTimeChannelsSupported + True when the Server brokers real-time channels. + + i=78 + i=68 + ns=1;i=1005 + + + + MissionBranchingSupported + True when mission transitions are evaluated. A Server that reports false executes the steps in order and ignores any transitions supplied. + + i=78 + i=68 + ns=1;i=1005 + + + + MaxTrajectoryPoints + Largest number of points accepted in one trajectory. Zero means the Server states no limit. + + i=80 + i=68 + ns=1;i=1005 + + + diff --git a/src/Opc.Ua.Robotics/Opc.Ua.Robotics.csproj b/src/Opc.Ua.Robotics/Opc.Ua.Robotics.csproj index eb98a80a5e..b0a3b8516f 100644 --- a/src/Opc.Ua.Robotics/Opc.Ua.Robotics.csproj +++ b/src/Opc.Ua.Robotics/Opc.Ua.Robotics.csproj @@ -43,6 +43,7 @@ + @@ -51,6 +52,14 @@ Opc.Ua.Robotics + + + http://opcfoundation.org/UA/RobotIntent/ + Opc.Ua.RobotIntent + diff --git a/src/Opc.Ua.Robotics/RoboticsOperationConventions.cs b/src/Opc.Ua.Robotics/RoboticsOperationConventions.cs deleted file mode 100644 index 4c4649e827..0000000000 --- a/src/Opc.Ua.Robotics/RoboticsOperationConventions.cs +++ /dev/null @@ -1,159 +0,0 @@ -/* ======================================================================== - * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. - * - * OPC Foundation MIT License 1.00 - * - * Permission is hereby granted, free of charge, to any person - * obtaining a copy of this software and associated documentation - * files (the "Software"), to deal in the Software without - * restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the - * Software is furnished to do so, subject to the following - * conditions: - * - * The above copyright notice and this permission notice shall be - * included in all copies or substantial portions of the Software. - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES - * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND - * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT - * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, - * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING - * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR - * OTHER DEALINGS IN THE SOFTWARE. - * - * The complete license agreement can be found here: - * http://opcfoundation.org/License/MIT/1.00/ - * ======================================================================*/ - -namespace Opc.Ua.Robotics.Operations -{ - /// - /// Describes how a release operation should transfer the held object. - /// - public enum RoboticsReleaseMode - { - /// - /// Release the object without controlled placement. - /// - Drop, - - /// - /// Place the object at the requested or current target. - /// - Place, - - /// - /// Hold the object for a handover and release when the application decides it is safe. - /// - Handover - } - - /// - /// Describes the approach direction convention used by grasping and placement operations. - /// - public enum RoboticsApproach - { - /// - /// The server or application chooses the approach. - /// - Default, - - /// - /// Approach along the tool Z axis. - /// - ToolZ, - - /// - /// Approach from above in the active work coordinate system. - /// - Top, - - /// - /// Approach from the side in the active work coordinate system. - /// - Side - } - - /// - /// Requests a non-normative convention MoveTo operation. - /// - public sealed record MoveToRequest( - ThreeDFrame TargetFrame, - double? SpeedFraction = null, - double? BlendRadius = null, - EUInformation? BlendRadiusUnits = null); - - /// - /// Requests a non-normative convention joint move. - /// - public sealed record JointMoveRequest( - ArrayOf JointTargets, - EUInformation JointUnits, - double? SpeedFraction = null); - - /// - /// Requests a non-normative convention linear move. - /// - public sealed record LinearMoveRequest( - ThreeDFrame TargetFrame, - double LinearSpeed, - EUInformation LinearSpeedUnits, - double? Acceleration = null, - EUInformation? AccelerationUnits = null); - - /// - /// Requests a non-normative convention grasp operation. - /// - public sealed record GraspRequest( - double? ForceNewtons = null, - double? Width = null, - EUInformation? WidthUnits = null, - RoboticsApproach Approach = RoboticsApproach.Default); - - /// - /// Requests a non-normative convention release operation. - /// - public sealed record ReleaseRequest( - RoboticsReleaseMode Mode, - ThreeDFrame? TargetFrame = null); - - /// - /// Requests a non-normative convention pick or place operation. - /// - public sealed record PickPlaceRequest( - string StationOrLocationIdentifier, - string ObjectClass, - ArrayOf Attributes, - double? ForceNewtons = null); - - /// - /// Requests a non-normative convention tool-change operation. - /// - public sealed record ToolChangeRequest(string ToolIdentifier, string? DockStation = null); - - /// - /// Requests a non-normative convention output write operation. - /// - public sealed record OutputRequest(string OutputLineIdentifier, Variant Value); - - /// - /// Requests a non-normative convention program call operation. - /// - public sealed record ProgramCallRequest(string ProgramName, ArrayOf Arguments); - - /// - /// Describes the result of a non-normative convention Robotics operation. - /// - public sealed record RoboticsOperationResult( - ServiceResult ServiceResult, - string? Message = null, - ArrayOf? Outputs = null) - { - /// - /// Gets a successful operation result. - /// - public static RoboticsOperationResult Good { get; } = new(ServiceResult.Good); - } -} diff --git a/tests/Opc.Ua.Robotics.Tests/IntentControllerHostTests.cs b/tests/Opc.Ua.Robotics.Tests/IntentControllerHostTests.cs new file mode 100644 index 0000000000..6e98a9ec1b --- /dev/null +++ b/tests/Opc.Ua.Robotics.Tests/IntentControllerHostTests.cs @@ -0,0 +1,705 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.RobotIntent; +using Opc.Ua.Tests; +using Opc.Ua.RobotIntent.Server; +using RiDataTypeIds = Opc.Ua.RobotIntent.DataTypeIds; +using RiNamespaces = Opc.Ua.RobotIntent.Namespaces; + +namespace Opc.Ua.Robotics.Tests +{ + /// + /// Exercises the OPC UA - Robot Intent execution lifecycle. + /// + /// + /// These assert the normative rules the specification can actually be observed to + /// keep: the clause 6.2 admission order, the clause 6.3 state pairing, the clause + /// 6.4 queue, the clause 6.5 right to refuse a cancel, and the clause 7.2 base + /// immutability. A test that only proved "a method exists" would prove nothing. + /// + [TestFixture] + public class IntentControllerHostTests + { + private ServiceMessageContext m_messageContext = null!; + private SystemContext m_context = null!; + private IntentControllerState m_controller = null!; + private ScriptedExecutor m_executor = null!; + private IntentControllerHost m_host = null!; + private readonly List m_added = []; + + [SetUp] + public void SetUp() + { + ITelemetryContext telemetry = NUnitTelemetryContext.Create(true); + m_messageContext = ServiceMessageContext.Create(telemetry); + m_messageContext.NamespaceUris.Append(RiNamespaces.RobotIntent); + m_context = new SystemContext(telemetry) + { + NamespaceUris = m_messageContext.NamespaceUris, + EncodeableFactory = m_messageContext.Factory + }; + + m_controller = new IntentControllerState(null); + m_controller.Create( + m_context, + new NodeId("Controller", 1), + new QualifiedName("Controller", 1), + new LocalizedText("Controller"), + true); + + m_executor = new ScriptedExecutor(); + m_added.Clear(); + m_host = new IntentControllerHost( + m_controller, + m_executor, + (node, ct) => + { + lock (m_added) + { + m_added.Add(node); + } + return default; + }, + Options()); + m_host.Start(m_context); + } + + [TearDown] + public void TearDown() + { + m_host?.Dispose(); + } + + private static IntentControllerHostOptions Options( + OperationalModeEnum mode = OperationalModeEnum.AutomaticExternal, + bool requireAuthority = false) + { + var options = new IntentControllerHostOptions + { + OperationalMode = mode, + RequireControlAuthority = requireAuthority, + AxisCount = 6, + MaxQueueDepth = 4 + }; + options.Accept(RiDataTypeIds.LinearMoveIntentDataType); + options.Accept(RiDataTypeIds.JointMoveIntentDataType); + options.Accept(RiDataTypeIds.GraspIntentDataType, cancelSupported: false); + return options; + } + + private static Pose3DDataType Pose(double x = 0, double y = 0, double z = 0) + { + return new Pose3DDataType + { + FrameId = "base", + Position = new[] { x, y, z }, + Orientation = new[] { 0.0, 0.0, 0.0, 1.0 } + }; + } + + private static LinearMoveIntentDataType Move( + string id = "", + BufferModeEnum buffer = BufferModeEnum.Aborting) + { + return new LinearMoveIntentDataType + { + IntentId = id, + BufferMode = buffer, + Target = Pose(1, 0, 0) + }; + } + + // ------------------------------------------------------- clause 6.2 admission + + [Test] + public void SubmitReturnsAHandleWithoutWaitingForTheMotion() + { + m_executor.Gate = new SemaphoreSlim(0); + + IntentAdmission admission = m_host.SubmitIntent(m_context, null, Move()); + + Assert.Multiple(() => + { + Assert.That(admission.Accepted, Is.True); + Assert.That(admission.IntentId, Is.Not.Empty); + Assert.That(admission.Operation, Is.Not.EqualTo(NodeId.Null), + "submission must return the IntentOperation that tracks the work"); + }); + m_executor.Gate!.Release(); + } + + [Test] + public void SubmissionIsRefusedOutsideAutomaticModes() + { + using var host = NewHost(Options(OperationalModeEnum.ManualReducedSpeed)); + + IntentAdmission admission = host.SubmitIntent(m_context, null, Move()); + + Assert.Multiple(() => + { + Assert.That(admission.Accepted, Is.False); + Assert.That(admission.Failure, Is.EqualTo(IntentFailureEnum.NotPermittedInMode)); + }); + } + + [Test] + public void SubmissionIsRefusedWithoutCommandAuthority() + { + using var host = NewHost(Options(requireAuthority: true)); + + IntentAdmission admission = host.SubmitIntent(m_context, new NodeId("s1", 1), Move()); + + Assert.Multiple(() => + { + Assert.That(admission.Accepted, Is.False); + Assert.That(admission.Failure, Is.EqualTo(IntentFailureEnum.ControlNotOwned)); + }); + } + + [Test] + public void AuthorityIsExclusiveAndReleasedWhenTheSessionCloses() + { + using var host = NewHost(Options(requireAuthority: true)); + var first = new NodeId("s1", 1); + var second = new NodeId("s2", 1); + + Assert.That(host.RequestControl(m_context, first, out _), Is.True); + Assert.That(host.RequestControl(m_context, second, out NodeId? owner), Is.False); + Assert.That(owner, Is.EqualTo(first)); + + host.OnSessionClosed(m_context, first); + + Assert.That(host.RequestControl(m_context, second, out _), Is.True, + "a closed Session must not lock the robot for good"); + } + + [Test] + public void AnUndeclaredIntentTypeIsRefused() + { + var options = new IntentControllerHostOptions { RequireControlAuthority = false }; + options.Accept(RiDataTypeIds.JointMoveIntentDataType); + using var host = NewHost(options); + + IntentAdmission admission = host.SubmitIntent(m_context, null, Move()); + + Assert.Multiple(() => + { + Assert.That(admission.Accepted, Is.False); + Assert.That(admission.Failure, Is.EqualTo(IntentFailureEnum.CapabilityNotSupported)); + }); + } + + [Test] + public void AnUnnormalisedOrientationIsRefused() + { + var intent = new LinearMoveIntentDataType + { + BufferMode = BufferModeEnum.Aborting, + Target = new Pose3DDataType + { + FrameId = "base", + Position = new[] { 1.0, 0.0, 0.0 }, + // Not a rotation: accepting it would command an orientation + // nobody specified. + Orientation = new[] { 0.0, 0.0, 0.0, 0.5 } + } + }; + + IntentAdmission admission = m_host.SubmitIntent(m_context, null, intent); + + Assert.Multiple(() => + { + Assert.That(admission.Accepted, Is.False); + Assert.That(admission.Failure, Is.EqualTo(IntentFailureEnum.ParameterInvalid)); + }); + } + + [Test] + public void JointTargetsMustMatchTheDeclaredAxisCount() + { + var intent = new JointMoveIntentDataType + { + BufferMode = BufferModeEnum.Aborting, + HasJointTargets = true, + JointTargets = new[] { 0.1, 0.2, 0.3 } + }; + + IntentAdmission admission = m_host.SubmitIntent(m_context, null, intent); + + Assert.Multiple(() => + { + Assert.That(admission.Accepted, Is.False); + Assert.That(admission.Failure, Is.EqualTo(IntentFailureEnum.ParameterInvalid)); + }); + } + + [Test] + public void AuthorityIsCheckedBeforeParameters() + { + // The order in clause 6.2 is normative: a caller that lacks authority must + // be told that, not that its parameters are wrong. + using var host = NewHost(Options(requireAuthority: true)); + var bad = new LinearMoveIntentDataType { Target = null! }; + + IntentAdmission admission = host.SubmitIntent(m_context, new NodeId("s1", 1), bad); + + Assert.That(admission.Failure, Is.EqualTo(IntentFailureEnum.ControlNotOwned)); + } + + // ------------------------------------------------------------ clause 6.3 state + + [Test] + public void EveryExecutionStateHasExactlyOnePartTenPairing() + { + // The generic overload is net5+, and this test project also targets .NET + // Framework. Enumerating rather than listing the values is the point: a + // state added without a clause 6.3 pairing must fail here. +#if NET5_0_OR_GREATER + foreach (ExecutionStateEnum state in Enum.GetValues()) +#else + foreach (ExecutionStateEnum state in + Enum.GetValues(typeof(ExecutionStateEnum)).Cast()) +#endif + { + Assert.DoesNotThrow( + () => IntentControllerHost.MapToProgramState(state), + $"{state} has no clause 6.3 pairing"); + } + } + + [Test] + public void TheStatePairingMatchesTheSpecificationTable() + { + Assert.Multiple(() => + { + Assert.That(IntentControllerHost.MapToProgramState(ExecutionStateEnum.Accepted), Is.EqualTo(1u)); + Assert.That(IntentControllerHost.MapToProgramState(ExecutionStateEnum.Queued), Is.EqualTo(1u)); + Assert.That(IntentControllerHost.MapToProgramState(ExecutionStateEnum.Executing), Is.EqualTo(2u)); + Assert.That(IntentControllerHost.MapToProgramState(ExecutionStateEnum.Cancelling), Is.EqualTo(2u)); + Assert.That(IntentControllerHost.MapToProgramState(ExecutionStateEnum.Suspended), Is.EqualTo(3u)); + Assert.That(IntentControllerHost.MapToProgramState(ExecutionStateEnum.Succeeded), Is.EqualTo(4u)); + Assert.That(IntentControllerHost.MapToProgramState(ExecutionStateEnum.Failed), Is.EqualTo(4u)); + Assert.That(IntentControllerHost.MapToProgramState(ExecutionStateEnum.Cancelled), Is.EqualTo(4u)); + Assert.That(IntentControllerHost.MapToProgramState(ExecutionStateEnum.Retriable), Is.EqualTo(4u)); + }); + } + + [Test] + public async Task ASucceededIntentPublishesItsResultAndReachesHalted() + { + IntentAdmission admission = m_host.SubmitIntent(m_context, null, Move()); + IntentOperationState node = await WaitForTerminalAsync(admission.IntentId).ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That(node.ExecutionState!.Value, Is.EqualTo(ExecutionStateEnum.Succeeded)); + Assert.That(node.Result!.Value, Is.Not.Null); + Assert.That(node.Result!.Value!.Failure, Is.EqualTo(IntentFailureEnum.None)); + Assert.That(node.Result!.Value!.IntentId, Is.EqualTo(admission.IntentId)); + Assert.That(node.FinalResultData, Is.Not.Null, + "the terminal result must also be reachable where Part 10 says it is"); + }); + } + + // ------------------------------------------------------------ clause 6.4 queue + + [Test] + public async Task BufferedWorkQueuesBehindWhatIsExecuting() + { + m_executor.Gate = new SemaphoreSlim(0); + + IntentAdmission first = m_host.SubmitIntent(m_context, null, Move("a")); + IntentAdmission second = m_host.SubmitIntent( + m_context, null, Move("b", BufferModeEnum.Buffered)); + + Assert.That(second.Accepted, Is.True); + await WaitAsync(() => m_executor.Started.Contains("a")).ConfigureAwait(false); + Assert.That(m_executor.Started, Does.Not.Contain("b"), + "buffered work must not start while its predecessor executes"); + + m_executor.Gate!.Release(2); + await WaitForTerminalAsync("b").ConfigureAwait(false); + Assert.That(m_executor.Started, Is.EqualTo(new[] { "a", "b" })); + } + + [Test] + public async Task AnAbortingSubmissionSupersedesTheQueue() + { + m_executor.Gate = new SemaphoreSlim(0); + + m_host.SubmitIntent(m_context, null, Move("a")); + m_host.SubmitIntent(m_context, null, Move("b", BufferModeEnum.Buffered)); + await WaitAsync(() => m_executor.Started.Contains("a")).ConfigureAwait(false); + + m_host.SubmitIntent(m_context, null, Move("c")); + m_executor.Gate!.Release(3); + + IntentOperationState superseded = await WaitForTerminalAsync("b").ConfigureAwait(false); + Assert.Multiple(() => + { + Assert.That(superseded.ExecutionState!.Value, Is.EqualTo(ExecutionStateEnum.Cancelled)); + Assert.That(superseded.Result!.Value!.Failure, Is.EqualTo(IntentFailureEnum.Superseded), + "replaced work must be distinguishable from work a client cancelled"); + }); + } + + [Test] + public void TheQueueIsBoundedByMaxQueueDepth() + { + m_executor.Gate = new SemaphoreSlim(0); + var options = Options(); + options.MaxQueueDepth = 1; + using var host = NewHost(options); + + host.SubmitIntent(m_context, null, Move("a", BufferModeEnum.Buffered)); + IntentAdmission overflow = host.SubmitIntent( + m_context, null, Move("b", BufferModeEnum.Buffered)); + + Assert.Multiple(() => + { + Assert.That(overflow.Accepted, Is.False); + Assert.That(overflow.Failure, Is.EqualTo(IntentFailureEnum.QueueFull)); + }); + m_executor.Gate!.Release(4); + } + + // ------------------------------------------------------- clause 6.5 cancelling + + [Test] + public async Task CancellingQueuedWorkTerminatesItWithoutRunningIt() + { + m_executor.Gate = new SemaphoreSlim(0); + + m_host.SubmitIntent(m_context, null, Move("a")); + m_host.SubmitIntent(m_context, null, Move("b", BufferModeEnum.Buffered)); + await WaitAsync(() => m_executor.Started.Contains("a")).ConfigureAwait(false); + + Assert.That(m_host.CancelIntent(m_context, null, "b"), Is.True); + + IntentOperationState cancelled = await WaitForTerminalAsync("b").ConfigureAwait(false); + Assert.Multiple(() => + { + Assert.That(cancelled.ExecutionState!.Value, Is.EqualTo(ExecutionStateEnum.Cancelled)); + Assert.That(m_executor.Started, Does.Not.Contain("b")); + }); + m_executor.Gate!.Release(2); + } + + [Test] + public async Task AServerMayRefuseACancel() + { + m_executor.Gate = new SemaphoreSlim(0); + m_executor.RefuseCancel = true; + + m_host.SubmitIntent(m_context, null, Move("a")); + await WaitAsync(() => m_executor.Started.Contains("a")).ConfigureAwait(false); + + Assert.That(m_host.CancelIntent(m_context, null, "a"), Is.False, + "some motions cannot be abandoned part-way, and the Server says so"); + + m_executor.Gate!.Release(); + IntentOperationState node = await WaitForTerminalAsync("a").ConfigureAwait(false); + Assert.That(node.ExecutionState!.Value, Is.EqualTo(ExecutionStateEnum.Succeeded)); + } + + [Test] + public async Task AnAcceptedCancelStopsTheWorkAndReportsCancelled() + { + m_executor.Gate = new SemaphoreSlim(0); + m_executor.HonourCancellation = true; + + m_host.SubmitIntent(m_context, null, Move("a")); + await WaitAsync(() => m_executor.Started.Contains("a")).ConfigureAwait(false); + + Assert.That(m_host.CancelIntent(m_context, null, "a"), Is.True); + + IntentOperationState node = await WaitForTerminalAsync("a").ConfigureAwait(false); + Assert.That(node.ExecutionState!.Value, Is.EqualTo(ExecutionStateEnum.Cancelled)); + } + + [Test] + public async Task RetryCreatesANewOperationAndLeavesTheOriginalTerminal() + { + m_executor.Outcome = IntentOutcome.Retriable( + IntentFailureEnum.GraspFailed, "nothing in the gripper"); + + IntentAdmission first = m_host.SubmitIntent(m_context, null, Move("a")); + IntentOperationState original = await WaitForTerminalAsync("a").ConfigureAwait(false); + Assert.That(original.ExecutionState!.Value, Is.EqualTo(ExecutionStateEnum.Retriable)); + + m_executor.Outcome = IntentOutcome.Success; + IntentAdmission retry = m_host.Retry(m_context, null, "a"); + + Assert.Multiple(() => + { + Assert.That(retry.Accepted, Is.True); + Assert.That(retry.IntentId, Is.Not.EqualTo(first.IntentId)); + Assert.That(retry.Operation, Is.Not.EqualTo(first.Operation), + "a retry is a new attempt, and the history of the first survives"); + }); + await WaitForTerminalAsync(retry.IntentId).ConfigureAwait(false); + Assert.That(original.ExecutionState!.Value, Is.EqualTo(ExecutionStateEnum.Retriable)); + } + + // ------------------------------------------------------------ clause 7 missions + + [Test] + public async Task AMissionRunsItsStepsInOrder() + { + MissionAdmission admission = m_host.SubmitMission(m_context, null, new MissionDataType + { + MissionId = "m1", + Steps = new[] + { + Step("s1", 1, released: true), + Step("s2", 2, released: true) + } + }); + + Assert.That(admission.Accepted, Is.True); + await WaitAsync(() => m_executor.Started.Length >= 2).ConfigureAwait(false); + Assert.That(m_executor.Started, Has.Length.EqualTo(2)); + } + + [Test] + public void AMissionUpdateThatWouldAlterTheBaseIsRefused() + { + m_executor.Gate = new SemaphoreSlim(0); + m_host.SubmitMission(m_context, null, new MissionDataType + { + MissionId = "m1", + MissionUpdateId = 0, + Steps = new[] { Step("s1", 1, released: true), Step("s2", 2, released: false) } + }); + + MissionUpdateOutcome outcome = m_host.UpdateMission(m_context, null, "m1", 1, new[] + { + Step("renamed", 1, released: true), + Step("s2", 2, released: false) + }); + + Assert.That(outcome.Result, Is.EqualTo(MissionUpdateResultEnum.BaseConflict), + "the base is committed and may already have executed"); + m_executor.Gate!.Release(4); + } + + [Test] + public void AnOutdatedMissionUpdateIsRejectedRatherThanAppliedOutOfOrder() + { + m_executor.Gate = new SemaphoreSlim(0); + m_host.SubmitMission(m_context, null, new MissionDataType + { + MissionId = "m1", + MissionUpdateId = 5, + Steps = new[] { Step("s1", 1, released: true), Step("s2", 2, released: false) } + }); + + MissionUpdateOutcome stale = m_host.UpdateMission(m_context, null, "m1", 5, new[] + { + Step("s1", 1, released: true), + Step("s3", 3, released: false) + }); + + Assert.That(stale.Result, Is.EqualTo(MissionUpdateResultEnum.Outdated)); + m_executor.Gate!.Release(4); + } + + [Test] + public void AHorizonUpdateIsAccepted() + { + m_executor.Gate = new SemaphoreSlim(0); + m_host.SubmitMission(m_context, null, new MissionDataType + { + MissionId = "m1", + MissionUpdateId = 0, + Steps = new[] { Step("s1", 1, released: true), Step("s2", 2, released: false) } + }); + + MissionUpdateOutcome outcome = m_host.UpdateMission(m_context, null, "m1", 1, new[] + { + Step("s1", 1, released: true), + Step("s9", 9, released: false) + }); + + Assert.That(outcome.Result, Is.EqualTo(MissionUpdateResultEnum.Accepted)); + m_executor.Gate!.Release(4); + } + + [Test] + public void AnUnknownMissionUpdateIsReportedAsSuch() + { + MissionUpdateOutcome outcome = m_host.UpdateMission( + m_context, null, "nope", 1, new[] { Step("s1", 1, released: false) }); + + Assert.That(outcome.Result, Is.EqualTo(MissionUpdateResultEnum.UnknownMission)); + } + + [Test] + public void ReleasedStepsMustFormAPrefix() + { + MissionAdmission admission = m_host.SubmitMission(m_context, null, new MissionDataType + { + MissionId = "m1", + Steps = new[] + { + Step("s1", 1, released: false), + Step("s2", 2, released: true) + } + }); + + Assert.That(admission.Accepted, Is.False, + "a released step after an unreleased one makes 'the base' meaningless"); + } + + private static MissionStepDataType Step(string id, uint sequence, bool released) + { + return new MissionStepDataType + { + StepId = id, + SequenceId = sequence, + Released = released, + Intent = Move(id) + }; + } + + // ------------------------------------------------------------------- helpers + + private IntentControllerHost NewHost(IntentControllerHostOptions options) + { + var controller = new IntentControllerState(null); + controller.Create( + m_context, + new NodeId(Guid.NewGuid().ToString(), 1), + new QualifiedName("Controller", 1), + new LocalizedText("Controller"), + true); + var host = new IntentControllerHost( + controller, m_executor, (_, _) => default, options); + host.Start(m_context); + return host; + } + + private async Task WaitForTerminalAsync(string intentId) + { + IntentOperationState? node = null; + await WaitAsync(() => + { + node = FindOperation(intentId); + return node?.ExecutionState?.Value is { } state && IntentOutcome.IsTerminal(state); + }).ConfigureAwait(false); + return node!; + } + + private IntentOperationState? FindOperation(string intentId) + { + lock (m_added) + { + return m_added + .OfType() + .FirstOrDefault(n => n.IntentId?.Value == intentId); + } + } + + private static async Task WaitAsync(Func condition, int timeoutMs = 5000) + { + DateTime deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); + while (DateTime.UtcNow < deadline) + { + if (condition()) + { + return; + } + await Task.Delay(10).ConfigureAwait(false); + } + Assert.Fail("timed out waiting for the expected condition"); + } + + /// + /// A stand-in for the robot. It records what it was asked to do, can be held + /// open on a gate so a test can observe the queue, and can refuse a cancel. + /// + private sealed class ScriptedExecutor : IIntentExecutor + { + public ConcurrentQueue StartedQueue { get; } = new(); + public string[] Started => StartedQueue.ToArray(); + public SemaphoreSlim? Gate { get; set; } + public bool RefuseCancel { get; set; } + public bool HonourCancellation { get; set; } + public IntentOutcome Outcome { get; set; } = IntentOutcome.Success; + + public async ValueTask ExecuteAsync( + IntentExecution execution, CancellationToken cancellationToken) + { + StartedQueue.Enqueue(execution.Intent.IntentId ?? execution.IntentId); + execution.Progress.ReportProgress(0); + + if (Gate != null) + { + try + { + await Gate.WaitAsync( + HonourCancellation ? cancellationToken : CancellationToken.None) + .ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return new IntentOutcome { State = ExecutionStateEnum.Cancelled }; + } + } + else if (HonourCancellation) + { + try + { + await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return new IntentOutcome { State = ExecutionStateEnum.Cancelled }; + } + } + + execution.Progress.ReportProgress(1); + return Outcome; + } + + public bool CanCancel(IntentExecution execution) + { + return !RefuseCancel; + } + } + } +} diff --git a/tests/Opc.Ua.Robotics.Tests/IntentScopeExtensionTests.cs b/tests/Opc.Ua.Robotics.Tests/IntentScopeExtensionTests.cs new file mode 100644 index 0000000000..7087b80d9c --- /dev/null +++ b/tests/Opc.Ua.Robotics.Tests/IntentScopeExtensionTests.cs @@ -0,0 +1,628 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.RobotIntent; +using Opc.Ua.RobotIntent.Server; +using Opc.Ua.Tests; +using RiDataTypeIds = Opc.Ua.RobotIntent.DataTypeIds; +using RiNamespaces = Opc.Ua.RobotIntent.Namespaces; + +namespace Opc.Ua.Robotics.Tests +{ + /// + /// Exercises the capability brought into scope beyond single moves: safety + /// awareness, trajectories and force, brokered real-time channels, and the mission + /// step graph with its error policies. + /// + [TestFixture] + public class IntentScopeExtensionTests + { + private SystemContext m_context = null!; + private ScriptedExecutor m_executor = null!; + + [SetUp] + public void SetUp() + { + ITelemetryContext telemetry = NUnitTelemetryContext.Create(true); + ServiceMessageContext messageContext = ServiceMessageContext.Create(telemetry); + messageContext.NamespaceUris.Append(RiNamespaces.RobotIntent); + m_context = new SystemContext(telemetry) + { + NamespaceUris = messageContext.NamespaceUris, + EncodeableFactory = messageContext.Factory + }; + m_executor = new ScriptedExecutor(); + } + + // ------------------------------------------------------------ clause 10.4 + + [Test] + public void AProtectiveStopRefusesSubmission() + { + using var host = NewHost(); + host.UpdateSafetyState(m_context, new SafetyStatus { ProtectiveStopActive = true }); + + IntentAdmission admission = host.SubmitIntent(m_context, null, Move()); + + Assert.Multiple(() => + { + Assert.That(admission.Accepted, Is.False); + Assert.That(admission.Failure, Is.EqualTo(IntentFailureEnum.NotPermittedInMode)); + }); + } + + [Test] + public void AFaultedSafetyControllerRefusesSubmission() + { + using var host = NewHost(); + host.UpdateSafetyState(m_context, new SafetyStatus { SafetyControllerOk = false }); + + IntentAdmission admission = host.SubmitIntent(m_context, null, Move()); + + Assert.That(admission.Failure, Is.EqualTo(IntentFailureEnum.NotPermittedInMode)); + } + + [Test] + public void ASpeedAboveTheEnforcedSafeLimitIsRefused() + { + using var host = NewHost(); + host.UpdateSafetyState(m_context, new SafetyStatus + { + ActiveFunction = SafeMotionFunctionEnum.Sls, + SafeSpeedLimitActive = true, + SafeSpeedLimit = 0.25 + }); + + var intent = Move(); + intent.Constraints = new MotionConstraintsDataType { CartesianSpeed = 1.0 }; + + IntentAdmission admission = host.SubmitIntent(m_context, null, intent); + + Assert.Multiple(() => + { + Assert.That(admission.Accepted, Is.False); + Assert.That(admission.Failure, Is.EqualTo(IntentFailureEnum.SafetyLimitExceeded)); + }); + } + + [Test] + public void ASpeedWithinTheEnforcedSafeLimitIsAdmitted() + { + using var host = NewHost(); + host.UpdateSafetyState(m_context, new SafetyStatus + { + ActiveFunction = SafeMotionFunctionEnum.Sls, + SafeSpeedLimitActive = true, + SafeSpeedLimit = 0.25 + }); + + var intent = Move(); + intent.Constraints = new MotionConstraintsDataType { CartesianSpeed = 0.1 }; + + Assert.That(host.SubmitIntent(m_context, null, intent).Accepted, Is.True); + } + + [Test] + public void AnInactiveSpeedLimitDoesNotRefuse() + { + using var host = NewHost(); + host.UpdateSafetyState(m_context, new SafetyStatus + { + SafeSpeedLimitActive = false, + SafeSpeedLimit = 0.25 + }); + + var intent = Move(); + intent.Constraints = new MotionConstraintsDataType { CartesianSpeed = 1.0 }; + + Assert.That(host.SubmitIntent(m_context, null, intent).Accepted, Is.True, + "a limit that is not being enforced constrains nothing"); + } + + // -------------------------------------------------------------- clause 6.8 + + [Test] + public void ATrajectoryOutOfTimeOrderIsRefused() + { + using var host = NewHost(); + var intent = new TrajectoryIntentDataType + { + Points = new[] { Point(100), Point(50) } + }; + + IntentAdmission admission = host.SubmitIntent(m_context, null, intent); + + Assert.Multiple(() => + { + Assert.That(admission.Accepted, Is.False); + Assert.That(admission.Failure, Is.EqualTo(IntentFailureEnum.ParameterInvalid)); + }); + } + + [Test] + public void ATrajectoryPointWithTheWrongAxisCountIsRefused() + { + using var host = NewHost(); + var bad = new TrajectoryPointDataType + { + TimeFromStart = 100, + Positions = new[] { 0.1, 0.2 } + }; + + IntentAdmission admission = host.SubmitIntent( + m_context, null, new TrajectoryIntentDataType { Points = new[] { bad } }); + + Assert.That(admission.Failure, Is.EqualTo(IntentFailureEnum.ParameterInvalid)); + } + + [Test] + public void ATrajectoryLongerThanTheDeclaredLimitIsRefused() + { + IntentControllerHostOptions options = Options(); + options.MaxTrajectoryPoints = 2; + using var host = NewHost(options); + + IntentAdmission admission = host.SubmitIntent(m_context, null, + new TrajectoryIntentDataType + { + Points = new[] { Point(10), Point(20), Point(30) } + }); + + Assert.That(admission.Failure, Is.EqualTo(IntentFailureEnum.ParameterInvalid)); + } + + [Test] + public async Task AWellFormedTrajectoryExecutes() + { + using var host = NewHost(); + IntentAdmission admission = host.SubmitIntent(m_context, null, + new TrajectoryIntentDataType + { + Points = new[] { Point(10), Point(20), Point(30) } + }); + + Assert.That(admission.Accepted, Is.True); + await WaitAsync(() => m_executor.Started.Length == 1).ConfigureAwait(false); + } + + [Test] + public void AForceIntentWithAZeroDirectionIsRefused() + { + using var host = NewHost(); + IntentAdmission admission = host.SubmitIntent(m_context, null, + new ForceIntentDataType + { + Direction = new[] { 0.0, 0.0, 0.0 }, + ContactForce = 5, + MaxDistance = 0.1 + }); + + Assert.That(admission.Failure, Is.EqualTo(IntentFailureEnum.ParameterInvalid)); + } + + [Test] + public void AForceIntentWithoutADistanceIsRefused() + { + using var host = NewHost(); + IntentAdmission admission = host.SubmitIntent(m_context, null, + new ForceIntentDataType + { + Direction = new[] { 0.0, 0.0, -1.0 }, + ContactForce = 5, + MaxDistance = 0 + }); + + Assert.That(admission.Failure, Is.EqualTo(IntentFailureEnum.ParameterInvalid)); + } + + // -------------------------------------------------------------- clause 6.9 + + [Test] + public void AChannelLeaseIsExclusiveAndReleasable() + { + using var host = NewHost(ChannelOptions()); + var first = new NodeId("s1", 1); + var second = new NodeId("s2", 1); + + RealTimeLease granted = host.OpenRealTimeChannel(m_context, first, "rtde", 5000); + Assert.Multiple(() => + { + Assert.That(granted.Granted, Is.True); + Assert.That(granted.EndpointUrl, Is.EqualTo("rtde://robot:30004")); + Assert.That(granted.Expiry, Is.GreaterThan(DateTime.UtcNow)); + }); + + Assert.That(host.OpenRealTimeChannel(m_context, second, "rtde", 5000).Granted, + Is.False, "a second Session must not take a held channel"); + + Assert.That(host.CloseRealTimeChannel(m_context, second, "rtde"), Is.False, + "only the holder may release the lease"); + Assert.That(host.CloseRealTimeChannel(m_context, first, "rtde"), Is.True); + Assert.That(host.OpenRealTimeChannel(m_context, second, "rtde", 5000).Granted, + Is.True); + } + + [Test] + public void AnUnknownChannelIsRefused() + { + using var host = NewHost(ChannelOptions()); + + Assert.That(host.OpenRealTimeChannel(m_context, null, "nope", 1000).Granted, + Is.False); + } + + [Test] + public void AChannelIsRefusedOutsideItsRequiredMode() + { + IntentControllerHostOptions options = ChannelOptions(); + options.OperationalMode = OperationalModeEnum.Automatic; + using var host = NewHost(options); + + RealTimeLease lease = host.OpenRealTimeChannel(m_context, null, "rtde", 1000); + + Assert.That(lease.Granted, Is.False, + "the channel declares it needs AutomaticExternal"); + } + + [Test] + public void MotionIsRefusedWhileAChannelLeaseIsHeldAndNothingArbitrates() + { + using var host = NewHost(ChannelOptions()); + host.OpenRealTimeChannel(m_context, null, "rtde", 5000); + + IntentAdmission admission = host.SubmitIntent(m_context, null, Move()); + + Assert.Multiple(() => + { + Assert.That(admission.Accepted, Is.False); + Assert.That(admission.Failure, + Is.EqualTo(IntentFailureEnum.CapabilityNotSupported)); + }); + } + + [Test] + public void MotionIsAdmittedAlongsideAChannelWhenTheHostArbitrates() + { + IntentControllerHostOptions options = ChannelOptions(); + options.ArbitratesWithRealTimeChannel = true; + using var host = NewHost(options); + host.OpenRealTimeChannel(m_context, null, "rtde", 5000); + + Assert.That(host.SubmitIntent(m_context, null, Move()).Accepted, Is.True); + } + + // ---------------------------------------------------------------- clause 7.4 + + [Test] + public void ATransitionNamingAnUnknownStepIsRefused() + { + using var host = NewHost(); + MissionAdmission admission = host.SubmitMission(m_context, null, new MissionDataType + { + MissionId = "m1", + Steps = new[] { Step("s1", 1), Step("s2", 2) }, + Transitions = new[] { Transition("s1", "nowhere") } + }); + + Assert.That(admission.Accepted, Is.False); + } + + [Test] + public void AStepMixingDivergenceKindsIsRefused() + { + using var host = NewHost(); + MissionAdmission admission = host.SubmitMission(m_context, null, new MissionDataType + { + MissionId = "m1", + Steps = new[] { Step("s1", 1), Step("s2", 2), Step("s3", 3) }, + Transitions = new[] + { + Transition("s1", "s2"), + Transition("s1", "s3", DivergenceKindEnum.Parallel) + } + }); + + Assert.That(admission.Accepted, Is.False, + "a step cannot both choose one branch and take them all"); + } + + [Test] + public void AFallbackNamingAnUnknownStepIsRefused() + { + using var host = NewHost(); + MissionStepDataType step = Step("s1", 1); + step.ErrorPolicy = ErrorPolicyEnum.Fallback; + step.FallbackStepId = "nowhere"; + + MissionAdmission admission = host.SubmitMission(m_context, null, new MissionDataType + { + MissionId = "m1", + Steps = new[] { step, Step("s2", 2) } + }); + + Assert.That(admission.Accepted, Is.False); + } + + [Test] + public async Task ASkipPolicyContinuesPastAFailedStep() + { + using var host = NewHost(); + m_executor.FailFirst = true; + + MissionStepDataType first = Step("s1", 1); + first.ErrorPolicy = ErrorPolicyEnum.Skip; + + host.SubmitMission(m_context, null, new MissionDataType + { + MissionId = "m1", + Steps = new[] { first, Step("s2", 2) } + }); + + await WaitAsync(() => m_executor.Started.Length >= 2).ConfigureAwait(false); + Assert.That(m_executor.Started, Has.Length.EqualTo(2), + "the mission must reach the second step even though the first failed"); + } + + [Test] + public async Task AnAbortPolicyStopsAtTheFailedStep() + { + using var host = NewHost(); + m_executor.FailFirst = true; + + host.SubmitMission(m_context, null, new MissionDataType + { + MissionId = "m1", + Steps = new[] { Step("s1", 1), Step("s2", 2) } + }); + + await Task.Delay(300).ConfigureAwait(false); + Assert.That(m_executor.Started, Has.Length.EqualTo(1), + "Abort is the default and must not begin a later step"); + } + + [Test] + public async Task ARetryPolicyReattemptsUpToTheConfiguredBound() + { + IntentControllerHostOptions options = Options(); + options.MaxStepRetries = 2; + using var host = NewHost(options); + m_executor.AlwaysFail = true; + + MissionStepDataType step = Step("s1", 1); + step.ErrorPolicy = ErrorPolicyEnum.Retry; + + host.SubmitMission(m_context, null, new MissionDataType + { + MissionId = "m1", + Steps = new[] { step } + }); + + await Task.Delay(400).ConfigureAwait(false); + Assert.That(m_executor.Started, Has.Length.EqualTo(3), + "one attempt plus two retries"); + } + + [Test] + public async Task AnUnconditionalTransitionChoosesTheNextStep() + { + using var host = NewHost(); + MissionAdmission admission = host.SubmitMission(m_context, null, new MissionDataType + { + MissionId = "m1", + Steps = new[] { Step("s1", 1), Step("s2", 2), Step("s3", 3) }, + // The graph skips s2 entirely; without it the mission would run all three. + Transitions = new[] { Transition("s1", "s3") } + }); + + Assert.That(admission.Accepted, Is.True, admission.Message); + await WaitAsync(() => m_executor.Started.Length >= 2).ConfigureAwait(false); + await Task.Delay(150).ConfigureAwait(false); + Assert.That(m_executor.Started, Is.EqualTo(new[] { "s1", "s3" })); + } + + [Test] + public async Task TransitionsAreIgnoredWhenBranchingIsNotSupported() + { + IntentControllerHostOptions options = Options(); + options.MissionBranchingSupported = false; + using var host = NewHost(options); + + host.SubmitMission(m_context, null, new MissionDataType + { + MissionId = "m1", + Steps = new[] { Step("s1", 1), Step("s2", 2), Step("s3", 3) }, + Transitions = new[] { Transition("s1", "s3") } + }); + + await WaitAsync(() => m_executor.Started.Length >= 3).ConfigureAwait(false); + Assert.That(m_executor.Started, Is.EqualTo(new[] { "s1", "s2", "s3" }), + "a host that declares no branching runs the steps in order"); + } + + [Test] + public async Task AMissionWithoutTransitionsIsStillTheFlatSequence() + { + using var host = NewHost(); + host.SubmitMission(m_context, null, new MissionDataType + { + MissionId = "m1", + Steps = new[] { Step("s1", 1), Step("s2", 2) } + }); + + await WaitAsync(() => m_executor.Started.Length >= 2).ConfigureAwait(false); + Assert.That(m_executor.Started, Is.EqualTo(new[] { "s1", "s2" })); + } + + // ------------------------------------------------------------------- helpers + + private static IntentControllerHostOptions Options() + { + var options = new IntentControllerHostOptions + { + RequireControlAuthority = false, + AxisCount = 6, + MaxQueueDepth = 8, + ForceControlSupported = true + }; + options.Accept(RiDataTypeIds.LinearMoveIntentDataType); + options.Accept(RiDataTypeIds.TrajectoryIntentDataType); + options.Accept(RiDataTypeIds.CartesianPathIntentDataType); + options.Accept(RiDataTypeIds.ForceIntentDataType); + options.Accept(RiDataTypeIds.ArcWeldIntentDataType); + return options; + } + + private static IntentControllerHostOptions ChannelOptions() + { + IntentControllerHostOptions options = Options(); + options.RealTimeChannelsSupported = true; + options.Channels.Add(new DeclaredChannel + { + ChannelId = "rtde", + Transport = RealTimeTransportEnum.Rtde, + EndpointUrl = "rtde://robot:30004", + Initiator = ChannelInitiatorEnum.Client, + NominalRate = 500, + PayloadDescriptor = "actual_q,actual_TCP_pose", + RequiredMode = OperationalModeEnum.AutomaticExternal + }); + return options; + } + + private IntentControllerHost NewHost(IntentControllerHostOptions? options = null) + { + var controller = new IntentControllerState(null); + controller.Create( + m_context, + new NodeId(Guid.NewGuid().ToString(), 1), + new QualifiedName("Controller", 1), + new LocalizedText("Controller"), + true); + var host = new IntentControllerHost( + controller, m_executor, (_, _) => default, options ?? Options()); + host.Start(m_context); + return host; + } + + private static Pose3DDataType Pose() + { + return new Pose3DDataType + { + FrameId = "base", + Position = new[] { 1.0, 0.0, 0.0 }, + Orientation = new[] { 0.0, 0.0, 0.0, 1.0 } + }; + } + + private static LinearMoveIntentDataType Move(string id = "") + { + return new LinearMoveIntentDataType { IntentId = id, Target = Pose() }; + } + + private static TrajectoryPointDataType Point(double timeMs) + { + return new TrajectoryPointDataType + { + TimeFromStart = timeMs, + Positions = new[] { 0.0, 0.1, 0.2, 0.3, 0.4, 0.5 } + }; + } + + private static MissionStepDataType Step(string id, uint sequence) + { + return new MissionStepDataType + { + StepId = id, + SequenceId = sequence, + Released = true, + Intent = Move(id) + }; + } + + private static MissionTransitionDataType Transition( + string from, string to, + DivergenceKindEnum kind = DivergenceKindEnum.Alternative) + { + return new MissionTransitionDataType + { + FromStepId = from, + ToStepId = to, + DivergenceKind = kind + }; + } + + private static async Task WaitAsync(Func condition, int timeoutMs = 5000) + { + DateTime deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); + while (DateTime.UtcNow < deadline) + { + if (condition()) + { + return; + } + await Task.Delay(10).ConfigureAwait(false); + } + Assert.Fail("timed out waiting for the expected condition"); + } + + private sealed class ScriptedExecutor : IIntentExecutor + { + private int m_calls; + + public ConcurrentQueue StartedQueue { get; } = new(); + public string[] Started => StartedQueue.ToArray(); + public bool FailFirst { get; set; } + public bool AlwaysFail { get; set; } + + public ValueTask ExecuteAsync( + IntentExecution execution, CancellationToken cancellationToken) + { + int call = Interlocked.Increment(ref m_calls); + StartedQueue.Enqueue(execution.Intent.IntentId ?? execution.IntentId); + if (AlwaysFail || (FailFirst && call == 1)) + { + return new ValueTask( + IntentOutcome.Fail(IntentFailureEnum.Other, "scripted failure")); + } + return new ValueTask(IntentOutcome.Success); + } + + public bool CanCancel(IntentExecution execution) + { + return true; + } + } + } +} diff --git a/tests/Opc.Ua.Robotics.Tests/RoboticsOperationsConventionBuilderTests.cs b/tests/Opc.Ua.Robotics.Tests/RoboticsOperationsConventionBuilderTests.cs deleted file mode 100644 index 63ea6539e6..0000000000 --- a/tests/Opc.Ua.Robotics.Tests/RoboticsOperationsConventionBuilderTests.cs +++ /dev/null @@ -1,194 +0,0 @@ -/* ======================================================================== - * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. - * - * OPC Foundation MIT License 1.00 - * - * Permission is hereby granted, free of charge, to any person - * obtaining a copy of this software and associated documentation - * files (the "Software"), to deal in the Software without - * restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the - * Software is furnished to do so, subject to the following - * conditions: - * - * The above copyright notice and this permission notice shall be - * included in all copies or substantial portions of the Software. - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES - * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND - * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT - * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, - * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING - * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR - * OTHER DEALINGS IN THE SOFTWARE. - * - * The complete license agreement can be found here: - * http://opcfoundation.org/License/MIT/1.00/ - * ======================================================================*/ - -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using NUnit.Framework; -using Opc.Ua.Robotics; -using Opc.Ua.Robotics.Operations; -using Opc.Ua.Robotics.Server; -using Opc.Ua.Robotics.Server.Builders; - -namespace Opc.Ua.Robotics.Server.Tests -{ - [TestFixture] - [NonParallelizable] - [Category("Robotics")] - public sealed class RoboticsOperationsConventionBuilderTests - { - private RoboticsServerFixture m_fixture = null!; - private int m_nameCounter; - - [OneTimeSetUp] - public async Task SetUpAsync() - { - m_fixture = new RoboticsServerFixture(); - await m_fixture.StartAsync().ConfigureAwait(false); - } - - [OneTimeTearDown] - public async Task TearDownAsync() - { - await m_fixture.DisposeAsync().ConfigureAwait(false); - } - - [Test] - public async Task RegisteredHandlersMaterializeOnlySelectedMethodsWithMetadata() - { - IRoboticsOperationsBuilder operations = null!; - - await BuildRobotAsync(builder => - { - operations = builder.AddOperations("Operations", builder.BuildContext.InstanceNamespaceIndex, op => op - .OnMoveTo((_, _) => new ValueTask(RoboticsOperationResult.Good)) - .OnGrasp((_, _) => new ValueTask(RoboticsOperationResult.Good))); - }).ConfigureAwait(false); - - Assert.That(operations.State, Is.Not.Null); - Assert.That(FindMethod(operations.State!, "MoveTo"), Is.Not.Null); - Assert.That(FindMethod(operations.State!, "Grasp"), Is.Not.Null); - Assert.That(FindMethod(operations.State!, "MoveJ"), Is.Null); - MethodState moveTo = FindMethod(operations.State!, "MoveTo")!; - Assert.That(moveTo.InputArguments, Is.Not.Null); - Assert.That(moveTo.OutputArguments, Is.Not.Null); - Assert.That(moveTo.InputArguments!.Value[0].Name, Is.EqualTo("TargetFrame")); - Assert.That(moveTo.OutputArguments!.Value[0].Name, Is.EqualTo("StatusCode")); - } - - [Test] - public void StandardNamespaceIndexIsRejected() - { - IRoboticsBuildContext context = m_fixture.CreateBuildContext(); - ushort roboticsNamespaceIndex = - (ushort)context.Context.NamespaceUris.GetIndex(Opc.Ua.Robotics.Namespaces.Robotics); - - ServiceResultException exception = Assert.ThrowsAsync(async () => - await context.AddMotionDeviceSystemAsync(NextName("Cell"), system => - { - AddRequiredTopology(system, motion => - motion.AddOperations("Operations", roboticsNamespaceIndex, _ => { })); - }).ConfigureAwait(false))!; - - Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadConfigurationError)); - } - - [Test] - public async Task MoveToReturnsHandlerResultAndFailureStatus() - { - IRoboticsOperationsBuilder operations = null!; - - await BuildRobotAsync(builder => - { - operations = builder.AddOperations("Operations", builder.BuildContext.InstanceNamespaceIndex, op => op - .OnMoveTo((_, _) => new ValueTask( - new RoboticsOperationResult(new ServiceResult(StatusCodes.BadInvalidArgument), "no")))); - }).ConfigureAwait(false); - - MethodState moveTo = FindMethod(operations.State!, "MoveTo")!; - var outputs = ResultOutputs(); - ServiceResult result = await moveTo.OnCallMethod2Async!( - m_fixture.Manager.SystemContext, - moveTo, - operations.State!.NodeId, - [Structure(new ThreeDFrame()), Variant.Null, Variant.Null, Variant.Null], - outputs, - CancellationToken.None).ConfigureAwait(false); - - Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadInvalidArgument)); - Assert.That(outputs[1].TryGetValue(out string message), Is.True); - Assert.That(message, Is.EqualTo("no")); - } - - private async Task BuildRobotAsync(Action configureMotion) - { - await m_fixture.CreateBuildContext() - .AddMotionDeviceSystemAsync(NextName("Cell"), system => AddRequiredTopology(system, configureMotion)) - .ConfigureAwait(false); - } - - private static void AddRequiredTopology( - IMotionDeviceSystemBuilder system, - Action configureMotion) - { - system.AddMotionDevice("Robot", motion => - { - motion.WithCategory(MotionDeviceCategoryEnumeration.ARTICULATED_ROBOT) - .WithSpeedOverride(50); - motion.AddAxis("Axis1", axis => axis - .AsVirtual() - .WithMotionProfile(AxisMotionProfileEnumeration.ROTARY) - .WithActualPosition(0)); - motion.AddPowerTrain("PowerTrain1", powerTrain => - powerTrain.AddMotor("Motor1", motor => motor.WithMotorTemperature(20))); - configureMotion(motion); - }); - system.AddSafetyState("Safety"); - system.AddController("Controller", controller => - { - controller.WithCurrentUser(user => user.WithLevel("Operator").WithName("alice")); - controller.AddSoftware("Software"); - controller.AddTaskControl("Task", task => task - .WithComponentName("Task") - .WithTaskProgramLoaded(false) - .WithTaskProgramName(string.Empty)); - }); - } - - private static MethodState? FindMethod(BaseObjectState operations, string name) - { - var children = new List(); - operations.GetChildren(null!, children); - for (int ii = 0; ii < children.Count; ii++) - { - if (children[ii] is MethodState method && method.BrowseName.Name == name) - { - return method; - } - } - return null; - } - - private static List ResultOutputs() - { - return [Variant.Null, Variant.Null, Variant.Null]; - } - - private static Variant Structure(IEncodeable value) - { - return new Variant(new ExtensionObject(value)); - } - - private string NextName(string prefix) - { - return $"{prefix}{++m_nameCounter}"; - } - } -}