Skip to content

Commit

Permalink
BREAKING CHANGE: Response data from API is now automatically unwrapped
Browse files Browse the repository at this point in the history
  • Loading branch information
evrys committed Jul 10, 2024
1 parent f4aad47 commit 90f923c
Show file tree
Hide file tree
Showing 9 changed files with 312 additions and 277 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
.rollup.cache
dist
node_modules
tsconfig.tsbuildinfo
tsconfig.tsbuildinfo
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 2 additions & 6 deletions src/NovaClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,11 @@ export class NovaClient {
async connectMotionGroups(
motionGroupIds: string[],
): Promise<ConnectedMotionGroup[]> {
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),
),
)
}
Expand Down
13 changes: 5 additions & 8 deletions src/lib/ConnectedMotionGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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)
Expand All @@ -91,8 +88,8 @@ export class ConnectedMotionGroup {
initialMotionState,
motionStateSocket,
isVirtual,
tcpOptionsRes.data.tcps!,
motionGroupSpecification.data,
tcps!,
motionGroupSpecification,
)
}

Expand Down
18 changes: 14 additions & 4 deletions src/lib/NovaCellAPIClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,13 @@ import type { BaseAPI } from "@wandelbots/wandelbots-api-client/base"
type OmitFirstArg<F> = F extends (x: any, ...args: infer P) => infer R
? (...args: P) => R
: never

type UnwrapAxiosResponseReturn<T extends (...a: any) => any> = (
...a: Parameters<T>
) => Promise<Awaited<ReturnType<T>>["data"]>

export type WithCellId<T> = {
[P in keyof T]: OmitFirstArg<T[P]>
[P in keyof T]: UnwrapAxiosResponseReturn<OmitFirstArg<T[P]>>
}

/**
Expand All @@ -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<T extends BaseAPI>(
ApiConstructor: new (config: Configuration) => T,
Expand All @@ -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
}
}
}
Expand Down
245 changes: 0 additions & 245 deletions src/lib/ProgramRunner.ts
Original file line number Diff line number Diff line change
@@ -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",
})
}
}
Loading

0 comments on commit 90f923c

Please sign in to comment.