nova-control-browser 0.0.4 → 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -2
- package/dist/nova-control-browser.d.ts +11 -6
- package/dist/nova-control-browser.js +135 -32
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ Browser ESM module for controlling the [Creoqode Nova DIY AI Robot](../../README
|
|
|
10
10
|
| --- | --- |
|
|
11
11
|
| **Chrome or Edge 89+** | required — the Web Serial API is only available in Chromium-based browsers. Firefox and Safari do not support it. |
|
|
12
12
|
| **Node.js 22+** | required for the build toolchain only (`npm run build`). Not needed at runtime in the browser. Download from [nodejs.org](https://nodejs.org). |
|
|
13
|
-
| **Arduino sketch** | the matching `Nova_SerialController.ino` sketch must be flashed to the robot's Arduino board (baud rate 9600, 8N1). |
|
|
13
|
+
| **Arduino sketch** | the matching [`Nova_SerialController.ino`](../../Nova_SerialController.ino) sketch must be flashed to the robot's Arduino board (baud rate 9600, 8N1). |
|
|
14
14
|
| **User gesture** | `openNova()` must be called from within a user gesture (e.g. a button click) because the browser requires a transient activation before showing the port picker. |
|
|
15
15
|
|
|
16
16
|
---
|
|
@@ -65,7 +65,8 @@ Assembles a 5-byte direct servo control packet from the given servo state. Each
|
|
|
65
65
|
|
|
66
66
|
```typescript
|
|
67
67
|
async function openNova (
|
|
68
|
-
PortOrOptions?:WebSerialPort | WebSerialPortRequestOptions
|
|
68
|
+
PortOrOptions?:WebSerialPort | WebSerialPortRequestOptions,
|
|
69
|
+
Options?: NovaOptions
|
|
69
70
|
):Promise<NovaController>
|
|
70
71
|
```
|
|
71
72
|
|
|
@@ -73,6 +74,17 @@ Opens a USB serial port and returns a `NovaController`. Without an argument the
|
|
|
73
74
|
|
|
74
75
|
The returned promise resolves after the 2-second Arduino reset delay that follows every port open.
|
|
75
76
|
|
|
77
|
+
### `NovaOptions`
|
|
78
|
+
|
|
79
|
+
```typescript
|
|
80
|
+
interface NovaOptions {
|
|
81
|
+
StepIntervalMs?: number // ms between interpolation steps; default 20 (50 Hz); 0 = instant
|
|
82
|
+
RampRatio?: number // fraction of withinMS used for each ramp phase; default 0.25; range 0–0.499
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Controls how servo movements are executed when `withinMS` is specified on a movement call. `StepIntervalMs` sets the interval between intermediate packets. `RampRatio` controls what fraction of the total movement time is used for ramp-up and ramp-down phases (each); the remainder is constant speed. For example `RampRatio: 0.25` means 25% ramp-up, 50% constant speed, 25% ramp-down.
|
|
87
|
+
|
|
76
88
|
### `NovaController`
|
|
77
89
|
|
|
78
90
|
```typescript
|
|
@@ -98,15 +110,41 @@ interface NovaController {
|
|
|
98
110
|
| `pitchHeadTo(Degrees)` | sets s3 — head up `> 110°`, down toward `40°` |
|
|
99
111
|
| `liftHeadTo(Degrees)` | sets s5 — secondary head up/down, range `20°`–`150°` |
|
|
100
112
|
| `rotateBodyTo(Degrees)` | sets s4 — rotates the entire body around the Z-axis |
|
|
113
|
+
| `moveTo(Target, withinMS?)` | moves the servos listed in `Target` to their target angles; with `withinMS`, uses the trapezoidal profile |
|
|
101
114
|
| `State` (get) | returns a deep copy of the pending state if any, else the last-sent state |
|
|
102
115
|
| `State` (set) | replaces any pending entry with `Update` merged onto the *last-sent* state (not onto pending); flush with `sendServoState()` |
|
|
103
116
|
| `sendServoState()` | flushes any pending state update to the Arduino |
|
|
104
117
|
| `destroy()` | releases the stream writer lock and closes the serial port |
|
|
105
118
|
|
|
119
|
+
All movement methods (`home`, `shiftHeadTo`, `rollHeadTo`, `pitchHeadTo`, `liftHeadTo`, `rotateBodyTo`, and `moveTo`) accept an optional `withinMS?:number` final argument. When provided, the method executes a smooth timed movement that completes in exactly the given number of milliseconds, using the trapezoidal velocity profile configured by `RampRatio` in `NovaOptions`. Without `withinMS`, the method uses the existing constant-speed ramp (governed by `StepIntervalMs`).
|
|
120
|
+
|
|
106
121
|
`State` reflects what was *sent* (or is pending to be sent) to the Arduino, not the physical servo position — there is no read-back channel in the protocol.
|
|
107
122
|
|
|
108
123
|
Sends are serialised internally: concurrent method calls and `sendServoState()` calls never overlap on the wire. Named methods such as `shiftHeadTo()` *accumulate* changes on top of whatever is already pending; the `State` setter instead *replaces* the pending entry, starting fresh from the last-sent state.
|
|
109
124
|
|
|
125
|
+
### `runScript`
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
async function runScript (Nova:NovaController, Script:string):Promise<void>
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Parses and executes a multi-line movement script against an already-open controller. Commands are executed sequentially, one per line. Blank lines and lines starting with `#` are ignored.
|
|
132
|
+
|
|
133
|
+
Supported commands:
|
|
134
|
+
|
|
135
|
+
| command | description |
|
|
136
|
+
| --- | --- |
|
|
137
|
+
| `home` | send all servos to home positions |
|
|
138
|
+
| `shift-to <deg>` | s1 — head forward / back |
|
|
139
|
+
| `roll-to <deg>` | s2 — head CW / CCW |
|
|
140
|
+
| `pitch-to <deg>` | s3 — head up / down |
|
|
141
|
+
| `rotate-to <deg>` | s4 — body Z-axis rotation |
|
|
142
|
+
| `lift-to <deg>` | s5 — secondary head axis |
|
|
143
|
+
| `move [key val …]` | set multiple servos atomically (e.g. `move shift-to 100 rotate-to 120`) |
|
|
144
|
+
| `wait <ms>` | pause for the given number of milliseconds |
|
|
145
|
+
|
|
146
|
+
Throws a descriptive error containing the line number if an unknown command or invalid argument is encountered.
|
|
147
|
+
|
|
110
148
|
### Types
|
|
111
149
|
|
|
112
150
|
```typescript
|
|
@@ -203,6 +241,16 @@ Output is written to `packages/nova-control-browser/dist/`.
|
|
|
203
241
|
|
|
204
242
|
---
|
|
205
243
|
|
|
244
|
+
## Related packages
|
|
245
|
+
|
|
246
|
+
| package | description |
|
|
247
|
+
| --- | --- |
|
|
248
|
+
| [`nova-control-node`](../nova-control-node/README.md) | same API for Node.js via the `serialport` package |
|
|
249
|
+
| [`nova-control-command`](../nova-control-command/README.md) | CLI — one-shot commands, interactive REPL, and script files |
|
|
250
|
+
| [`nova-control-mcp-server`](../nova-control-mcp-server/README.md) | MCP server — lets an AI assistant control the robot |
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
206
254
|
## License
|
|
207
255
|
|
|
208
256
|
[MIT License](../../LICENSE.md) © Andreas Rozek
|
|
@@ -8,12 +8,13 @@ export declare const HomePosition: Readonly<ServoState>;
|
|
|
8
8
|
|
|
9
9
|
/**** NovaController ****/
|
|
10
10
|
export declare interface NovaController {
|
|
11
|
-
home(): Promise<void>;
|
|
12
|
-
shiftHeadTo(
|
|
13
|
-
rollHeadTo(
|
|
14
|
-
pitchHeadTo(
|
|
15
|
-
liftHeadTo(
|
|
16
|
-
rotateBodyTo(
|
|
11
|
+
home(withinMS?: number): Promise<void>;
|
|
12
|
+
shiftHeadTo(Angle: number, withinMS?: number): Promise<void>;
|
|
13
|
+
rollHeadTo(Angle: number, withinMS?: number): Promise<void>;
|
|
14
|
+
pitchHeadTo(Angle: number, withinMS?: number): Promise<void>;
|
|
15
|
+
liftHeadTo(Angle: number, withinMS?: number): Promise<void>;
|
|
16
|
+
rotateBodyTo(Angle: number, withinMS?: number): Promise<void>;
|
|
17
|
+
moveTo(Target: ServoUpdate, withinMS?: number): Promise<void>;
|
|
17
18
|
get State(): ServoState;
|
|
18
19
|
set State(Update: ServoUpdate);
|
|
19
20
|
sendServoState(): Promise<void>;
|
|
@@ -23,11 +24,15 @@ export declare interface NovaController {
|
|
|
23
24
|
/**** NovaOptions ****/
|
|
24
25
|
export declare interface NovaOptions {
|
|
25
26
|
StepIntervalMs?: number;
|
|
27
|
+
RampRatio?: number;
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
/**** openNova — factory ****/
|
|
29
31
|
export declare function openNova(PortOrOptions?: WebSerialPort | WebSerialPortRequestOptions, Options?: NovaOptions): Promise<NovaController>;
|
|
30
32
|
|
|
33
|
+
/**** runScript — execute a multi-line script of movement commands ****/
|
|
34
|
+
export declare function runScript(Nova: NovaController, Script: string): Promise<void>;
|
|
35
|
+
|
|
31
36
|
/**** SafeRange ****/
|
|
32
37
|
export declare const SafeRange: Readonly<Record<ServoKey, [number, number]>>;
|
|
33
38
|
|
|
@@ -53,22 +53,29 @@ async function o(t) {
|
|
|
53
53
|
}
|
|
54
54
|
};
|
|
55
55
|
}
|
|
56
|
-
|
|
57
|
-
let
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
56
|
+
function s(e, t) {
|
|
57
|
+
let n = Math.min(.499, Math.max(0, t)), r = 1 / (1 - n);
|
|
58
|
+
if (e <= n) return r * e * e / (2 * n);
|
|
59
|
+
if (e <= 1 - n) return r * (e - n / 2);
|
|
60
|
+
let i = 1 - e;
|
|
61
|
+
return 1 - r * i * i / (2 * n);
|
|
62
|
+
}
|
|
63
|
+
async function c(e, n) {
|
|
64
|
+
let i = n?.StepIntervalMs ?? 20, c = n?.RampRatio ?? .25, l = await o(e), u = { ...t }, d, f = Promise.resolve();
|
|
65
|
+
function p(e) {
|
|
66
|
+
d = {
|
|
67
|
+
...d ?? u,
|
|
61
68
|
...e
|
|
62
69
|
};
|
|
63
70
|
}
|
|
64
|
-
async function
|
|
65
|
-
let e =
|
|
66
|
-
|
|
71
|
+
async function m() {
|
|
72
|
+
let e = f;
|
|
73
|
+
f = (async () => {
|
|
67
74
|
try {
|
|
68
75
|
await e;
|
|
69
76
|
} catch {}
|
|
70
|
-
for (;
|
|
71
|
-
let e =
|
|
77
|
+
for (; d != null;) {
|
|
78
|
+
let e = d, t = !0, n = { ...u };
|
|
72
79
|
for (let a of [
|
|
73
80
|
"s1",
|
|
74
81
|
"s2",
|
|
@@ -76,48 +83,144 @@ async function s(e, n) {
|
|
|
76
83
|
"s4",
|
|
77
84
|
"s5"
|
|
78
85
|
]) {
|
|
79
|
-
let o = e[a] -
|
|
80
|
-
Math.abs(o) > s ? (n[a] =
|
|
86
|
+
let o = e[a] - u[a], s = i > 0 ? r[a] * i : Infinity;
|
|
87
|
+
Math.abs(o) > s ? (n[a] = u[a] + Math.sign(o) * s, t = !1) : n[a] = e[a];
|
|
81
88
|
}
|
|
82
|
-
t && (
|
|
89
|
+
t && (d = void 0), u = { ...n }, await l.write(a(n)), t || await new Promise((e) => setTimeout(e, i));
|
|
90
|
+
}
|
|
91
|
+
})(), await f;
|
|
92
|
+
}
|
|
93
|
+
async function h(e, t) {
|
|
94
|
+
let n = f;
|
|
95
|
+
f = (async () => {
|
|
96
|
+
try {
|
|
97
|
+
await n;
|
|
98
|
+
} catch {}
|
|
99
|
+
let r = { ...u }, o = i > 0 ? Math.max(1, Math.round(t / i)) : 1;
|
|
100
|
+
d = void 0;
|
|
101
|
+
for (let t = 1; t <= o; t++) {
|
|
102
|
+
let n = s(t / o, c), d = { ...u };
|
|
103
|
+
for (let t of Object.keys(e)) d[t] = Math.round(r[t] + (e[t] - r[t]) * n);
|
|
104
|
+
u = d, await l.write(a(d)), t < o && await new Promise((e) => setTimeout(e, i));
|
|
83
105
|
}
|
|
84
|
-
})(), await
|
|
106
|
+
})(), await f;
|
|
85
107
|
}
|
|
86
108
|
return {
|
|
87
|
-
async home() {
|
|
88
|
-
|
|
109
|
+
async home(e) {
|
|
110
|
+
e != null && e > 0 ? await h({ ...t }, e) : (p({ ...t }), await m());
|
|
89
111
|
},
|
|
90
|
-
async shiftHeadTo(e) {
|
|
91
|
-
|
|
112
|
+
async shiftHeadTo(e, t) {
|
|
113
|
+
t != null && t > 0 ? await h({ s1: e }, t) : (p({ s1: e }), await m());
|
|
92
114
|
},
|
|
93
|
-
async rollHeadTo(e) {
|
|
94
|
-
|
|
115
|
+
async rollHeadTo(e, t) {
|
|
116
|
+
t != null && t > 0 ? await h({ s2: e }, t) : (p({ s2: e }), await m());
|
|
95
117
|
},
|
|
96
|
-
async pitchHeadTo(e) {
|
|
97
|
-
|
|
118
|
+
async pitchHeadTo(e, t) {
|
|
119
|
+
t != null && t > 0 ? await h({ s3: e }, t) : (p({ s3: e }), await m());
|
|
98
120
|
},
|
|
99
|
-
async liftHeadTo(e) {
|
|
100
|
-
|
|
121
|
+
async liftHeadTo(e, t) {
|
|
122
|
+
t != null && t > 0 ? await h({ s5: e }, t) : (p({ s5: e }), await m());
|
|
101
123
|
},
|
|
102
|
-
async rotateBodyTo(e) {
|
|
103
|
-
|
|
124
|
+
async rotateBodyTo(e, t) {
|
|
125
|
+
t != null && t > 0 ? await h({ s4: e }, t) : (p({ s4: e }), await m());
|
|
126
|
+
},
|
|
127
|
+
async moveTo(e, t) {
|
|
128
|
+
t != null && t > 0 ? await h(e, t) : (p(e), await m());
|
|
104
129
|
},
|
|
105
130
|
get State() {
|
|
106
|
-
return structuredClone(
|
|
131
|
+
return structuredClone(d ?? u);
|
|
107
132
|
},
|
|
108
133
|
set State(e) {
|
|
109
|
-
|
|
110
|
-
...
|
|
134
|
+
d = {
|
|
135
|
+
...u,
|
|
111
136
|
...e
|
|
112
137
|
};
|
|
113
138
|
},
|
|
114
139
|
async sendServoState() {
|
|
115
|
-
await
|
|
140
|
+
await m();
|
|
116
141
|
},
|
|
117
142
|
destroy() {
|
|
118
|
-
|
|
143
|
+
l.destroy();
|
|
119
144
|
}
|
|
120
145
|
};
|
|
121
146
|
}
|
|
147
|
+
async function l(e, t) {
|
|
148
|
+
let n = t.split("\n");
|
|
149
|
+
for (let t = 0; t < n.length; t++) {
|
|
150
|
+
let r = n[t].trim(), i = t + 1;
|
|
151
|
+
if (r === "" || r.startsWith("#")) continue;
|
|
152
|
+
let a = r.split(/\s+/), o = a[0].toLowerCase();
|
|
153
|
+
switch (!0) {
|
|
154
|
+
case o === "home":
|
|
155
|
+
await e.home();
|
|
156
|
+
break;
|
|
157
|
+
case o === "shift-to": {
|
|
158
|
+
let t = Number(a[1]);
|
|
159
|
+
if (isNaN(t)) throw Error(`line ${i}: shift-to requires a numeric angle, got '${a[1]}'`);
|
|
160
|
+
await e.shiftHeadTo(t);
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
case o === "roll-to": {
|
|
164
|
+
let t = Number(a[1]);
|
|
165
|
+
if (isNaN(t)) throw Error(`line ${i}: roll-to requires a numeric angle, got '${a[1]}'`);
|
|
166
|
+
await e.rollHeadTo(t);
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
case o === "pitch-to": {
|
|
170
|
+
let t = Number(a[1]);
|
|
171
|
+
if (isNaN(t)) throw Error(`line ${i}: pitch-to requires a numeric angle, got '${a[1]}'`);
|
|
172
|
+
await e.pitchHeadTo(t);
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
case o === "rotate-to": {
|
|
176
|
+
let t = Number(a[1]);
|
|
177
|
+
if (isNaN(t)) throw Error(`line ${i}: rotate-to requires a numeric angle, got '${a[1]}'`);
|
|
178
|
+
await e.rotateBodyTo(t);
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
case o === "lift-to": {
|
|
182
|
+
let t = Number(a[1]);
|
|
183
|
+
if (isNaN(t)) throw Error(`line ${i}: lift-to requires a numeric angle, got '${a[1]}'`);
|
|
184
|
+
await e.liftHeadTo(t);
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
case o === "move": {
|
|
188
|
+
let t = {};
|
|
189
|
+
for (let e = 1; e < a.length; e += 2) {
|
|
190
|
+
let n = a[e].toLowerCase(), r = Number(a[e + 1]);
|
|
191
|
+
if (isNaN(r)) throw Error(`line ${i}: '${n}' requires a numeric angle, got '${a[e + 1]}'`);
|
|
192
|
+
switch (n) {
|
|
193
|
+
case "shift-to":
|
|
194
|
+
t.s1 = r;
|
|
195
|
+
break;
|
|
196
|
+
case "roll-to":
|
|
197
|
+
t.s2 = r;
|
|
198
|
+
break;
|
|
199
|
+
case "pitch-to":
|
|
200
|
+
t.s3 = r;
|
|
201
|
+
break;
|
|
202
|
+
case "rotate-to":
|
|
203
|
+
t.s4 = r;
|
|
204
|
+
break;
|
|
205
|
+
case "lift-to":
|
|
206
|
+
t.s5 = r;
|
|
207
|
+
break;
|
|
208
|
+
default: throw Error(`line ${i}: unknown move argument '${n}'`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (Object.keys(t).length === 0) throw Error(`line ${i}: move requires at least one servo argument`);
|
|
212
|
+
e.State = t, await e.sendServoState();
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
case o === "wait": {
|
|
216
|
+
let e = Number(a[1]);
|
|
217
|
+
if (isNaN(e) || e < 0) throw Error(`line ${i}: wait requires a non-negative number in ms, got '${a[1]}'`);
|
|
218
|
+
await new Promise((t) => setTimeout(t, e));
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
default: throw Error(`line ${i}: unknown command '${o}'`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
122
225
|
//#endregion
|
|
123
|
-
export { e as BaudRate, t as HomePosition, n as SafeRange, r as ServoSpeed, a as buildDirectPacket,
|
|
226
|
+
export { e as BaudRate, t as HomePosition, n as SafeRange, r as ServoSpeed, a as buildDirectPacket, c as openNova, l as runScript };
|