diff --git a/.gitignore b/.gitignore index ee3dd27..4b66fe9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .rollup.cache +dist node_modules -tsconfig.tsbuildinfo \ No newline at end of file +tsconfig.tsbuildinfo diff --git a/README.md b/README.md index 6656a85..733371a 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,38 @@ The Wandelbots Nova client library provides convenient access to the Nova API fr npm install @wandelbots/nova ``` +## Usage + +The core of this package is the `NovaClient`, which represents a connection to a configured robot cell on a given Nova instance. + +```ts +import { createNovaClient } from "@wandelbots/nova" + +const nova = createNovaClient({ + instanceUrl: "https://example.instance.wandelbots.io", +}) +``` + +## API calls + +You can make calls to the REST API via `nova.api`, which contains a bunch of namespaced methods for each endpoint generated from the OpenAPI spec and documentation. + +For example, to list the devices configured in your cell: + +````ts +const controllers = await nova.api.controller.listControllers() +// -> +``` + +Documentation for the various API endpoints is available on your Nova instance at `/api/v1/ui` (public documentation site is in the works) + ## Contributing To set up nova-js for development, first clone the repo and run: ```bash npm install -``` +```` Then you can run the tests against any Nova instance: diff --git a/src/NovaClient.ts b/src/NovaClient.ts index 73d40be..e9f6d5a 100644 --- a/src/NovaClient.ts +++ b/src/NovaClient.ts @@ -33,15 +33,11 @@ export class NovaClient { async connectMotionGroups( motionGroupIds: string[], ): Promise { - const { data: controllersRes } = await this.api.controller.listControllers() + const { instances } = await this.api.controller.listControllers() return Promise.all( motionGroupIds.map((motionGroupId) => - ConnectedMotionGroup.connect( - this, - motionGroupId, - controllersRes.instances, - ), + ConnectedMotionGroup.connect(this, motionGroupId, instances), ), ) } diff --git a/src/lib/ConnectedMotionGroup.ts b/src/lib/ConnectedMotionGroup.ts index 9d2c58e..6c90a99 100644 --- a/src/lib/ConnectedMotionGroup.ts +++ b/src/lib/ConnectedMotionGroup.ts @@ -61,12 +61,10 @@ export class ConnectedMotionGroup { // This is used to determine if the robot is virtual or physical let isVirtual = false try { - const virtualModeRes = + const opMode = await nova.api.virtualRobotMode.getOperationMode(controllerId) - if (virtualModeRes.status === 200) { - isVirtual = true - } + if (opMode) isVirtual = true } catch (err) { if (err instanceof AxiosError) { console.log( @@ -78,8 +76,7 @@ export class ConnectedMotionGroup { } // Find out what TCPs this motion group has (we need it for jogging) - const tcpOptionsRes = - await nova.api.motionGroupInfos.listTcps(motionGroupId) + const { tcps } = await nova.api.motionGroupInfos.listTcps(motionGroupId) const motionGroupSpecification = await nova.api.motionGroupInfos.getMotionGroupSpecification(motionGroupId) @@ -91,8 +88,8 @@ export class ConnectedMotionGroup { initialMotionState, motionStateSocket, isVirtual, - tcpOptionsRes.data.tcps!, - motionGroupSpecification.data, + tcps!, + motionGroupSpecification, ) } diff --git a/src/lib/NovaCellAPIClient.ts b/src/lib/NovaCellAPIClient.ts index 3c6dde7..b7b1201 100644 --- a/src/lib/NovaCellAPIClient.ts +++ b/src/lib/NovaCellAPIClient.ts @@ -25,8 +25,13 @@ import type { BaseAPI } from "@wandelbots/wandelbots-api-client/base" type OmitFirstArg = F extends (x: any, ...args: infer P) => infer R ? (...args: P) => R : never + +type UnwrapAxiosResponseReturn any> = ( + ...a: Parameters +) => Promise>["data"]> + export type WithCellId = { - [P in keyof T]: OmitFirstArg + [P in keyof T]: UnwrapAxiosResponseReturn> } /** @@ -41,7 +46,8 @@ export class NovaCellAPIClient { /** * Some TypeScript sorcery which alters the API class methods so you don't - * have to pass the cell id to every single one + * have to pass the cell id to every single one, and de-encapsulates the + * response data */ private withCellId( ApiConstructor: new (config: Configuration) => T, @@ -53,8 +59,12 @@ export class NovaCellAPIClient { for (const key of Reflect.ownKeys(Reflect.getPrototypeOf(apiClient)!)) { if (key !== "constructor" && typeof apiClient[key] === "function") { const originalFunction = apiClient[key] - apiClient[key] = (...args: any[]) => { - return originalFunction.apply(apiClient, [this.cellId, ...args]) + apiClient[key] = async (...args: any[]) => { + const res = await originalFunction.apply(apiClient, [ + this.cellId, + ...args, + ]) + return res.data } } } diff --git a/src/lib/ProgramRunner.ts b/src/lib/ProgramRunner.ts index c15ecb9..e69de29 100644 --- a/src/lib/ProgramRunner.ts +++ b/src/lib/ProgramRunner.ts @@ -1,245 +0,0 @@ -import { AxiosError } from "axios" -import { AutoReconnectingWebsocket } from "./util/AutoReconnectingWebsocket" -import { tryParseJson } from "./util/converters" -import type { NovaClient } from "../NovaClient" -import type { ConnectedMotionGroup } from "./ConnectedMotionGroup" - -export type ProgramRunnerLogEntry = { - timestamp: number - message: string - level?: "warn" | "error" -} - -export enum ProgramState { - NotStarted = "not started", - Running = "running", - Stopped = "stopped", - Failed = "failed", - Completed = "completed", -} - -export type CurrentProgram = { - id?: string - wandelscript?: string - state?: ProgramState -} - -type ProgramStateMessage = { - id: string - state: ProgramState - start_time?: number | null - execution_time?: number | null -} - -export class ProgramRunner { - currentProgram: CurrentProgram = {} - logs: ProgramRunnerLogEntry[] = [] - - executionState = "idle" as "idle" | "starting" | "executing" | "stopping" - currentlyExecutingProgramRunnerId = null as string | null - - programStateSocket: AutoReconnectingWebsocket - - constructor(readonly nova: NovaClient) { - this.programStateSocket = new AutoReconnectingWebsocket(` - ${nova.config.instanceUrl}/cells/${nova.config.cellId}/programs/state - `) - - this.programStateSocket.addEventListener("message", (ev) => { - const msg = tryParseJson(ev.data) - - if (!msg) { - console.error("Failed to parse program state message", ev.data) - return - } - - this.handleProgramStateMessage(msg) - }) - } - - /** Handle a program state update from the backend */ - async handleProgramStateMessage(msg: ProgramStateMessage) { - // Ignoring other programs for now - // TODO - show if execution state is busy from another source - if (msg.id !== this.currentlyExecutingProgramRunnerId) return - - if (msg.state === ProgramState.Failed) { - try { - const { data: runnerState } = - await this.nova.api.program.getProgramRunner(msg.id) - - // TODO - wandelengine should send print statements in real time over - // websocket as well, rather than at the end - const stdout = (runnerState as any).stdout - if (stdout) { - this.log(stdout) - } - this.logError( - `Program runner ${msg.id} failed with error: ${runnerState.error}\n${runnerState.traceback}`, - ) - } catch (err) { - this.logError( - `Failed to retrieve results for program ${msg.id}: ${err}`, - ) - } - - this.currentProgram.state = ProgramState.Failed - - this.gotoIdleState() - } else if (msg.state === ProgramState.Stopped) { - try { - const { data: runnerState } = - await this.nova.api.program.getProgramRunner(msg.id) - - const stdout = (runnerState as any).stdout - if (stdout) { - this.log(stdout) - } - - this.currentProgram.state = ProgramState.Stopped - this.log(`Program runner ${msg.id} stopped`) - } catch (err) { - this.logError( - `Failed to retrieve results for program ${msg.id}: ${err}`, - ) - } - - this.gotoIdleState() - } else if (msg.state === ProgramState.Completed) { - try { - const { data: runnerState } = - await this.nova.api.program.getProgramRunner(msg.id) - - const stdout = (runnerState as any).stdout - if (stdout) { - this.log(stdout) - } - this.log( - `Program runner ${msg.id} finished successfully in ${msg.execution_time?.toFixed(2)} seconds`, - ) - - this.currentProgram.state = ProgramState.Completed - } catch (err) { - this.logError( - `Failed to retrieve results for program ${msg.id}: ${err}`, - ) - } - - this.gotoIdleState() - } else if (msg.state === ProgramState.Running) { - this.currentProgram.state = ProgramState.Running - this.log(`Program runner ${msg.id} now running`) - } else if (msg.state !== ProgramState.NotStarted) { - console.error(msg) - this.logError( - `Program runner ${msg.id} entered unexpected state: ${msg.state}`, - ) - this.currentProgram.state = ProgramState.NotStarted - this.gotoIdleState() - } - } - - /** Call when a program is no longer executing */ - gotoIdleState() { - this.executionState = "idle" - this.currentlyExecutingProgramRunnerId = null - } - - async executeProgram( - wandelscript: string, - initial_state?: Object, - activeRobot?: ConnectedMotionGroup, - ) { - this.currentProgram = { - wandelscript: wandelscript, - state: ProgramState.NotStarted, - } - - const { currentProgram: openProgram } = this - if (!openProgram) return - - this.executionState = "starting" - - // Jogging can cause program execution to fail for some time after - // So we need to explicitly stop jogging before running a program - if (activeRobot) { - try { - await this.nova.api.motionGroupJogging.stopJogging( - activeRobot.motionGroupId, - ) - } catch (err) { - console.error(err) - } - } - - // WOS-1539: Wandelengine parser currently breaks if there are empty lines with indentation - const trimmedCode = openProgram.wandelscript!.replaceAll(/^\s*$/gm, "") - - try { - const { data: programRunnerRef } = - await this.nova.api.program.createProgramRunner( - { - code: trimmedCode, - initial_state: initial_state, - default_robot: activeRobot?.wandelscriptIdentifier, - } as any, - { - headers: { - "Content-Type": "application/json", - }, - }, - ) - - this.log(`Created program runner ${programRunnerRef.id}"`) - - this.executionState = "executing" - this.currentlyExecutingProgramRunnerId = programRunnerRef.id - } catch (error) { - if (error instanceof AxiosError && error.response && error.request) { - this.logError( - `${error.response.status} ${error.response.statusText} from ${error.response.config.url} ${JSON.stringify(error.response.data)}`, - ) - } else { - this.logError(JSON.stringify(error)) - } - this.executionState = "idle" - } - } - - async stopProgram() { - if (!this.currentlyExecutingProgramRunnerId) return - - this.executionState = "stopping" - - try { - await this.nova.api.program.stopProgramRunner( - this.currentlyExecutingProgramRunnerId, - ) - } catch (err) { - // Reactivate the stop button so user can try again - this.executionState = "executing" - throw err - } - } - - reset() { - this.currentProgram = {} - } - - log(message: string) { - console.log(message) - this.logs.push({ - timestamp: Date.now(), - message, - }) - } - - logError(message: string) { - console.log(message) - this.logs.push({ - timestamp: Date.now(), - message, - level: "error", - }) - } -} diff --git a/src/lib/ProgramStateConnection.ts b/src/lib/ProgramStateConnection.ts new file mode 100644 index 0000000..9e32d29 --- /dev/null +++ b/src/lib/ProgramStateConnection.ts @@ -0,0 +1,249 @@ +import { AxiosError } from "axios" +import { AutoReconnectingWebsocket } from "./util/AutoReconnectingWebsocket" +import { tryParseJson } from "./util/converters" +import type { NovaClient } from "../NovaClient" +import type { ConnectedMotionGroup } from "./ConnectedMotionGroup" + +export type ProgramRunnerLogEntry = { + timestamp: number + message: string + level?: "warn" | "error" +} + +export enum ProgramState { + NotStarted = "not started", + Running = "running", + Stopped = "stopped", + Failed = "failed", + Completed = "completed", +} + +export type CurrentProgram = { + id?: string + wandelscript?: string + state?: ProgramState +} + +type ProgramStateMessage = { + id: string + state: ProgramState + start_time?: number | null + execution_time?: number | null +} + +/** + * Interface for running Wandelscript programs on the Nova instance and + * tracking their progress and output + */ +export class ProgramStateConnection { + currentProgram: CurrentProgram = {} + logs: ProgramRunnerLogEntry[] = [] + + executionState = "idle" as "idle" | "starting" | "executing" | "stopping" + currentlyExecutingProgramRunnerId = null as string | null + + programStateSocket: AutoReconnectingWebsocket + + constructor(readonly nova: NovaClient) { + this.programStateSocket = new AutoReconnectingWebsocket(` + ${nova.config.instanceUrl}/cells/${nova.config.cellId}/programs/state + `) + + this.programStateSocket.addEventListener("message", (ev) => { + const msg = tryParseJson(ev.data) + + if (!msg) { + console.error("Failed to parse program state message", ev.data) + return + } + + this.handleProgramStateMessage(msg) + }) + } + + /** Handle a program state update from the backend */ + async handleProgramStateMessage(msg: ProgramStateMessage) { + // Ignoring other programs for now + // TODO - show if execution state is busy from another source + if (msg.id !== this.currentlyExecutingProgramRunnerId) return + + if (msg.state === ProgramState.Failed) { + try { + const { data: runnerState } = + await this.nova.api.program.getProgramRunner(msg.id) + + // TODO - wandelengine should send print statements in real time over + // websocket as well, rather than at the end + const stdout = (runnerState as any).stdout + if (stdout) { + this.log(stdout) + } + this.logError( + `Program runner ${msg.id} failed with error: ${runnerState.error}\n${runnerState.traceback}`, + ) + } catch (err) { + this.logError( + `Failed to retrieve results for program ${msg.id}: ${err}`, + ) + } + + this.currentProgram.state = ProgramState.Failed + + this.gotoIdleState() + } else if (msg.state === ProgramState.Stopped) { + try { + const { data: runnerState } = + await this.nova.api.program.getProgramRunner(msg.id) + + const stdout = (runnerState as any).stdout + if (stdout) { + this.log(stdout) + } + + this.currentProgram.state = ProgramState.Stopped + this.log(`Program runner ${msg.id} stopped`) + } catch (err) { + this.logError( + `Failed to retrieve results for program ${msg.id}: ${err}`, + ) + } + + this.gotoIdleState() + } else if (msg.state === ProgramState.Completed) { + try { + const { data: runnerState } = + await this.nova.api.program.getProgramRunner(msg.id) + + const stdout = (runnerState as any).stdout + if (stdout) { + this.log(stdout) + } + this.log( + `Program runner ${msg.id} finished successfully in ${msg.execution_time?.toFixed(2)} seconds`, + ) + + this.currentProgram.state = ProgramState.Completed + } catch (err) { + this.logError( + `Failed to retrieve results for program ${msg.id}: ${err}`, + ) + } + + this.gotoIdleState() + } else if (msg.state === ProgramState.Running) { + this.currentProgram.state = ProgramState.Running + this.log(`Program runner ${msg.id} now running`) + } else if (msg.state !== ProgramState.NotStarted) { + console.error(msg) + this.logError( + `Program runner ${msg.id} entered unexpected state: ${msg.state}`, + ) + this.currentProgram.state = ProgramState.NotStarted + this.gotoIdleState() + } + } + + /** Call when a program is no longer executing */ + gotoIdleState() { + this.executionState = "idle" + this.currentlyExecutingProgramRunnerId = null + } + + async executeProgram( + wandelscript: string, + initial_state?: Object, + activeRobot?: ConnectedMotionGroup, + ) { + this.currentProgram = { + wandelscript: wandelscript, + state: ProgramState.NotStarted, + } + + const { currentProgram: openProgram } = this + if (!openProgram) return + + this.executionState = "starting" + + // Jogging can cause program execution to fail for some time after + // So we need to explicitly stop jogging before running a program + if (activeRobot) { + try { + await this.nova.api.motionGroupJogging.stopJogging( + activeRobot.motionGroupId, + ) + } catch (err) { + console.error(err) + } + } + + // WOS-1539: Wandelengine parser currently breaks if there are empty lines with indentation + const trimmedCode = openProgram.wandelscript!.replaceAll(/^\s*$/gm, "") + + try { + const { data: programRunnerRef } = + await this.nova.api.program.createProgramRunner( + { + code: trimmedCode, + initial_state: initial_state, + default_robot: activeRobot?.wandelscriptIdentifier, + } as any, + { + headers: { + "Content-Type": "application/json", + }, + }, + ) + + this.log(`Created program runner ${programRunnerRef.id}"`) + + this.executionState = "executing" + this.currentlyExecutingProgramRunnerId = programRunnerRef.id + } catch (error) { + if (error instanceof AxiosError && error.response && error.request) { + this.logError( + `${error.response.status} ${error.response.statusText} from ${error.response.config.url} ${JSON.stringify(error.response.data)}`, + ) + } else { + this.logError(JSON.stringify(error)) + } + this.executionState = "idle" + } + } + + async stopProgram() { + if (!this.currentlyExecutingProgramRunnerId) return + + this.executionState = "stopping" + + try { + await this.nova.api.program.stopProgramRunner( + this.currentlyExecutingProgramRunnerId, + ) + } catch (err) { + // Reactivate the stop button so user can try again + this.executionState = "executing" + throw err + } + } + + reset() { + this.currentProgram = {} + } + + log(message: string) { + console.log(message) + this.logs.push({ + timestamp: Date.now(), + message, + }) + } + + logError(message: string) { + console.log(message) + this.logs.push({ + timestamp: Date.now(), + message, + level: "error", + }) + } +} diff --git a/test/jogging.test.ts b/test/jogging.test.ts deleted file mode 100644 index 5882717..0000000 --- a/test/jogging.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { expect, test } from "vitest" -import { NovaClient } from "../src" -// TODO - jogging test - -test("client instantiation", () => { - const nova = new NovaClient({ - instanceUrl: "http://localhost:3000", - cellId: "cell", - }) - - expect(nova).toBeTruthy() -}) diff --git a/test/programs.test.ts b/test/programs.test.ts new file mode 100644 index 0000000..0f99364 --- /dev/null +++ b/test/programs.test.ts @@ -0,0 +1,14 @@ +/// + +import { expect, test } from "vitest" +import { NovaClient } from "../src" + +test("running a Wandelscript program", async () => { + const nova = new NovaClient({ + instanceUrl: import.meta.env.NOVA_INSTANCE_URL, + cellId: "cell", + }) + + const devices = await nova.api.deviceConfig.listDevices() + expect(devices.length).toBeGreaterThan(0) +})