/* ======================================================================== * 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 Microsoft.Extensions.Logging; using Opc.Ua; using Opc.Ua.Robotics.Server; using Opc.Ua.Robotics.Server.Builders; using Opc.Ua.RobotIntent; using Opc.Ua.Server; using Robotics.IntentEnabledRobot.Kinematics; using Robotics.IntentEnabledRobot.Simulation; using ThreeDCartesianCoordinates = Opc.Ua.ThreeDCartesianCoordinates; using ThreeDFrame = Opc.Ua.ThreeDFrame; using ThreeDOrientation = Opc.Ua.ThreeDOrientation; namespace Robotics.IntentEnabledRobot { /// /// Builds the minimal Robot Intent sample cell. /// public sealed partial class IntentRobotCell : IDisposable { /// /// Creates the sample cell configurator. /// public IntentRobotCell( ILogger logger, SimulatedArmExecutor executor, SampleSafetySource safetySource) { m_logger = logger ?? throw new ArgumentNullException(nameof(logger)); m_executor = executor ?? throw new ArgumentNullException(nameof(executor)); m_safetySource = safetySource ?? throw new ArgumentNullException(nameof(safetySource)); m_executor.SnapshotChanged += OnSnapshotChanged; } internal AsyncCustomNodeManager Manager => m_manager ?? throw new InvalidOperationException( "IntentRobotCell has not been attached to a Robot Intent build context."); internal ServerSystemContext SystemContext => m_systemContext ?? throw new InvalidOperationException( "IntentRobotCell has not been attached to a Robot Intent build context."); internal IIntentControllerBuilder Controller => m_controller ?? throw new InvalidOperationException( "The intent controller has not been materialised."); internal IEnumerable Axes => m_axes; internal IEnumerable Locations => m_locations; internal IReadOnlyDictionary LocationNodes => m_locationNodes; /// /// Configures Robot Intent and OpenUSD nodes. /// public async ValueTask ConfigureAsync(IRobotIntentBuildContext context, CancellationToken cancellationToken) { if (context == null) { throw new ArgumentNullException(nameof(context)); } m_manager = context.Manager; m_systemContext = context.Manager.SystemContext; await MaterialiseOpenUsdFacilityAsync(cancellationToken).ConfigureAwait(false); m_controller = await context.AddIntentControllerAsync( "UR5eIntentController", ConfigureController, cancellationToken).ConfigureAwait(false); await MaterialiseRepresentationsAsync(cancellationToken).ConfigureAwait(false); PublishSnapshot(m_executor.CurrentSnapshot); ArrayOf facets = m_controller.ComputeFacets(); m_logger.IntentCellReady(m_axes.Count, m_locations.Count, facets); } /// public void Dispose() { m_executor.SnapshotChanged -= OnSnapshotChanged; } private void ConfigureController(IIntentControllerBuilder controller) { controller .WithOperationalMode(OperationalModeEnum.AutomaticExternal) .WithReady(true) .WithMaxQueueDepth(16) .Accepts(cancelSupported: true) .Accepts(cancelSupported: true, pauseSupported: true) .Accepts(cancelSupported: true) .Accepts(cancelSupported: true) .Accepts(cancelSupported: true) .Accepts(cancelSupported: false) .Accepts(cancelSupported: true) .Accepts(cancelSupported: false) .Accepts(cancelSupported: true) .Accepts(cancelSupported: false) .Accepts(cancelSupported: true, pauseSupported: true, retrySupported: true) .WithSafetyState(m_safetySource); controller.State.Capabilities!.MissionsSupported!.Value = true; controller.State.Capabilities.MissionHorizonSupported!.Value = true; controller.State.Capabilities.MissionBranchingSupported!.Value = true; controller.State.Capabilities.BlendingSupported!.Value = false; controller.State.Capabilities.ForceControlSupported!.Value = false; controller.State.Capabilities.MaxTrajectoryPoints!.Value = 64u; IIntentFrameBuilder world = controller.AddFrame( "World", WorldFrameId, FrameRoleEnum.World, Pose(WorldFrameId, 0.0, 0.0, 0.0)); IIntentFrameBuilder @base = controller.AddFrame( "Base", BaseFrameId, FrameRoleEnum.Base, Pose(WorldFrameId, 0.0, 0.0, 0.829), frame => frame.WithParent(world)); IIntentFrameBuilder flange = controller.AddFrame( "MechanicalInterface", FlangeFrameId, FrameRoleEnum.MechanicalInterface, Pose(BaseFrameId, 0.0, 0.0, 0.1625), frame => frame.WithParent(@base)); IIntentFrameBuilder tool = controller.AddFrame( "GripperTcp", ToolFrameId, FrameRoleEnum.Tool, Pose(FlangeFrameId, 0.0, 0.0, 0.115), frame => frame.WithParent(flange)); controller.AddTool("ParallelGripper", tool, fitted: true); for (uint index = 0; index < s_axisUsd.Length; index++) { IIntentAxisBuilder axis = controller.AddAxis(s_axisUsd[index].Name, index, AxisKindEnum.Revolute); ConfigureAxis(axis.State, index == 2 ? -Math.PI : -FullTurn, index == 2 ? Math.PI : FullTurn); m_axes.Add(axis.State); } foreach ((string name, string _, double x, double y, double z, double rz) in s_locations) { uint capacity = string.Equals(name, "Bin", StringComparison.Ordinal) || string.Equals(name, "Fixture", StringComparison.Ordinal) ? PayloadSlotCount : 1u; IIntentLocationBuilder location = controller.AddLocation( name, Pose(WorldFrameId, x, y, z, rz), builder => builder.WithOccupancy(string.Equals(name, "Bin", StringComparison.Ordinal), capacity)); m_locations.Add(location.State); m_locationNodes[name] = location.State.NodeId; } IIntentOutputSignalBuilder gripperOpen = controller.AddOutput( "GripperOpen", Opc.Ua.DataTypeIds.Boolean, ToVariant(true)); IIntentOutputSignalBuilder benchLight = controller.AddOutput( "BenchLight", Opc.Ua.DataTypeIds.Boolean, ToVariant(false)); IIntentOutputSignalBuilder heldPartPosition = controller.AddOutput( "HeldPartPosition", Opc.Ua.DataTypeIds.Double, ToVariant(m_executor.CurrentSnapshot.HeldPartPosition)); IIntentOutputSignalBuilder heldPartVisible = controller.AddOutput( "HeldPartVisible", Opc.Ua.DataTypeIds.Boolean, ToVariant(false)); m_gripperOpenValue = gripperOpen.State.Value; m_benchLightValue = benchLight.State.Value; m_heldPartPositionValue = heldPartPosition.State.Value; m_heldPartVisibleValue = heldPartVisible.State.Value; for (int ii = 0; ii < PayloadSlotCount; ii++) { IIntentOutputSignalBuilder slotFilled = controller.AddOutput( $"PayloadSlot{ii + 1:00}Filled", Opc.Ua.DataTypeIds.Boolean, ToVariant(false)); m_payloadSlotFilledValues.Add(slotFilled.State.Value); } controller.AddRealTimeChannel( "JointTelemetry", "joint-telemetry", RealTimeTransportEnum.OpcUaFx, "udp://239.0.0.40:4840"); controller.WithDescription(description => description .WithKinematicChain(CreateKinematicChain()) .WithLimits( SimulatedArmKinematics.Reach, payloadLimit: 5.0, maxCartesianSpeed: 0.25, maxCartesianAcceleration: 0.7)); } private void ConfigureAxis(global::Opc.Ua.RobotIntent.AxisState axis, double min, double max) { axis.CreateOrReplaceMinPosition(SystemContext, null!).Value = min; axis.CreateOrReplaceMaxPosition(SystemContext, null!).Value = max; axis.CreateOrReplaceMaxSpeed(SystemContext, null!).Value = 2.0; if (axis.Position != null) { axis.Position.Value = 0.0; } } private ArrayOf CreateKinematicChain() { var joints = new KinematicJointDataType[s_axisUsd.Length]; for (int ii = 0; ii < joints.Length; ii++) { joints[ii] = new KinematicJointDataType { AxisId = s_axisUsd[ii].Name, Kind = AxisKindEnum.Revolute, OriginTransform = Pose( ii == 0 ? BaseFrameId : s_axisUsd[ii - 1].Name, 0.0, 0.0, ii == 0 ? 0.1625 : 0.12), AxisVector = s_axisUsd[ii].AxisVector }; } return joints.ToArrayOf(); } private void OnSnapshotChanged(object? sender, SimulatedArmSnapshot snapshot) { PublishSnapshot(snapshot); } private void PublishSnapshot(SimulatedArmSnapshot snapshot) { for (int ii = 0; ii < m_axes.Count && ii < snapshot.JointAngles.Count; ii++) { global::Opc.Ua.RobotIntent.AxisState axis = m_axes[ii]; if (axis.Position != null) { axis.Position.Value = snapshot.JointAngles[ii] * 180.0 / Math.PI; axis.Position.ClearChangeMasks(SystemContext, true); } } if (m_gripperOpenValue != null) { m_gripperOpenValue.Value = snapshot.GripperOpening > 0.04; m_gripperOpenValue.ClearChangeMasks(SystemContext, true); } if (m_benchLightValue != null) { m_benchLightValue.Value = snapshot.HasObject; m_benchLightValue.ClearChangeMasks(SystemContext, true); } if (m_heldPartPositionValue != null) { m_heldPartPositionValue.Value = snapshot.HeldPartPosition; m_heldPartPositionValue.ClearChangeMasks(SystemContext, true); } if (m_heldPartVisibleValue != null) { m_heldPartVisibleValue.Value = snapshot.HasObject; m_heldPartVisibleValue.ClearChangeMasks(SystemContext, true); } int filledCount = 0; for (int ii = 0; ii < m_payloadSlotFilledValues.Count && ii < snapshot.StackSlotsFilled.Count; ii++) { bool filled = snapshot.StackSlotsFilled[ii]; if (filled) { filledCount++; } BaseVariableState? slot = m_payloadSlotFilledValues[ii]; if (slot != null) { slot.Value = filled; slot.ClearChangeMasks(SystemContext, true); } } UpdateLocationOccupancy("Bin", filledCount < PayloadSlotCount || snapshot.HasObject); UpdateLocationOccupancy("Fixture", filledCount > 0); } private void UpdateLocationOccupancy(string name, bool occupied) { global::Opc.Ua.RobotIntent.LocationState? location = FindLocation(name); if (location?.Occupied != null) { location.Occupied.Value = occupied; location.Occupied.ClearChangeMasks(SystemContext, true); } } private static Pose3DDataType Pose(string frameId, double x, double y, double z, double rzDegrees = 0.0) { return PoseMath.FromThreeDFrame( new ThreeDFrame { CartesianCoordinates = new ThreeDCartesianCoordinates { X = x, Y = y, Z = z }, Orientation = new ThreeDOrientation { C = rzDegrees * Math.PI / 180.0 } }, frameId); } private static ArrayOf Vector(double x, double y, double z) { return new[] { x, y, z }.ToArrayOf(); } private static Variant ToVariant(bool value) { var builder = new VariantBuilder(); return ((IVariantBuilder)builder).WithValue(value); } private static Variant ToVariant(double value) { var builder = new VariantBuilder(); return ((IVariantBuilder)builder).WithValue(value); } private static Variant ToVariant(ArrayOf value) { var builder = new VariantBuilder(); return ((IVariantBuilder>)builder).WithValue(value); } private static Guid GuidFor(string key) { byte[] hash; #pragma warning disable CA1850 // Prefer static HashData (net48/netstandard2.0 compatibility) using (var sha = System.Security.Cryptography.SHA256.Create()) { hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes("minimal-intent:" + key)); } #pragma warning restore CA1850 byte[] guidBytes = new byte[16]; Array.Copy(hash, guidBytes, guidBytes.Length); return new Guid(guidBytes); } private const string WorldFrameId = "world"; private const string BaseFrameId = "ur5e-base"; private const string FlangeFrameId = "ur5e-flange"; private const string ToolFrameId = "parallel-gripper-tcp"; private const double FullTurn = Math.PI * 2.0; private const int PayloadSlotCount = 8; private static readonly (string Name, string PrimPath, double X, double Y, double Z, double Rz)[] s_locations = [ ("Bin", "/World/Targets/Bin", 0.41, -0.28, 0.829, 0.0), ("Fixture", "/World/Targets/Fixture", 0.48, 0.26, 0.829, 25.0), ("Inspect", "/World/Targets/Inspect", -0.25, 0.30, 0.829, -20.0), ("Handoff", "/World/Targets/Handoff", -0.46, -0.26, 0.829, 40.0) ]; private static readonly (string Name, string PrimPath, string RotateOp, ArrayOf AxisVector)[] s_axisUsd = [ ("J1", "/Arm/Base/J1", "xformOp:rotateZ", Vector(0.0, 0.0, 1.0)), ("J2", "/Arm/Base/J1/J2", "xformOp:rotateY", Vector(0.0, 1.0, 0.0)), ("J3", "/Arm/Base/J1/J2/J3", "xformOp:rotateY", Vector(0.0, 1.0, 0.0)), ("J4", "/Arm/Base/J1/J2/J3/J4", "xformOp:rotateY", Vector(0.0, 1.0, 0.0)), ("J5", "/Arm/Base/J1/J2/J3/J4/J5", "xformOp:rotateZ", Vector(0.0, 0.0, 1.0)), ("J6", "/Arm/Base/J1/J2/J3/J4/J5/J6", "xformOp:rotateY", Vector(0.0, 1.0, 0.0)) ]; private readonly ILogger m_logger; private readonly SimulatedArmExecutor m_executor; private readonly SampleSafetySource m_safetySource; private readonly List m_axes = []; private readonly List m_locations = []; private readonly Dictionary m_locationNodes = new(StringComparer.Ordinal); private readonly List m_payloadSlotFilledValues = []; private AsyncCustomNodeManager? m_manager; private ServerSystemContext? m_systemContext; private IIntentControllerBuilder? m_controller; private BaseVariableState? m_gripperOpenValue; private BaseVariableState? m_benchLightValue; private BaseVariableState? m_heldPartPositionValue; private BaseVariableState? m_heldPartVisibleValue; } internal static partial class IntentRobotCellLog { [LoggerMessage(EventId = IntentEnabledRobotEventIds.IntentRobotCell + 1, Level = LogLevel.Information, Message = "Materialised IntentEnabledRobot ({AxisCount} axes, " + "{LocationCount} locations, facets {Facets}).")] public static partial void IntentCellReady( this ILogger logger, int axisCount, int locationCount, ArrayOf facets); } /// /// Mutable read-only safety source driven by the console sample commands. /// public sealed class SampleSafetySource : IRobotIntentSafetySource { /// public ValueTask ReadAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); lock (m_lock) { return ValueTask.FromResult(m_snapshot); } } internal void Reset() { Set(new RobotIntentSafetySnapshot( SafeMotionFunctionEnum.None, EmergencyStopActive: false, ProtectiveStopActive: false, SafeSpeedLimitActive: false, SafeSpeedLimit: 0.0, SafetyControllerOk: true, LastStopReason: LocalizedText.Null)); } internal void TripProtectiveStop() { Set(new RobotIntentSafetySnapshot( SafeMotionFunctionEnum.Ss1, EmergencyStopActive: false, ProtectiveStopActive: true, SafeSpeedLimitActive: false, SafeSpeedLimit: 0.0, SafetyControllerOk: true, LastStopReason: LocalizedText.From("Simulated protective stop"))); } internal void LimitSpeed(double limit) { Set(new RobotIntentSafetySnapshot( SafeMotionFunctionEnum.Sls, EmergencyStopActive: false, ProtectiveStopActive: false, SafeSpeedLimitActive: true, SafeSpeedLimit: limit, SafetyControllerOk: true, LastStopReason: LocalizedText.From($"Simulated safe speed limit {limit:0.###} m/s"))); } private void Set(RobotIntentSafetySnapshot snapshot) { lock (m_lock) { m_snapshot = snapshot; } } private readonly Lock m_lock = new(); private RobotIntentSafetySnapshot m_snapshot = new( SafeMotionFunctionEnum.None, EmergencyStopActive: false, ProtectiveStopActive: false, SafeSpeedLimitActive: false, SafeSpeedLimit: 0.0, SafetyControllerOk: true, LastStopReason: LocalizedText.Null); } internal sealed class SafetyAwareArmExecutor : IIntentExecutor { public SafetyAwareArmExecutor(SimulatedArmExecutor inner, SampleSafetySource safety) { m_inner = inner ?? throw new ArgumentNullException(nameof(inner)); m_safety = safety ?? throw new ArgumentNullException(nameof(safety)); } public async ValueTask ExecuteAsync( IntentExecution execution, CancellationToken cancellationToken) { RobotIntentSafetySnapshot safety = await m_safety.ReadAsync(cancellationToken).ConfigureAwait(false); if (safety.ProtectiveStopActive || safety.EmergencyStopActive || !safety.SafetyControllerOk) { return IntentOutcome.Fail( IntentFailureEnum.NotPermittedInMode, safety.LastStopReason.Text ?? "The simulated safety system is stopping motion."); } if (safety.SafeSpeedLimitActive && safety.SafeSpeedLimit > 0.0 && ExceedsSafeSpeed(execution.Intent, safety.SafeSpeedLimit)) { return IntentOutcome.Fail( IntentFailureEnum.SafetyLimitExceeded, $"The requested speed exceeds the simulated safe limit of {safety.SafeSpeedLimit:0.###} m/s."); } if (execution.Intent is LinearMoveIntentDataType linear) { IntentOutcome approach = await MoveToApproachAsync( execution, linear, cancellationToken).ConfigureAwait(false); if (approach.State != ExecutionStateEnum.Succeeded) { return approach; } } return await m_inner.ExecuteAsync(execution, cancellationToken).ConfigureAwait(false); } public bool CanCancel(IntentExecution execution) { return m_inner.CanCancel(execution); } private async ValueTask MoveToApproachAsync( IntentExecution execution, LinearMoveIntentDataType linear, CancellationToken cancellationToken) { Pose3DDataType target = linear.Target; ReadOnlySpan position = target.Position.Span; if (position.Length != 3) { return IntentOutcome.Fail( IntentFailureEnum.ParameterInvalid, "A linear move target must carry a 3D position."); } var approach = new Pose3DDataType { FrameId = target.FrameId, Position = new[] { position[0], position[1], position[2] - TargetApproachOffset }.ToArrayOf(), Orientation = target.Orientation.IsNull ? ArrayOf.Empty : target.Orientation.Span.ToArray().ToArrayOf() }; var jointMove = new JointMoveIntentDataType { IntentId = execution.IntentId + "-approach", Label = LocalizedText.From("Approach " + linear.Label.Text), Constraints = linear.Constraints }; if (m_kinematics.TrySelectNearest( approach, m_inner.CurrentSnapshot.JointAngles.Span, out SimulatedArmIkSolution? solution, out SimulatedArmKinematicFailure failure)) { jointMove.HasJointTargets = true; jointMove.JointTargets = solution.JointAngles; } else { return IntentOutcome.Fail( SimulatedArmKinematics.ToIntentFailure(failure), "The target approach pose cannot be reached."); } var approachExecution = new IntentExecution( execution.IntentId + "-approach", jointMove, execution.Progress) { MissionId = execution.MissionId }; return await m_inner.ExecuteAsync(approachExecution, cancellationToken).ConfigureAwait(false); } private static bool ExceedsSafeSpeed(IntentDataType intent, double safeSpeedLimit) { return intent is MotionIntentDataType motion && motion.Constraints.CartesianSpeed > 0.0 && motion.Constraints.CartesianSpeed > safeSpeedLimit; } private const double TargetApproachOffset = 0.05; private readonly SimulatedArmExecutor m_inner; private readonly SimulatedArmKinematics m_kinematics = new(); private readonly SampleSafetySource m_safety; } internal sealed class SafetyConsoleService : Microsoft.Extensions.Hosting.BackgroundService { public SafetyConsoleService(SampleSafetySource safety, ILogger logger) { m_safety = safety ?? throw new ArgumentNullException(nameof(safety)); m_logger = logger ?? throw new ArgumentNullException(nameof(logger)); } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { m_logger.SafetyCommandsReady(); while (!stoppingToken.IsCancellationRequested) { string? line = await Console.In.ReadLineAsync(stoppingToken).ConfigureAwait(false); if (line == null) { return; } ApplyCommand(line.Trim()); } } private void ApplyCommand(string command) { if (command.Equals("stop", StringComparison.OrdinalIgnoreCase)) { m_safety.TripProtectiveStop(); m_logger.ProtectiveStopActive(); } else if (command.StartsWith("limit", StringComparison.OrdinalIgnoreCase)) { string[] parts = command.Split( ' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); double limit = parts.Length > 1 && double.TryParse(parts[1], out double parsed) ? parsed : 0.05; m_safety.LimitSpeed(limit); m_logger.SafeSpeedLimitActive(limit); } else if (command.Equals("reset", StringComparison.OrdinalIgnoreCase)) { m_safety.Reset(); m_logger.SafetyReset(); } } private readonly SampleSafetySource m_safety; private readonly ILogger m_logger; } internal static partial class SafetyConsoleLog { [LoggerMessage(EventId = IntentEnabledRobotEventIds.SafetyConsole + 1, Level = LogLevel.Information, Message = "Safety commands: type 'stop', 'limit ' or 'reset' in the console.")] public static partial void SafetyCommandsReady(this ILogger logger); [LoggerMessage(EventId = IntentEnabledRobotEventIds.SafetyConsole + 2, Level = LogLevel.Warning, Message = "Simulated protective stop is active; submitted intents are refused with NotPermittedInMode.")] public static partial void ProtectiveStopActive(this ILogger logger); [LoggerMessage(EventId = IntentEnabledRobotEventIds.SafetyConsole + 3, Level = LogLevel.Warning, Message = "Simulated safe speed limit is {Limit} m/s; faster motions are refused " + "with SafetyLimitExceeded.")] public static partial void SafeSpeedLimitActive(this ILogger logger, double limit); [LoggerMessage(EventId = IntentEnabledRobotEventIds.SafetyConsole + 4, Level = LogLevel.Information, Message = "Simulated safety state reset to nominal.")] public static partial void SafetyReset(this ILogger logger); } }