UNPKG

@wandelbots/nova-js

Version:

Official JS client for the Wandelbots API

1 lines 123 kB
{"version":3,"file":"NovaClient-D2EItmiH.cjs","names":["MOTION_DELTA_THRESHOLD","nova: NovaClient","controller: ControllerInstance","motionGroup: MotionGroupPhysical","initialMotionState: MotionGroupStateResponse","motionStateSocket: AutoReconnectingWebsocket","isVirtual: boolean","tcps: RobotTcp[]","motionGroupSpecification: MotionGroupSpecification","safetySetup: SafetySetup","mounting: Mounting | null","initialControllerState: RobotControllerState","controllerStateSocket: AutoReconnectingWebsocket","THREE","motionStream: MotionStreamConnection","opts: JoggerConnectionOpts","commands: Command[]","Vector3","jointVelocityLimits: number[]","Vector3","nova: NovaClient","controller: ControllerInstance","motionGroup: MotionGroupPhysical","initialMotionState: MotionGroupStateResponse","motionStateSocket: AutoReconnectingWebsocket","cellId: string","opts: BaseConfiguration & {\n axiosInstance?: AxiosInstance\n mock?: boolean\n }","SystemApi","CellApi","DeviceConfigurationApi","MotionGroupApi","MotionGroupInfosApi","ControllerApi","ProgramApi","ProgramValuesApi","ControllerIOsApi","MotionGroupKinematicApi","MotionApi","CoordinateSystemsApi","ApplicationApi","MotionGroupJoggingApi","VirtualRobotApi","VirtualRobotSetupApi","VirtualRobotModeApi","VirtualRobotBehaviorApi","LibraryProgramMetadataApi","LibraryProgramApi","LibraryRecipeMetadataApi","LibraryRecipeApi","StoreObjectApi","StoreCollisionComponentsApi","StoreCollisionScenesApi","pathToRegexp","AxiosError","availableStorage","config","loginWithAuth0","AutoReconnectingWebsocket"],"sources":["../src/lib/converters.ts","../src/lib/v1/wandelscriptUtils.ts","../src/lib/v1/motionStateUpdate.ts","../src/lib/v1/ConnectedMotionGroup.ts","../src/lib/v1/JoggerConnection.ts","../src/lib/v1/MotionStreamConnection.ts","../src/lib/v1/NovaCellAPIClient.ts","../src/lib/v1/mock/MockNovaInstance.ts","../src/lib/v1/NovaClient.ts"],"sourcesContent":["/** Try to parse something as JSON; return undefined if we can't */\n// biome-ignore lint/suspicious/noExplicitAny: it's json\nexport function tryParseJson(json: unknown): any {\n try {\n return JSON.parse(json as string)\n } catch {\n return undefined\n }\n}\n\n/** Try to turn something into JSON; return undefined if we can't */\nexport function tryStringifyJson(json: unknown): string | undefined {\n try {\n return JSON.stringify(json)\n } catch {\n return undefined\n }\n}\n\n/**\n * Converts object parameters to query string.\n * e.g. { a: \"1\", b: \"2\" } => \"?a=1&b=2\"\n * {} => \"\"\n */\nexport function makeUrlQueryString(obj: Record<string, string>): string {\n const str = new URLSearchParams(obj).toString()\n return str ? `?${str}` : \"\"\n}\n\n/** Convert radians to degrees */\nexport function radiansToDegrees(radians: number): number {\n return radians * (180 / Math.PI)\n}\n\n/** Convert degrees to radians */\nexport function degreesToRadians(degrees: number): number {\n return degrees * (Math.PI / 180)\n}\n\n/**\n * Check for coordinate system id equivalence, accounting for the \"world\" default\n * on empty/undefined values.\n */\nexport function isSameCoordinateSystem(\n firstCoordSystem: string | undefined,\n secondCoordSystem: string | undefined,\n) {\n if (!firstCoordSystem) firstCoordSystem = \"world\"\n if (!secondCoordSystem) secondCoordSystem = \"world\"\n\n return firstCoordSystem === secondCoordSystem\n}\n\n/**\n * Helpful const for converting {x, y, z} to [x, y, z] and vice versa\n */\nexport const XYZ_TO_VECTOR = { x: 0, y: 1, z: 2 }\n","import type { Pose } from \"@wandelbots/nova-api/v1\"\n\n/**\n * Convert a Pose object representing a motion group position\n * into a string which represents that pose in Wandelscript.\n */\nexport function poseToWandelscriptString(\n pose: Pick<Pose, \"position\" | \"orientation\">,\n) {\n const position = [pose.position.x, pose.position.y, pose.position.z]\n const orientation = [\n pose.orientation?.x ?? 0,\n pose.orientation?.y ?? 0,\n pose.orientation?.z ?? 0,\n ]\n\n const positionValues = position.map((v) => v.toFixed(1))\n // Rotation needs more precision since it's in radians\n const rotationValues = orientation.map((v) => v.toFixed(4))\n\n return `(${positionValues.concat(rotationValues).join(\", \")})`\n}\n","import type { TcpPose } from \"@wandelbots/nova-api/v1\"\n\nexport function jointValuesEqual(\n oldJointValues: number[],\n newJointValues: number[],\n changeDeltaThreshold: number,\n): boolean {\n if (newJointValues.length !== oldJointValues.length) {\n return true\n }\n\n for (let jointIndex = 0; jointIndex < newJointValues.length; jointIndex++) {\n if (\n // biome-ignore lint/style/noNonNullAssertion: legacy code\n Math.abs(newJointValues[jointIndex]! - oldJointValues[jointIndex]!) >\n changeDeltaThreshold\n ) {\n return false\n }\n }\n\n return true\n}\n\nexport function tcpPoseEqual(\n oldTcp: TcpPose | undefined,\n newTcp: TcpPose | undefined,\n changeDeltaThreshold: number,\n): boolean {\n // undefined -> defined (+reverse) transition\n if ((oldTcp === undefined && newTcp) || (oldTcp && newTcp === undefined)) {\n return false\n }\n\n // the typechecker cannot resolve states to \"!= undefined\" if \"&&\" is used\n if (oldTcp === undefined || newTcp === undefined) {\n return true\n }\n\n let changedDelta = 0\n changedDelta += Math.abs(oldTcp.orientation.x - newTcp.orientation.x)\n changedDelta += Math.abs(oldTcp.orientation.y - newTcp.orientation.y)\n changedDelta += Math.abs(oldTcp.orientation.z - newTcp.orientation.z)\n changedDelta += Math.abs(oldTcp.position.x - newTcp.position.x)\n changedDelta += Math.abs(oldTcp.position.y - newTcp.position.y)\n changedDelta += Math.abs(oldTcp.position.z - newTcp.position.z)\n\n if (changedDelta > changeDeltaThreshold) {\n return false\n }\n\n return (\n oldTcp.coordinate_system === newTcp.coordinate_system &&\n oldTcp.tcp === newTcp.tcp\n )\n}\n","import type {\n ControllerInstance,\n MotionGroupPhysical,\n MotionGroupSpecification,\n MotionGroupStateResponse,\n Mounting,\n RobotControllerState,\n RobotControllerStateOperationModeEnum,\n RobotControllerStateSafetyStateEnum,\n RobotTcp,\n SafetySetup,\n} from \"@wandelbots/nova-api/v1\"\nimport { makeAutoObservable, runInAction } from \"mobx\"\nimport * as THREE from \"three\"\nimport type { AutoReconnectingWebsocket } from \"../AutoReconnectingWebsocket\"\nimport { tryParseJson } from \"../converters\"\nimport { jointValuesEqual, tcpPoseEqual } from \"./motionStateUpdate\"\nimport type { NovaClient } from \"./NovaClient\"\n\nconst MOTION_DELTA_THRESHOLD = 0.0001\n\nexport type MotionGroupOption = {\n selectionId: string\n} & MotionGroupPhysical\n\n/**\n * Store representing the current state of a connected motion group.\n */\nexport class ConnectedMotionGroup {\n static async connect(\n nova: NovaClient,\n motionGroupId: string,\n controllers: ControllerInstance[],\n ) {\n const [_motionGroupIndex, controllerId] = motionGroupId.split(\"@\") as [\n string,\n string,\n ]\n const controller = controllers.find((c) => c.controller === controllerId)\n const motionGroup = controller?.physical_motion_groups.find(\n (mg) => mg.motion_group === motionGroupId,\n )\n if (!controller || !motionGroup) {\n throw new Error(\n `Controller ${controllerId} or motion group ${motionGroupId} not found`,\n )\n }\n\n const motionStateSocket = nova.openReconnectingWebsocket(\n `/motion-groups/${motionGroupId}/state-stream`,\n )\n\n // Wait for the first message to get the initial state\n const firstMessage = await motionStateSocket.firstMessage()\n const initialMotionState = tryParseJson(firstMessage.data)\n ?.result as MotionGroupStateResponse\n\n if (!initialMotionState) {\n throw new Error(\n `Unable to parse initial motion state message ${firstMessage.data}`,\n )\n }\n\n console.log(\n `Connected motion state websocket to motion group ${motionGroup.motion_group}. Initial state:\\n `,\n initialMotionState,\n )\n\n // Check if robot is virtual or physical\n const config = await nova.api.controller.getRobotController(\n controller.controller,\n )\n const isVirtual = config.configuration.kind === \"VirtualController\"\n\n // If there's a configured mounting, we need it to show the right\n // position of the robot model\n const mounting = await (async () => {\n try {\n const mounting = await nova.api.motionGroupInfos.getMounting(\n motionGroup.motion_group,\n )\n return mounting\n } catch (err) {\n console.error(\n `Error fetching mounting for ${motionGroup.motion_group}`,\n err,\n )\n return null\n }\n })()\n\n // Open the websocket to monitor controller state for e.g. e-stop\n const controllerStateSocket = nova.openReconnectingWebsocket(\n `/controllers/${controller.controller}/state-stream?response_rate=1000`,\n )\n\n // Wait for the first message to get the initial state\n const firstControllerMessage = await controllerStateSocket.firstMessage()\n const initialControllerState = tryParseJson(firstControllerMessage.data)\n ?.result as RobotControllerState\n\n if (!initialControllerState) {\n throw new Error(\n `Unable to parse initial controller state message ${firstControllerMessage.data}`,\n )\n }\n\n console.log(\n `Connected controller state websocket to controller ${controller.controller}. Initial state:\\n `,\n initialControllerState,\n )\n\n // Find out what TCPs this motion group has (we need it for jogging)\n const { tcps } = await nova.api.motionGroupInfos.listTcps(motionGroupId)\n\n const motionGroupSpecification =\n await nova.api.motionGroupInfos.getMotionGroupSpecification(motionGroupId)\n\n const safetySetup =\n await nova.api.motionGroupInfos.getSafetySetup(motionGroupId)\n\n return new ConnectedMotionGroup(\n nova,\n controller,\n motionGroup,\n initialMotionState,\n motionStateSocket,\n isVirtual,\n // biome-ignore lint/style/noNonNullAssertion: legacy code\n tcps!,\n motionGroupSpecification,\n safetySetup,\n mounting,\n initialControllerState,\n controllerStateSocket,\n )\n }\n\n connectedJoggingCartesianSocket: WebSocket | null = null\n connectedJoggingJointsSocket: WebSocket | null = null\n // biome-ignore lint/suspicious/noExplicitAny: legacy code\n planData: any | null // tmp\n joggingVelocity: number = 10\n\n // Not mobx-observable as this changes very fast; should be observed\n // using animation frames\n rapidlyChangingMotionState: MotionGroupStateResponse\n\n // Response rate on the websocket should be a bit slower on this one since\n // we don't use the motion data\n controllerState: RobotControllerState\n\n /**\n * Reflects activation state of the motion group / robot servos. The\n * movement controls in the UI should only be enabled in the \"active\" state\n */\n activationState: \"inactive\" | \"activating\" | \"deactivating\" | \"active\" =\n \"inactive\"\n\n constructor(\n readonly nova: NovaClient,\n readonly controller: ControllerInstance,\n readonly motionGroup: MotionGroupPhysical,\n readonly initialMotionState: MotionGroupStateResponse,\n readonly motionStateSocket: AutoReconnectingWebsocket,\n readonly isVirtual: boolean,\n readonly tcps: RobotTcp[],\n readonly motionGroupSpecification: MotionGroupSpecification,\n readonly safetySetup: SafetySetup,\n readonly mounting: Mounting | null,\n readonly initialControllerState: RobotControllerState,\n readonly controllerStateSocket: AutoReconnectingWebsocket,\n ) {\n this.rapidlyChangingMotionState = initialMotionState\n this.controllerState = initialControllerState\n\n // Track controller state updates (e.g. safety state and operation mode)\n controllerStateSocket.addEventListener(\"message\", (event) => {\n const data = tryParseJson(event.data)?.result\n\n if (!data) {\n return\n }\n\n runInAction(() => {\n this.controllerState = data\n })\n })\n\n motionStateSocket.addEventListener(\"message\", (event) => {\n const motionStateResponse = tryParseJson(event.data)?.result as\n | MotionGroupStateResponse\n | undefined\n\n if (!motionStateResponse) {\n throw new Error(\n `Failed to get motion state for ${this.motionGroupId}: ${event.data}`,\n )\n }\n\n // handle motionState message\n if (\n !jointValuesEqual(\n this.rapidlyChangingMotionState.state.joint_position.joints,\n motionStateResponse.state.joint_position.joints,\n MOTION_DELTA_THRESHOLD,\n )\n ) {\n runInAction(() => {\n this.rapidlyChangingMotionState.state = motionStateResponse.state\n })\n }\n\n // handle tcpPose message\n if (\n !tcpPoseEqual(\n this.rapidlyChangingMotionState.tcp_pose,\n motionStateResponse.tcp_pose,\n MOTION_DELTA_THRESHOLD,\n )\n ) {\n runInAction(() => {\n this.rapidlyChangingMotionState.tcp_pose =\n motionStateResponse.tcp_pose\n })\n }\n })\n makeAutoObservable(this)\n }\n\n get motionGroupId() {\n return this.motionGroup.motion_group\n }\n\n get controllerId() {\n return this.controller.controller\n }\n\n get modelFromController() {\n return this.motionGroup.model_from_controller\n }\n\n get wandelscriptIdentifier() {\n const num = this.motionGroupId.split(\"@\")[0]\n return `${this.controllerId.replaceAll(\"-\", \"_\")}_${num}`\n }\n\n /** Jogging velocity in radians for rotation and joint movement */\n get joggingVelocityRads() {\n return (this.joggingVelocity * Math.PI) / 180\n }\n\n get joints() {\n return this.initialMotionState.state.joint_position.joints.map((_, i) => {\n return {\n index: i,\n }\n })\n }\n\n get dhParameters() {\n return this.motionGroupSpecification.dh_parameters\n }\n\n get safetyZones() {\n return this.safetySetup.safety_zones\n }\n\n /** Gets the robot mounting position offset in 3D viz coordinates */\n get mountingPosition(): [number, number, number] {\n if (!this.mounting) {\n return [0, 0, 0]\n }\n\n return [\n this.mounting.pose.position.x / 1000,\n this.mounting.pose.position.y / 1000,\n this.mounting.pose.position.z / 1000,\n ]\n }\n\n /** Gets the robot mounting position rotation in 3D viz coordinates */\n get mountingQuaternion() {\n const rotationVector = new THREE.Vector3(\n this.mounting?.pose.orientation?.x || 0,\n this.mounting?.pose.orientation?.y || 0,\n this.mounting?.pose.orientation?.z || 0,\n )\n\n const magnitude = rotationVector.length()\n const axis = rotationVector.normalize()\n\n return new THREE.Quaternion().setFromAxisAngle(axis, magnitude)\n }\n\n /**\n * Whether the controller is currently in a safety state\n * corresponding to an emergency stop\n */\n get isEstopActive() {\n const estopStates: RobotControllerStateSafetyStateEnum[] = [\n \"SAFETY_STATE_ROBOT_EMERGENCY_STOP\",\n \"SAFETY_STATE_DEVICE_EMERGENCY_STOP\",\n ]\n\n return estopStates.includes(this.controllerState.safety_state)\n }\n\n /**\n * Whether the controller is in a safety state\n * that may be non-functional for robot pad purposes\n */\n get isMoveableSafetyState() {\n const goodSafetyStates: RobotControllerStateSafetyStateEnum[] = [\n \"SAFETY_STATE_NORMAL\",\n \"SAFETY_STATE_REDUCED\",\n ]\n\n return goodSafetyStates.includes(this.controllerState.safety_state)\n }\n\n /**\n * Whether the controller is in an operation mode that allows movement\n */\n get isMoveableOperationMode() {\n const goodOperationModes: RobotControllerStateOperationModeEnum[] = [\n \"OPERATION_MODE_AUTO\",\n \"OPERATION_MODE_MANUAL\",\n \"OPERATION_MODE_MANUAL_T1\",\n \"OPERATION_MODE_MANUAL_T2\",\n ]\n\n return goodOperationModes.includes(this.controllerState.operation_mode)\n }\n\n /**\n * Whether the robot is currently active and can be moved, based on the\n * safety state, operation mode and servo toggle activation state.\n */\n get canBeMoved() {\n return (\n this.isMoveableSafetyState &&\n this.isMoveableOperationMode &&\n this.activationState === \"active\"\n )\n }\n\n async deactivate() {\n if (this.activationState !== \"active\") {\n console.error(\"Tried to deactivate while already deactivating\")\n return\n }\n\n runInAction(() => {\n this.activationState = \"deactivating\"\n })\n\n try {\n await this.nova.api.controller.setDefaultMode(\n this.controllerId,\n \"MODE_MONITOR\",\n )\n\n runInAction(() => {\n this.activationState = \"inactive\"\n })\n } catch (err) {\n runInAction(() => {\n this.activationState = \"active\"\n })\n throw err\n }\n }\n\n async activate() {\n if (this.activationState !== \"inactive\") {\n console.error(\"Tried to activate while already activating\")\n return\n }\n\n runInAction(() => {\n this.activationState = \"activating\"\n })\n\n try {\n await this.nova.api.controller.setDefaultMode(\n this.controllerId,\n \"MODE_CONTROL\",\n )\n\n runInAction(() => {\n this.activationState = \"active\"\n })\n } catch (err) {\n runInAction(() => {\n this.activationState = \"inactive\"\n })\n throw err\n }\n }\n\n toggleActivation() {\n if (this.activationState === \"inactive\") {\n this.activate()\n } else if (this.activationState === \"active\") {\n this.deactivate()\n }\n }\n\n dispose() {\n this.motionStateSocket.close()\n if (this.connectedJoggingCartesianSocket)\n this.connectedJoggingCartesianSocket.close()\n if (this.connectedJoggingJointsSocket)\n this.connectedJoggingJointsSocket.close()\n }\n\n setJoggingVelocity(velocity: number) {\n this.joggingVelocity = velocity\n }\n}\n","import type { Command, Joints, TcpPose } from \"@wandelbots/nova-api/v1\"\nimport { Vector3 } from \"three/src/math/Vector3.js\"\nimport type { AutoReconnectingWebsocket } from \"../AutoReconnectingWebsocket\"\nimport { isSameCoordinateSystem, tryParseJson } from \"../converters\"\nimport type { MotionStreamConnection } from \"./MotionStreamConnection\"\nimport type { NovaClient } from \"./NovaClient\"\n\nexport type JoggerConnectionOpts = {\n /**\n * When an error message is received from the jogging websocket, it\n * will be passed here. If this handler is not provided, the error will\n * instead be thrown as an unhandled error.\n */\n onError?: (err: unknown) => void\n}\n\nexport class JoggerConnection {\n // Currently a separate websocket is needed for each mode, pester API people\n // to merge these for simplicity\n cartesianWebsocket: AutoReconnectingWebsocket | null = null\n jointWebsocket: AutoReconnectingWebsocket | null = null\n cartesianJoggingOpts: {\n tcpId?: string\n coordSystemId?: string\n } = {}\n\n static async open(\n nova: NovaClient,\n motionGroupId: string,\n opts: JoggerConnectionOpts = {},\n ) {\n const motionStream = await nova.connectMotionStream(motionGroupId)\n\n return new JoggerConnection(motionStream, opts)\n }\n\n constructor(\n readonly motionStream: MotionStreamConnection,\n readonly opts: JoggerConnectionOpts = {},\n ) {}\n\n get motionGroupId() {\n return this.motionStream.motionGroupId\n }\n\n get nova() {\n return this.motionStream.nova\n }\n\n get numJoints() {\n return this.motionStream.joints.length\n }\n\n get activeJoggingMode() {\n if (this.cartesianWebsocket) return \"cartesian\"\n if (this.jointWebsocket) return \"joint\"\n return \"increment\"\n }\n\n get activeWebsocket() {\n return this.cartesianWebsocket || this.jointWebsocket\n }\n\n async stop() {\n // Why not call the stopJogging API endpoint?\n // Because this results in the websocket closing and we\n // would like to keep it open for now.\n\n if (this.cartesianWebsocket) {\n this.cartesianWebsocket.sendJson({\n motion_group: this.motionGroupId,\n position_direction: { x: 0, y: 0, z: 0 },\n rotation_direction: { x: 0, y: 0, z: 0 },\n position_velocity: 0,\n rotation_velocity: 0,\n tcp: this.cartesianJoggingOpts.tcpId,\n coordinate_system: this.cartesianJoggingOpts.coordSystemId,\n })\n }\n\n if (this.jointWebsocket) {\n this.jointWebsocket.sendJson({\n motion_group: this.motionGroupId,\n joint_velocities: new Array(this.numJoints).fill(0),\n })\n }\n }\n\n dispose() {\n if (this.cartesianWebsocket) {\n this.cartesianWebsocket.dispose()\n }\n\n if (this.jointWebsocket) {\n this.jointWebsocket.dispose()\n }\n }\n\n setJoggingMode(\n mode: \"cartesian\" | \"joint\" | \"increment\",\n cartesianJoggingOpts?: {\n tcpId?: string\n coordSystemId?: string\n },\n ) {\n console.log(\"Setting jogging mode to\", mode)\n if (cartesianJoggingOpts) {\n // Websocket needs to be reopened to change options\n if (\n JSON.stringify(this.cartesianJoggingOpts) !==\n JSON.stringify(cartesianJoggingOpts)\n ) {\n if (this.cartesianWebsocket) {\n this.cartesianWebsocket.dispose()\n this.cartesianWebsocket = null\n }\n }\n\n this.cartesianJoggingOpts = cartesianJoggingOpts\n }\n\n if (mode !== \"cartesian\" && this.cartesianWebsocket) {\n this.cartesianWebsocket.dispose()\n this.cartesianWebsocket = null\n }\n\n if (mode !== \"joint\" && this.jointWebsocket) {\n this.jointWebsocket.dispose()\n this.jointWebsocket = null\n }\n\n if (mode === \"cartesian\" && !this.cartesianWebsocket) {\n this.cartesianWebsocket = this.nova.openReconnectingWebsocket(\n `/motion-groups/move-tcp`,\n )\n\n this.cartesianWebsocket.addEventListener(\n \"message\",\n (ev: MessageEvent) => {\n const data = tryParseJson(ev.data)\n if (data && \"error\" in data) {\n if (this.opts.onError) {\n this.opts.onError(ev.data)\n } else {\n throw new Error(ev.data)\n }\n }\n },\n )\n }\n\n if (mode === \"joint\" && !this.jointWebsocket) {\n this.jointWebsocket = this.nova.openReconnectingWebsocket(\n `/motion-groups/move-joint`,\n )\n\n this.jointWebsocket.addEventListener(\"message\", (ev: MessageEvent) => {\n const data = tryParseJson(ev.data)\n if (data && \"error\" in data) {\n if (this.opts.onError) {\n this.opts.onError(ev.data)\n } else {\n throw new Error(ev.data)\n }\n }\n })\n }\n }\n\n /**\n * Start rotation of a single robot joint at the specified velocity\n */\n async startJointRotation({\n joint,\n direction,\n velocityRadsPerSec,\n }: {\n /** Index of the joint to rotate */\n joint: number\n /** Direction of rotation (\"+\" or \"-\") */\n direction: \"+\" | \"-\"\n /** Speed of the rotation in radians per second */\n velocityRadsPerSec: number\n }) {\n if (!this.jointWebsocket) {\n throw new Error(\n \"Joint jogging websocket not connected; call setJoggingMode first\",\n )\n }\n\n const jointVelocities = new Array(this.numJoints).fill(0)\n\n jointVelocities[joint] =\n direction === \"-\" ? -velocityRadsPerSec : velocityRadsPerSec\n\n this.jointWebsocket.sendJson({\n motion_group: this.motionGroupId,\n joint_velocities: jointVelocities,\n })\n }\n\n /**\n * Start the TCP moving along a specified axis at a given velocity\n */\n async startTCPTranslation({\n axis,\n direction,\n velocityMmPerSec,\n }: {\n axis: \"x\" | \"y\" | \"z\"\n direction: \"-\" | \"+\"\n velocityMmPerSec: number\n }) {\n if (!this.cartesianWebsocket) {\n throw new Error(\n \"Cartesian jogging websocket not connected; call setJoggingMode first\",\n )\n }\n\n const zeroVector = { x: 0, y: 0, z: 0 }\n const joggingVector = Object.assign({}, zeroVector)\n joggingVector[axis] = direction === \"-\" ? -1 : 1\n\n this.cartesianWebsocket.sendJson({\n motion_group: this.motionGroupId,\n position_direction: joggingVector,\n rotation_direction: zeroVector,\n position_velocity: velocityMmPerSec,\n rotation_velocity: 0,\n tcp: this.cartesianJoggingOpts.tcpId,\n coordinate_system: this.cartesianJoggingOpts.coordSystemId,\n })\n }\n\n /**\n * Start the TCP rotating around a specified axis at a given velocity\n */\n async startTCPRotation({\n axis,\n direction,\n velocityRadsPerSec,\n }: {\n axis: \"x\" | \"y\" | \"z\"\n direction: \"-\" | \"+\"\n velocityRadsPerSec: number\n }) {\n if (!this.cartesianWebsocket) {\n throw new Error(\n \"Cartesian jogging websocket not connected; call setJoggingMode first\",\n )\n }\n\n const zeroVector = { x: 0, y: 0, z: 0 }\n const joggingVector = Object.assign({}, zeroVector)\n joggingVector[axis] = direction === \"-\" ? -1 : 1\n\n this.cartesianWebsocket.sendJson({\n motion_group: this.motionGroupId,\n position_direction: zeroVector,\n rotation_direction: joggingVector,\n position_velocity: 0,\n rotation_velocity: velocityRadsPerSec,\n tcp: this.cartesianJoggingOpts.tcpId,\n coordinate_system: this.cartesianJoggingOpts.coordSystemId,\n })\n }\n\n /**\n * Move the robot by a fixed distance in a single cartesian\n * axis, either rotating or translating relative to the TCP.\n * Promise resolves only after the motion has completed.\n */\n async runIncrementalCartesianMotion({\n currentTcpPose,\n currentJoints,\n coordSystemId,\n velocityInRelevantUnits,\n axis,\n direction,\n motion,\n }: {\n currentTcpPose: TcpPose\n currentJoints: Joints\n coordSystemId: string\n velocityInRelevantUnits: number\n axis: \"x\" | \"y\" | \"z\"\n direction: \"-\" | \"+\"\n motion:\n | {\n type: \"rotate\"\n distanceRads: number\n }\n | {\n type: \"translate\"\n distanceMm: number\n }\n }) {\n const commands: Command[] = []\n\n if (\n !isSameCoordinateSystem(currentTcpPose.coordinate_system, coordSystemId)\n ) {\n throw new Error(\n `Current TCP pose coordinate system ${currentTcpPose.coordinate_system} does not match target coordinate system ${coordSystemId}`,\n )\n }\n\n if (motion.type === \"translate\") {\n const targetTcpPosition = Object.assign({}, currentTcpPose.position)\n targetTcpPosition[axis] +=\n motion.distanceMm * (direction === \"-\" ? -1 : 1)\n\n commands.push({\n settings: {\n limits_override: {\n tcp_velocity_limit: velocityInRelevantUnits,\n },\n },\n line: {\n position: targetTcpPosition,\n orientation: currentTcpPose.orientation,\n coordinate_system: coordSystemId,\n },\n })\n } else if (motion.type === \"rotate\") {\n // Concatenate rotations expressed by rotation vectors\n // Equations taken from https://physics.stackexchange.com/a/287819\n\n // Compute axis and angle of current rotation vector\n const currentRotationVector = new Vector3(\n currentTcpPose.orientation.x,\n currentTcpPose.orientation.y,\n currentTcpPose.orientation.z,\n )\n\n const currentRotationRad = currentRotationVector.length()\n const currentRotationDirection = currentRotationVector.clone().normalize()\n\n // Compute axis and angle of difference rotation vector\n const differenceRotationRad =\n motion.distanceRads * (direction === \"-\" ? -1 : 1)\n\n const differenceRotationDirection = new Vector3(0.0, 0.0, 0.0)\n differenceRotationDirection[axis] = 1.0\n\n // Some abbreviations to make the following equations more readable\n const f1 =\n Math.cos(0.5 * differenceRotationRad) *\n Math.cos(0.5 * currentRotationRad)\n const f2 =\n Math.sin(0.5 * differenceRotationRad) *\n Math.sin(0.5 * currentRotationRad)\n const f3 =\n Math.sin(0.5 * differenceRotationRad) *\n Math.cos(0.5 * currentRotationRad)\n const f4 =\n Math.cos(0.5 * differenceRotationRad) *\n Math.sin(0.5 * currentRotationRad)\n\n const dotProduct = differenceRotationDirection.dot(\n currentRotationDirection,\n )\n\n const crossProduct = differenceRotationDirection\n .clone()\n .cross(currentRotationDirection)\n\n // Compute angle of concatenated rotation\n const newRotationRad = 2.0 * Math.acos(f1 - f2 * dotProduct)\n\n // Compute rotation vector of concatenated rotation\n const f5 = newRotationRad / Math.sin(0.5 * newRotationRad)\n\n const targetTcpOrientation = new Vector3()\n .addScaledVector(crossProduct, f2)\n .addScaledVector(differenceRotationDirection, f3)\n .addScaledVector(currentRotationDirection, f4)\n .multiplyScalar(f5)\n\n commands.push({\n settings: {\n limits_override: {\n tcp_orientation_velocity_limit: velocityInRelevantUnits,\n },\n },\n line: {\n position: currentTcpPose.position,\n orientation: targetTcpOrientation,\n coordinate_system: coordSystemId,\n },\n })\n }\n\n const motionPlanRes = await this.nova.api.motion.planMotion({\n motion_group: this.motionGroupId,\n start_joint_position: currentJoints,\n tcp: this.cartesianJoggingOpts.tcpId,\n commands,\n })\n\n const plannedMotion = motionPlanRes.plan_successful_response?.motion\n if (!plannedMotion) {\n throw new Error(\n `Failed to plan jogging increment motion ${JSON.stringify(motionPlanRes)}`,\n )\n }\n\n await this.nova.api.motion.streamMoveForward(\n plannedMotion,\n 100,\n undefined,\n undefined,\n undefined,\n {\n // Might take a while at low velocity\n timeout: 1000 * 60,\n },\n )\n }\n\n /**\n * Rotate a single robot joint by a fixed number of radians\n * Promise resolves only after the motion has completed.\n */\n async runIncrementalJointRotation({\n joint,\n currentJoints,\n velocityRadsPerSec,\n direction,\n distanceRads,\n }: {\n joint: number\n currentJoints: Joints\n velocityRadsPerSec: number\n direction: \"-\" | \"+\"\n distanceRads: number\n }) {\n const targetJoints = [...currentJoints.joints]\n // biome-ignore lint/style/noNonNullAssertion: legacy code\n targetJoints[joint]! += distanceRads * (direction === \"-\" ? -1 : 1)\n\n const jointVelocityLimits: number[] = new Array(\n currentJoints.joints.length,\n ).fill(velocityRadsPerSec)\n\n const motionPlanRes = await this.nova.api.motion.planMotion({\n motion_group: this.motionGroupId,\n start_joint_position: currentJoints,\n commands: [\n {\n settings: {\n limits_override: {\n joint_velocity_limits: {\n joints: jointVelocityLimits,\n },\n },\n },\n joint_ptp: {\n joints: targetJoints,\n },\n },\n ],\n })\n\n const plannedMotion = motionPlanRes.plan_successful_response?.motion\n if (!plannedMotion) {\n console.error(\"Failed to plan jogging increment motion\", motionPlanRes)\n return\n }\n\n await this.nova.api.motion.streamMoveForward(\n plannedMotion,\n 100,\n undefined,\n undefined,\n undefined,\n {\n // Might take a while at low velocity\n timeout: 1000 * 60,\n },\n )\n }\n}\n","/** biome-ignore-all lint/style/noNonNullAssertion: legacy code */\nimport type {\n ControllerInstance,\n MotionGroupPhysical,\n MotionGroupStateResponse,\n Vector3d,\n} from \"@wandelbots/nova-api/v1\"\nimport { makeAutoObservable, runInAction } from \"mobx\"\nimport { Vector3 } from \"three\"\nimport type { AutoReconnectingWebsocket } from \"../AutoReconnectingWebsocket\"\nimport { tryParseJson } from \"../converters\"\nimport { jointValuesEqual, tcpPoseEqual } from \"./motionStateUpdate\"\nimport type { NovaClient } from \"./NovaClient\"\n\nconst MOTION_DELTA_THRESHOLD = 0.0001\n\nfunction unwrapRotationVector(\n newRotationVectorApi: Vector3d,\n currentRotationVectorApi: Vector3d,\n): Vector3d {\n const currentRotationVector = new Vector3(\n currentRotationVectorApi.x,\n currentRotationVectorApi.y,\n currentRotationVectorApi.z,\n )\n\n const newRotationVector = new Vector3(\n newRotationVectorApi.x,\n newRotationVectorApi.y,\n newRotationVectorApi.z,\n )\n\n const currentAngle = currentRotationVector.length()\n const currentAxis = currentRotationVector.normalize()\n\n let newAngle = newRotationVector.length()\n let newAxis = newRotationVector.normalize()\n\n // Align rotation axes\n if (newAxis.dot(currentAxis) < 0) {\n newAngle = -newAngle\n newAxis = newAxis.multiplyScalar(-1.0)\n }\n\n // Shift rotation angle close to previous one to extend domain of rotation angles beyond [0, pi]\n // - this simplifies interpolation and prevents abruptly changing signs of the rotation angles\n let angleDifference = newAngle - currentAngle\n angleDifference -=\n 2.0 * Math.PI * Math.floor((angleDifference + Math.PI) / (2.0 * Math.PI))\n\n newAngle = currentAngle + angleDifference\n\n return newAxis.multiplyScalar(newAngle)\n}\n\n/**\n * Store representing the current state of a connected motion group.\n */\nexport class MotionStreamConnection {\n static async open(nova: NovaClient, motionGroupId: string) {\n const { instances: controllers } =\n await nova.api.controller.listControllers()\n\n const [_motionGroupIndex, controllerId] = motionGroupId.split(\"@\") as [\n string,\n string,\n ]\n const controller = controllers.find((c) => c.controller === controllerId)\n const motionGroup = controller?.physical_motion_groups.find(\n (mg) => mg.motion_group === motionGroupId,\n )\n if (!controller || !motionGroup) {\n throw new Error(\n `Controller ${controllerId} or motion group ${motionGroupId} not found`,\n )\n }\n\n const motionStateSocket = nova.openReconnectingWebsocket(\n `/motion-groups/${motionGroupId}/state-stream`,\n )\n\n // Wait for the first message to get the initial state\n const firstMessage = await motionStateSocket.firstMessage()\n const initialMotionState = tryParseJson(firstMessage.data)\n ?.result as MotionGroupStateResponse\n\n if (!initialMotionState) {\n throw new Error(\n `Unable to parse initial motion state message ${firstMessage.data}`,\n )\n }\n\n console.log(\n `Connected motion state websocket to motion group ${motionGroup.motion_group}. Initial state:\\n `,\n initialMotionState,\n )\n\n return new MotionStreamConnection(\n nova,\n controller,\n motionGroup,\n initialMotionState,\n motionStateSocket,\n )\n }\n\n // Not mobx-observable as this changes very fast; should be observed\n // using animation frames\n rapidlyChangingMotionState: MotionGroupStateResponse\n\n constructor(\n readonly nova: NovaClient,\n readonly controller: ControllerInstance,\n readonly motionGroup: MotionGroupPhysical,\n readonly initialMotionState: MotionGroupStateResponse,\n readonly motionStateSocket: AutoReconnectingWebsocket,\n ) {\n this.rapidlyChangingMotionState = initialMotionState\n\n motionStateSocket.addEventListener(\"message\", (event) => {\n const motionStateResponse = tryParseJson(event.data)?.result as\n | MotionGroupStateResponse\n | undefined\n\n if (!motionStateResponse) {\n throw new Error(\n `Failed to get motion state for ${this.motionGroupId}: ${event.data}`,\n )\n }\n\n // handle motionState message\n if (\n !jointValuesEqual(\n this.rapidlyChangingMotionState.state.joint_position.joints,\n motionStateResponse.state.joint_position.joints,\n MOTION_DELTA_THRESHOLD,\n )\n ) {\n runInAction(() => {\n this.rapidlyChangingMotionState.state = motionStateResponse.state\n })\n }\n\n // handle tcpPose message\n if (\n !tcpPoseEqual(\n this.rapidlyChangingMotionState.tcp_pose,\n motionStateResponse.tcp_pose,\n MOTION_DELTA_THRESHOLD,\n )\n ) {\n runInAction(() => {\n if (this.rapidlyChangingMotionState.tcp_pose == null) {\n this.rapidlyChangingMotionState.tcp_pose =\n motionStateResponse.tcp_pose\n } else {\n this.rapidlyChangingMotionState.tcp_pose = {\n position: motionStateResponse.tcp_pose!.position,\n orientation: unwrapRotationVector(\n motionStateResponse.tcp_pose!.orientation,\n this.rapidlyChangingMotionState.tcp_pose!.orientation,\n ),\n tcp: motionStateResponse.tcp_pose!.tcp,\n coordinate_system:\n motionStateResponse.tcp_pose!.coordinate_system,\n }\n }\n })\n }\n })\n makeAutoObservable(this)\n }\n\n get motionGroupId() {\n return this.motionGroup.motion_group\n }\n\n get controllerId() {\n return this.controller.controller\n }\n\n get modelFromController() {\n return this.motionGroup.model_from_controller\n }\n\n get wandelscriptIdentifier() {\n const num = this.motionGroupId.split(\"@\")[0]\n return `${this.controllerId.replaceAll(\"-\", \"_\")}_${num}`\n }\n\n get joints() {\n return this.initialMotionState.state.joint_position.joints.map((_, i) => {\n return {\n index: i,\n }\n })\n }\n\n dispose() {\n this.motionStateSocket.close()\n }\n}\n","/** biome-ignore-all lint/style/noNonNullAssertion: legacy code */\n/** biome-ignore-all lint/suspicious/noExplicitAny: legacy code */\nimport type {\n BaseAPI,\n Configuration as BaseConfiguration,\n} from \"@wandelbots/nova-api/v1\"\nimport {\n ApplicationApi,\n CellApi,\n ControllerApi,\n ControllerIOsApi,\n CoordinateSystemsApi,\n DeviceConfigurationApi,\n LibraryProgramApi,\n LibraryProgramMetadataApi,\n LibraryRecipeApi,\n LibraryRecipeMetadataApi,\n MotionApi,\n MotionGroupApi,\n MotionGroupInfosApi,\n MotionGroupJoggingApi,\n MotionGroupKinematicApi,\n ProgramApi,\n ProgramValuesApi,\n StoreCollisionComponentsApi,\n StoreCollisionScenesApi,\n StoreObjectApi,\n SystemApi,\n VirtualRobotApi,\n VirtualRobotBehaviorApi,\n VirtualRobotModeApi,\n VirtualRobotSetupApi,\n} from \"@wandelbots/nova-api/v1\"\nimport type { AxiosInstance } from \"axios\"\nimport axios from \"axios\"\n\ntype OmitFirstArg<F> = F extends (x: any, ...args: infer P) => infer R\n ? (...args: P) => R\n : never\n\ntype UnwrapAxiosResponseReturn<T> = T extends (...a: any) => any\n ? (\n ...a: Parameters<T>\n ) => Promise<Awaited<ReturnType<T>> extends { data: infer D } ? D : never>\n : never\n\nexport type WithCellId<T> = {\n [P in keyof T]: UnwrapAxiosResponseReturn<OmitFirstArg<T[P]>>\n}\n\nexport type WithUnwrappedAxiosResponse<T> = {\n [P in keyof T]: UnwrapAxiosResponseReturn<T[P]>\n}\n\n/**\n * API client providing type-safe access to all the Nova API REST endpoints\n * associated with a specific cell id.\n */\nexport class NovaCellAPIClient {\n constructor(\n readonly cellId: string,\n readonly opts: BaseConfiguration & {\n axiosInstance?: AxiosInstance\n mock?: boolean\n },\n ) {}\n\n /**\n * Some TypeScript sorcery which alters the API class methods so you don't\n * have to pass the cell id to every single one, and de-encapsulates the\n * response data\n */\n private withCellId<T extends BaseAPI>(\n ApiConstructor: new (\n config: BaseConfiguration,\n basePath: string,\n axios: AxiosInstance,\n ) => T,\n ) {\n const apiClient = new ApiConstructor(\n {\n ...this.opts,\n isJsonMime: (mime: string) => {\n return mime === \"application/json\"\n },\n },\n this.opts.basePath ?? \"\",\n this.opts.axiosInstance ?? axios.create(),\n ) as {\n [key: string | symbol]: any\n }\n\n for (const key of Reflect.ownKeys(Reflect.getPrototypeOf(apiClient)!)) {\n if (key !== \"constructor\" && typeof apiClient[key] === \"function\") {\n const originalFunction = apiClient[key]\n apiClient[key] = (...args: any[]) => {\n return originalFunction\n .apply(apiClient, [this.cellId, ...args])\n .then((res: any) => res.data)\n }\n }\n }\n\n return apiClient as WithCellId<T>\n }\n\n /**\n * As withCellId, but only does the response unwrapping\n */\n private withUnwrappedResponsesOnly<T extends BaseAPI>(\n ApiConstructor: new (\n config: BaseConfiguration,\n basePath: string,\n axios: AxiosInstance,\n ) => T,\n ) {\n const apiClient = new ApiConstructor(\n {\n ...this.opts,\n isJsonMime: (mime: string) => {\n return mime === \"application/json\"\n },\n },\n this.opts.basePath ?? \"\",\n this.opts.axiosInstance ?? axios.create(),\n ) as {\n [key: string | symbol]: any\n }\n\n for (const key of Reflect.ownKeys(Reflect.getPrototypeOf(apiClient)!)) {\n if (key !== \"constructor\" && typeof apiClient[key] === \"function\") {\n const originalFunction = apiClient[key]\n apiClient[key] = (...args: any[]) => {\n return originalFunction\n .apply(apiClient, args)\n .then((res: any) => res.data)\n }\n }\n }\n\n return apiClient as WithUnwrappedAxiosResponse<T>\n }\n\n readonly system = this.withUnwrappedResponsesOnly(SystemApi)\n readonly cell = this.withUnwrappedResponsesOnly(CellApi)\n\n readonly deviceConfig = this.withCellId(DeviceConfigurationApi)\n\n readonly motionGroup = this.withCellId(MotionGroupApi)\n readonly motionGroupInfos = this.withCellId(MotionGroupInfosApi)\n\n readonly controller = this.withCellId(ControllerApi)\n\n readonly program = this.withCellId(ProgramApi)\n readonly programValues = this.withCellId(ProgramValuesApi)\n\n readonly controllerIOs = this.withCellId(ControllerIOsApi)\n\n readonly motionGroupKinematic = this.withCellId(MotionGroupKinematicApi)\n readonly motion = this.withCellId(MotionApi)\n\n readonly coordinateSystems = this.withCellId(CoordinateSystemsApi)\n\n readonly application = this.withCellId(ApplicationApi)\n readonly applicationGlobal = this.withUnwrappedResponsesOnly(ApplicationApi)\n\n readonly motionGroupJogging = this.withCellId(MotionGroupJoggingApi)\n\n readonly virtualRobot = this.withCellId(VirtualRobotApi)\n readonly virtualRobotSetup = this.withCellId(VirtualRobotSetupApi)\n readonly virtualRobotMode = this.withCellId(VirtualRobotModeApi)\n readonly virtualRobotBehavior = this.withCellId(VirtualRobotBehaviorApi)\n\n readonly libraryProgramMetadata = this.withCellId(LibraryProgramMetadataApi)\n readonly libraryProgram = this.withCellId(LibraryProgramApi)\n readonly libraryRecipeMetadata = this.withCellId(LibraryRecipeMetadataApi)\n readonly libraryRecipe = this.withCellId(LibraryRecipeApi)\n\n readonly storeObject = this.withCellId(StoreObjectApi)\n readonly storeCollisionComponents = this.withCellId(\n StoreCollisionComponentsApi,\n )\n readonly storeCollisionScenes = this.withCellId(StoreCollisionScenesApi)\n}\n","import type {\n ControllerInstanceList,\n MotionGroupSpecification,\n MotionGroupStateResponse,\n RobotController,\n SafetySetup,\n} from \"@wandelbots/nova-api/v1\"\nimport type { AxiosResponse, InternalAxiosRequestConfig } from \"axios\"\nimport { AxiosError } from \"axios\"\nimport * as pathToRegexp from \"path-to-regexp\"\nimport type { AutoReconnectingWebsocket } from \"../../AutoReconnectingWebsocket\"\n\n/**\n * Ultra-simplified mock Nova server for testing stuff\n */\nexport class MockNovaInstance {\n readonly connections: AutoReconnectingWebsocket[] = []\n\n async handleAPIRequest(\n config: InternalAxiosRequestConfig,\n ): Promise<AxiosResponse> {\n const apiHandlers = [\n {\n method: \"GET\",\n path: \"/cells/:cellId/controllers\",\n handle() {\n return {\n instances: [\n {\n controller: \"mock-ur5e\",\n model_name: \"UniversalRobots::Controller\",\n host: \"mock-ur5e\",\n allow_software_install_on_controller: true,\n physical_motion_groups: [\n {\n motion_group: \"0@mock-ur5e\",\n name_from_controller: \"UR5e\",\n active: false,\n model_from_controller: \"UniversalRobots_UR5e\",\n },\n ],\n has_error: false,\n error_details: \"\",\n },\n ],\n } satisfies ControllerInstanceList\n },\n },\n {\n method: \"GET\",\n path: \"/cells/:cellId/controllers/:controllerId\",\n handle() {\n return {\n configuration: {\n kind: \"VirtualController\",\n manufacturer: \"universalrobots\",\n position: \"[0,-1.571,-1.571,-1.571,1.571,-1.571,0]\",\n type: \"universalrobots-ur5e\",\n },\n name: \"mock-ur5\",\n } satisfies RobotController\n },\n },\n {\n method: \"GET\",\n path: \"/cells/:cellId/motion-groups/:motionGroupId/specification\",\n handle() {\n return {\n dh_parameters: [\n {\n alpha: 1.5707963267948966,\n theta: 0,\n a: 0,\n d: 162.25,\n reverse_rotation_direction: false,\n },\n {\n alpha: 0,\n theta: 0,\n a: -425,\n d: 0,\n reverse_rotation_direction: false,\n },\n {\n alpha: 0,\n theta: 0,\n a: -392.2,\n d: 0,\n reverse_rotation_direction: false,\n },\n {\n alpha: 1.5707963267948966,\n theta: 0,\n a: 0,\n d: 133.3,\n reverse_rotation_direction: false,\n },\n {\n alpha: -1.5707963267948966,\n theta: 0,\n a: 0,\n d: 99.7,\n reverse_rotation_direction: false,\n },\n {\n alpha: 0,\n theta: 0,\n a: 0,\n d: 99.6,\n reverse_rotation_direction: false,\n },\n ],\n mechanical_joint_limits: [\n {\n joint: \"JOINTNAME_AXIS_1\",\n lower_limit: -6.335545063018799,\n upper_limit: 6.335545063018799,\n unlimited: false,\n },\n {\n joint: \"JOINTNAME_AXIS_2\",\n lower_limit: -6.335545063018799,\n upper_limit: 6.335545063018799,\n unlimited: false,\n },\n {\n joint: \"JOINTNAME_AXIS_3\",\n lower_limit: -6.335545063018799,\n upper_limit: 6.335545063018799,\n unlimited: false,\n },\n {\n joint: \"JOINTNAME_AXIS_4\",\n lower_limit: -6.335545063018799,\n upper_limit: 6.335545063018799,\n unlimited: false,\n },\n {\n joint: \"JOINTNAME_AXIS_5\",\n lower_limit: -6.335545063018799,\n upper_limit: 6.335545063018799,\n unlimited: false,\n },\n {\n joint: \"JOINTNAME_AXIS_6\",\n lower_limit: -6.335545063018799,\n upper_limit: 6.335545063018799,\n unlimited: false,\n },\n ],\n } satisfies MotionGroupSpecification\n },\n },\n {\n method: \"GET\",\n path: \"/cells/:cellId/motion-groups/:motionGroupId/safety-setup\",\n handle() {\n return {\n safety_settings: [\n {\n safety_state: \"SAFETY_NORMAL\",\n settings: {\n joint_position_limits: [\n {\n joint: \"JOINTNAME_AXIS_1\",\n lower_limit: -2.96705961227417,\n upper_limit: 2.96705961227417,\n unlimited: false,\n },\n {\n