UNPKG

@wandelbots/wandelbots-js-react-components

Version:

React UI toolkit for building applications on top of the Wandelbots platform

675 lines (582 loc) • 20.8 kB
import { type AutoReconnectingWebsocket, tryParseJson, XYZ_TO_VECTOR, } from "@wandelbots/nova-js" import type { InitializeMovementResponse, MotionCommand, NovaClient, Pose, StartMovementResponse, } from "@wandelbots/nova-js/v2" import { when } from "mobx" import { Vector3 } from "three/src/math/Vector3.js" import { MotionStreamConnection } from "./MotionStreamConnection" export type Vector3Simple = [number, number, number] const API_ERROR_CONNECTION_BLOCKED = `Movement request rejected. Another client is currently executing a 'Jogging' motion!` export type JoggerConnectionOptions = { // The mode of the jogger connection - see type description of JoggerMode for details mode?: JoggerMode // Connection timeout in milliseconds to wait for jogging initialization to complete timeout?: number /** * When an error message is received from the jogging websocket, it * will be passed here. If this handler is not provided, the error will * instead be thrown as an unhandled error. */ onError?: (err: unknown) => void // The TCP to use for jogging motions. tcp?: string // The coordinate system in which jogging takes place in // coordinateSystem?: string, // If set to "tool", jogging TcpVelocityRequest will use `use_tool_coordinate_system` option orientation?: JoggerOrientation } // Three modes are supported: // - "jogging": Continuous jogging mode, where joint velocities or TCP velocities can be commanded. Opens a single websocket connection to jogging endpoint // - "trajectory": Incremental jogging mode, where fixed distance motions are planned and executed. Opens a short lived websocket for each motion. // - "off": No jogging, all websockets are closed. export type JoggerMode = "jogging" | "trajectory" | "off" // If set to "tool", will use `use_tool_coordinate_system` option in movement requests export type JoggerOrientation = "coordsys" | "tool" export class JoggerConnection { ENDPOINT_JOGGING = "/execution/jogging" ENDPOINT_TRAJECTORY = "/execution/trajectory" DEFAULT_MODE = "off" as JoggerMode DEFAULT_TCP = "Flange" // DEFAULT_COORDINATE_SYSTEM = "world" DEFAULT_INIT_TIMEOUT = 5000 DEFAULT_ORIENTATION = "coordsys" as JoggerOrientation mode: JoggerMode = "off" joggingSocket: AutoReconnectingWebsocket | null = null trajectorySocket: AutoReconnectingWebsocket | null = null timeout: number = this.DEFAULT_INIT_TIMEOUT tcp: string // coordinateSystem?: string orientation: JoggerOrientation onError?: (err: unknown) => void onBlocked?: () => void /** * Initialize the jogging connection using jogging endpoint or trajectory endpoint depending on the selected mode. * * @param nova - The Nova client instance * @param motionGroupId - The ID of the motion group to connect to * @param options - Configuration options for the jogger connection * @param options.mode - The jogging mode to initialize: * - "jogging": Continuous jogging mode with persistent websocket for real-time velocity commands * - "trajectory": Incremental jogging mode for fixed-distance motions via trajectory planning * - "off": No jogging enabled, all websockets closed (default) * @param options.timeout - Timeout in milliseconds for websocket initialization (default: 5000ms) * @param options.tcp - TCP frame to use for motions (default: from motion group) * //param options.coordinateSystem - Coordinate system for jogging commands (default: "world"). Please note: Currently not implemented * @param options.orientation - If set to "tool", jogging TcpVelocityRequest will use `use_tool_coordinate_system` option (default: "coordsys") * @param options.onError - Error handler for websocket errors * @returns Promise resolving to initialized JoggerConnection instance */ static async open( nova: NovaClient, motionGroupId: string, options: JoggerConnectionOptions = {}, ) { // Get matching motion stream const motionStream = await MotionStreamConnection.open(nova, motionGroupId) // Initialize jogger with options const jogger = new JoggerConnection(motionStream, options) // Initialize the jogging websocket, if necessary (mode is "jogging") await jogger.setJoggingMode(jogger.mode) // Return the initialized jogger return jogger } constructor( readonly motionStream: MotionStreamConnection, readonly options: JoggerConnectionOptions | undefined = {}, ) { this.tcp = options?.tcp || motionStream.motionGroup.tcp || this.DEFAULT_TCP // this.coordinateSystem = options?.coordinateSystem || this.DEFAULT_COORDINATE_SYSTEM this.orientation = options?.orientation || this.DEFAULT_ORIENTATION this.timeout = options?.timeout || this.DEFAULT_INIT_TIMEOUT this.mode = options?.mode || this.DEFAULT_MODE this.onError = options?.onError } // Set a new tcp or other options. If current mode is jogging, will reinitialize the jogging websocket async setOptions(options: Partial<JoggerConnectionOptions>) { if (options.tcp) { this.tcp = options.tcp } if (options.orientation) { this.orientation = options.orientation } // if (options.coordinateSystem) { // this.coordinateSystem = options.coordinateSystem // } if (options.timeout) { this.timeout = options.timeout } if (options.mode) { this.mode = options.mode } if (options.onError) { this.onError = options.onError } this.setJoggingMode(this.mode, false) } get motionGroupId() { return this.motionStream.motionGroupId } get nova() { return this.motionStream.nova } get numJoints() { return this.motionStream.joints.length } // get activeJoggingMode() { // return this.mode // } // get activeWebsocket() { // return this.mode === "jogging" ? this.joggingSocket : this.trajectorySocket; // } // Sends stop movement command to robot async stop() { if (this.joggingSocket) { const velocity = new Array(this.numJoints).fill(0) this.joggingSocket.sendJson({ message_type: "JointVelocityRequest", velocity, }) } if (this.trajectorySocket) { this.trajectorySocket.sendJson({ message_type: "PauseMovementRequest", }) } } // Dispose the jogger, closing all open websockets async dispose() { // Collect all initialized sockets const sockets = [this.joggingSocket, this.trajectorySocket].filter( (s) => s !== null, ) as AutoReconnectingWebsocket[] // Call them to dispose sockets.forEach((s) => { s.dispose() }) // Remove references this.joggingSocket = null this.trajectorySocket = null // Return promise that resolves when all sockets are closed return Promise.all(sockets.map((s) => s.closed())) } // Activate jogger in one of the supported modes // Will iniitialize or close websockets as necessary // If mode is unchanged, does nothing (unless skipReinitializeIfSameMode is false) async setJoggingMode(mode: JoggerMode, skipReinitializeIfSameMode = true) { // If not changing mode, do nothing if (this.mode === mode && skipReinitializeIfSameMode) { return } // Close any previous websocket connection this.dispose() // Set the new mode this.mode = mode // Mode is "jogging" - open jogging websocket if (this.mode === "jogging") { return this.initializeJoggingWebsocket() } // Mode is "trajectory" or "off" - nothing more to do return } // Initializes continuous jogging websocket, called by setJoggingMode("jogging") async initializeJoggingWebsocket() { // Wait for initialization with timeout return new Promise<void>((resolve, reject) => { const connectionFailedTimeout = setTimeout(() => { reject( new Error( `Jogging initialization timeout after ${this.timeout} seconds`, ), ) }, this.timeout) this.joggingSocket = this.nova.openReconnectingWebsocket( this.ENDPOINT_JOGGING, ) this.joggingSocket.addEventListener("message", (ev: MessageEvent) => { const data = tryParseJson(ev.data) if (data?.result?.kind === "INITIALIZE_RECEIVED") { clearTimeout(connectionFailedTimeout) resolve() return } if (data?.result?.kind === "MOTION_ERROR") { clearTimeout(connectionFailedTimeout) if ( this.onBlocked && data?.result?.message.includes(API_ERROR_CONNECTION_BLOCKED) ) { this.joggingSocket?.dispose() this.onBlocked() return } else if (this.onError) { this.onError(ev.data) } else { reject(new Error(ev.data)) } } }) this.joggingSocket.sendJson({ message_type: "InitializeJoggingRequest", motion_group: this.motionGroupId, tcp: this.tcp, }) }) } /** * Jogging: Start rotation of a single robot joint at the specified velocity */ async rotateJoints({ joint, direction, velocityRadsPerSec, }: { /** Index of the joint to rotate */ joint: number /** Direction of rotation ("+" or "-") */ direction: "+" | "-" /** Speed of the rotation in radians per second */ velocityRadsPerSec: number }) { if (!this.joggingSocket || this.mode !== "jogging") { throw new Error( "Joint jogging websocket not connected; create one by setting jogging mode to 'jogging'", ) } const velocity = new Array(this.numJoints).fill(0) velocity[joint] = direction === "-" ? -velocityRadsPerSec : velocityRadsPerSec this.joggingSocket.sendJson({ message_type: "JointVelocityRequest", velocity, }) } /** * Jogging: Start the TCP moving along a specified axis at a given velocity */ async translateTCP({ axis, direction, velocityMmPerSec, }: { axis: "x" | "y" | "z" direction: "-" | "+" velocityMmPerSec: number }) { if (!this.joggingSocket || this.mode !== "jogging") { throw new Error( "Continuous jogging websocket not connected; create one by setting jogging mode to 'jogging'", ) } const rotation = [0, 0, 0] const translation = [0, 0, 0] translation[XYZ_TO_VECTOR[axis]] = direction === "-" ? -velocityMmPerSec : velocityMmPerSec this.joggingSocket.sendJson({ message_type: "TcpVelocityRequest", translation, rotation, use_tool_coordinate_system: this.orientation === "tool", }) } /** * Jogging: Start the TCP rotating around a specified axis at a given velocity */ async rotateTCP({ axis, direction, velocityRadsPerSec, }: { axis: "x" | "y" | "z" direction: "-" | "+" velocityRadsPerSec: number }) { if (!this.joggingSocket || this.mode !== "jogging") { throw new Error( "Continuous jogging websocket not connected; create one by setting jogging mode to 'jogging'", ) } const rotation = [0, 0, 0] const translation = [0, 0, 0] rotation[XYZ_TO_VECTOR[axis]] = direction === "-" ? -velocityRadsPerSec : velocityRadsPerSec this.joggingSocket.sendJson({ message_type: "TcpVelocityRequest", translation, rotation, }) } /** * Trajectory jogging: * * Move the robot by a fixed distance in a single cartesian * axis, either rotating or translating relative to the TCP. * Promise resolves only after the motion has completed. */ async runIncrementalCartesianMotion({ currentTcpPose, currentJoints, // coordSystemId, velocityInRelevantUnits, axis, direction, motion, }: { currentTcpPose: Pose currentJoints: Vector3Simple coordSystemId: string velocityInRelevantUnits: number axis: "x" | "y" | "z" direction: "-" | "+" motion: | { type: "rotate" distanceRads: number } | { type: "translate" distanceMm: number } }) { const commands: MotionCommand[] = [] if (this.mode !== "trajectory") { throw new Error( "Set jogging mode to 'trajectory' to run incremental cartesian motions", ) } if (motion.type === "translate") { if (!currentTcpPose.position) { throw new Error( "Current pose has no position, cannot perform translation", ) } const targetTcpPosition = [...currentTcpPose.position] targetTcpPosition[XYZ_TO_VECTOR[axis]] += motion.distanceMm * (direction === "-" ? -1 : 1) commands.push({ limits_override: { tcp_velocity_limit: velocityInRelevantUnits, }, path: { path_definition_name: "PathLine", target_pose: { position: targetTcpPosition, orientation: currentTcpPose.orientation, }, }, }) } else if (motion.type === "rotate") { // Concatenate rotations expressed by rotation vectors // Equations taken from https://physics.stackexchange.com/a/287819 if (!currentTcpPose.orientation) { throw new Error( "Current pose has no orientation, cannot perform rotation", ) } // Compute axis and angle of current rotation vector const currentRotationVector = new Vector3( currentTcpPose.orientation[0], currentTcpPose.orientation[1], currentTcpPose.orientation[2], ) const currentRotationRad = currentRotationVector.length() const currentRotationDirection = currentRotationVector.clone().normalize() // Compute axis and angle of difference rotation vector const differenceRotationRad = motion.distanceRads * (direction === "-" ? -1 : 1) const differenceRotationDirection = new Vector3(0.0, 0.0, 0.0) differenceRotationDirection[axis] = 1.0 // Some abbreviations to make the following equations more readable const f1 = Math.cos(0.5 * differenceRotationRad) * Math.cos(0.5 * currentRotationRad) const f2 = Math.sin(0.5 * differenceRotationRad) * Math.sin(0.5 * currentRotationRad) const f3 = Math.sin(0.5 * differenceRotationRad) * Math.cos(0.5 * currentRotationRad) const f4 = Math.cos(0.5 * differenceRotationRad) * Math.sin(0.5 * currentRotationRad) const dotProduct = differenceRotationDirection.dot( currentRotationDirection, ) const crossProduct = differenceRotationDirection .clone() .cross(currentRotationDirection) // Compute angle of concatenated rotation const newRotationRad = 2.0 * Math.acos(f1 - f2 * dotProduct) // Compute rotation vector of concatenated rotation const f5 = newRotationRad / Math.sin(0.5 * newRotationRad) const targetTcpOrientation = new Vector3() .addScaledVector(crossProduct, f2) .addScaledVector(differenceRotationDirection, f3) .addScaledVector(currentRotationDirection, f4) .multiplyScalar(f5) commands.push({ limits_override: { tcp_orientation_velocity_limit: velocityInRelevantUnits, }, path: { path_definition_name: "PathLine", target_pose: { position: currentTcpPose.position, orientation: [...targetTcpOrientation], }, }, }) } // Plan the motion https://portal.wandelbots.io/docs/api/v2/ui/#/operations/planTrajectory const description = this.motionStream.description if (description.cycle_time === undefined) { console.warn( "Current motion group has no cycle time, cannot plan jogging motion", ) return } const motion_group_setup = { motion_group_model: description.motion_group_model, cycle_time: description.cycle_time, mounting: description.mounting, // tcp_offset: description.tcp_offset, TODO: implement tcp_offset handling // FIXME use proper mode's limits if set global: description.operation_limits.auto_limits, } const motionPlanRes = await this.nova.api.trajectoryPlanning.planTrajectory( { motion_group_setup, start_joint_position: currentJoints, motion_commands: commands, }, ) const trajectoryData = motionPlanRes.response if (!trajectoryData) { throw new Error( `Failed to plan jogging increment motion ${JSON.stringify(motionPlanRes)}`, ) } if (this.trajectorySocket) { console.warn("Trajectory jogging websocket already open; will close") this.trajectorySocket.dispose() } // Execute the planned motion https://portal.wandelbots.io/docs/api/v2/ui/#/operations/executeTrajectory this.trajectorySocket = this.nova.openReconnectingWebsocket( this.ENDPOINT_TRAJECTORY, ) const messageInitializeMovementResponse = ( result: InitializeMovementResponse | undefined, ) => { // Handle errorous response if (!result || result.add_trajectory_error || result.message) { if (this.onError) { this.onError(result) } else { throw new Error( result?.add_trajectory_error?.message || result?.message || "Failed to execute trajectory, unknown error", ) } } // Handle socket gone if (!this.trajectorySocket) { throw new Error( `Failed to execute trajectory, websocket not available anymore`, ) } // Trajectory locked, now start movement this.trajectorySocket.sendJson({ message_type: "StartMovementRequest", direction: "DIRECTION_FORWARD", }) } const waitForMovementToStartAndFinish = async () => { // Wait for robot to start moving (standstill becomes false) await when(() => !this.motionStream.rapidlyChangingMotionState.standstill) // Then wait for robot to stop moving (standstill becomes true) await when(() => this.motionStream.rapidlyChangingMotionState.standstill) // Close connection and free robot this.trajectorySocket?.dispose() this.trajectorySocket = null } const waitForMovementToFinish = async () => { // Wait for robot to stop moving (standstill becomes true) await when(() => this.motionStream.rapidlyChangingMotionState.standstill) // Close connection and free robot this.trajectorySocket?.dispose() this.trajectorySocket = null } const messageStartMovementResponse = async ( data: StartMovementResponse, ) => { if (data?.message) { if (this.onError) { this.onError(data) return } else { throw new Error( data.message || "Failed to execute trajectory, unknown error", ) } } // Movement started we now wait to verify the robot is moving // by observing changes to motion state if (this.motionStream.rapidlyChangingMotionState.standstill) { await waitForMovementToStartAndFinish() } else { await waitForMovementToFinish() } } this.trajectorySocket.addEventListener("message", (ev: MessageEvent) => { const data = tryParseJson(ev.data) if (!data?.result?.kind) { throw new Error( `Failed to execute trajectory: Received invalid message ${ev.data}`, ) } if ( this.onBlocked && data.result.message?.includes(API_ERROR_CONNECTION_BLOCKED) ) { this.onBlocked() return } if (data.result.kind === "INITIALIZE_RECEIVED") { messageInitializeMovementResponse(data.result) } else if (data.result.kind === "START_RECEIVED") { messageStartMovementResponse(data) } else if (data.result.kind === "PAUSE_RECEIVED") { // do nothing } else if (data.result.kind === "MOTION_ERROR" && data.result.message) { if (this.onError) { this.onError(data) return } else { throw new Error(data.result.message) } } else { throw new Error( `Failed to execute trajectory, cannot handle message type "${data.result.kind}"`, ) } }) // Send initialization/movement request this.trajectorySocket.sendJson({ message_type: "InitializeMovementRequest", trajectory: { message_type: "TrajectoryData", motion_group: this.motionGroupId, data: trajectoryData, tcp: this.tcp, }, }) } }