nova-control-node 0.0.4 → 0.0.6
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 +55 -2
- package/dist/nova-control-node.d.ts +11 -6
- package/dist/nova-control-node.js +135 -32
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ Node.js ESM module for controlling the [Creoqode Nova DIY AI Robot](../../README
|
|
|
11
11
|
| **Node.js 22+** | required at runtime and for the build toolchain. Download from [nodejs.org](https://nodejs.org). |
|
|
12
12
|
| **`serialport` ≥ 12** | runtime dependency — installed automatically with this package. Requires a C++ build toolchain (`node-gyp`) on first install if no pre-built binary is available for your platform. |
|
|
13
13
|
| **USB serial permissions** | on Linux, add your user to the `dialout` group (`sudo usermod -aG dialout $USER`) and re-login. On macOS and Windows no extra configuration is normally required. |
|
|
14
|
-
| **Arduino sketch** | the matching `Nova_SerialController.ino` sketch must be flashed to the robot's Arduino board (baud rate 9600, 8N1). |
|
|
14
|
+
| **Arduino sketch** | the matching [`Nova_SerialController.ino`](../../Nova_SerialController.ino) sketch must be flashed to the robot's Arduino board (baud rate 9600, 8N1). |
|
|
15
15
|
|
|
16
16
|
---
|
|
17
17
|
|
|
@@ -64,15 +64,32 @@ Assembles a 5-byte direct servo control packet from the given servo state. Each
|
|
|
64
64
|
### `openNova`
|
|
65
65
|
|
|
66
66
|
```typescript
|
|
67
|
-
async function openNova (
|
|
67
|
+
async function openNova (
|
|
68
|
+
PortPath: string,
|
|
69
|
+
Rate?: number,
|
|
70
|
+
Options?: NovaOptions
|
|
71
|
+
):Promise<NovaController>
|
|
68
72
|
```
|
|
69
73
|
|
|
70
74
|
Opens the USB serial port at `PortPath` and returns a `NovaController`.
|
|
71
75
|
|
|
72
76
|
`PortPath` examples: `/dev/ttyACM0` or `/dev/ttyUSB0` on Linux/macOS, `COM3` on Windows.
|
|
73
77
|
|
|
78
|
+
An optional second argument `Rate` overrides the default baud rate of 9600. An optional third argument `Options` configures the timing behaviour (see `NovaOptions` below).
|
|
79
|
+
|
|
74
80
|
The returned promise rejects if the port cannot be opened (e.g. wrong path, permission denied, or device not connected). On success the promise resolves after the 2-second Arduino reset delay that follows every port open.
|
|
75
81
|
|
|
82
|
+
### `NovaOptions`
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
interface NovaOptions {
|
|
86
|
+
StepIntervalMs?: number // ms between interpolation steps; default 20 (50 Hz); 0 = instant
|
|
87
|
+
RampRatio?: number // fraction of withinMS used for each ramp phase; default 0.25; range 0–0.499
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
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.
|
|
92
|
+
|
|
76
93
|
### `NovaController`
|
|
77
94
|
|
|
78
95
|
```typescript
|
|
@@ -98,15 +115,41 @@ interface NovaController {
|
|
|
98
115
|
| `pitchHeadTo(Degrees)` | sets s3 — head up `> 110°`, down toward `40°` |
|
|
99
116
|
| `liftHeadTo(Degrees)` | sets s5 — secondary head up/down, range `20°`–`150°` |
|
|
100
117
|
| `rotateBodyTo(Degrees)` | sets s4 — rotates the entire body around the Z-axis |
|
|
118
|
+
| `moveTo(Target, withinMS?)` | moves the servos listed in `Target` to their target angles; with `withinMS`, uses the trapezoidal profile |
|
|
101
119
|
| `State` (get) | returns a deep copy of the pending state if any, else the last-sent state |
|
|
102
120
|
| `State` (set) | replaces any pending entry with `Update` merged onto the *last-sent* state (not onto pending); flush with `sendServoState()` |
|
|
103
121
|
| `sendServoState()` | flushes any pending state update to the Arduino |
|
|
104
122
|
| `destroy()` | closes the serial port |
|
|
105
123
|
|
|
124
|
+
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`).
|
|
125
|
+
|
|
106
126
|
`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
127
|
|
|
108
128
|
Sends are serialised internally: concurrent method calls and `sendServoState()` calls never overlap on the wire. Each write awaits both `Port.write` and `Port.drain` before resolving, ensuring the 5-byte packet is fully flushed to the OS serial buffer. 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
129
|
|
|
130
|
+
### `runScript`
|
|
131
|
+
|
|
132
|
+
```typescript
|
|
133
|
+
async function runScript (Nova:NovaController, Script:string):Promise<void>
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
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.
|
|
137
|
+
|
|
138
|
+
Supported commands:
|
|
139
|
+
|
|
140
|
+
| command | description |
|
|
141
|
+
| --- | --- |
|
|
142
|
+
| `home` | send all servos to home positions |
|
|
143
|
+
| `shift-to <deg>` | s1 — head forward / back |
|
|
144
|
+
| `roll-to <deg>` | s2 — head CW / CCW |
|
|
145
|
+
| `pitch-to <deg>` | s3 — head up / down |
|
|
146
|
+
| `rotate-to <deg>` | s4 — body Z-axis rotation |
|
|
147
|
+
| `lift-to <deg>` | s5 — secondary head axis |
|
|
148
|
+
| `move [key val …]` | set multiple servos atomically (e.g. `move shift-to 100 rotate-to 120`) |
|
|
149
|
+
| `wait <ms>` | pause for the given number of milliseconds |
|
|
150
|
+
|
|
151
|
+
Throws a descriptive error containing the line number if an unknown command or invalid argument is encountered.
|
|
152
|
+
|
|
110
153
|
### Types
|
|
111
154
|
|
|
112
155
|
```typescript
|
|
@@ -198,6 +241,16 @@ Output is written to `packages/nova-control-node/dist/`.
|
|
|
198
241
|
|
|
199
242
|
---
|
|
200
243
|
|
|
244
|
+
## Related packages
|
|
245
|
+
|
|
246
|
+
| package | description |
|
|
247
|
+
| --- | --- |
|
|
248
|
+
| [`nova-control-browser`](../nova-control-browser/README.md) | same API for the browser via the Web Serial API |
|
|
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
|
+
|
|
201
254
|
## License
|
|
202
255
|
|
|
203
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(PortPath: string, Rate?: number, 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
|
|
|
@@ -59,22 +59,29 @@ async function s(t, n) {
|
|
|
59
59
|
}
|
|
60
60
|
};
|
|
61
61
|
}
|
|
62
|
-
|
|
63
|
-
let
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
62
|
+
function c(e, t) {
|
|
63
|
+
let n = Math.min(.499, Math.max(0, t)), r = 1 / (1 - n);
|
|
64
|
+
if (e <= n) return r * e * e / (2 * n);
|
|
65
|
+
if (e <= 1 - n) return r * (e - n / 2);
|
|
66
|
+
let i = 1 - e;
|
|
67
|
+
return 1 - r * i * i / (2 * n);
|
|
68
|
+
}
|
|
69
|
+
async function l(e, r = t, a) {
|
|
70
|
+
let l = a?.StepIntervalMs ?? 20, u = a?.RampRatio ?? .25, d = await s(e, r), f = { ...n }, p, m = Promise.resolve();
|
|
71
|
+
function h(e) {
|
|
72
|
+
p = {
|
|
73
|
+
...p ?? f,
|
|
67
74
|
...e
|
|
68
75
|
};
|
|
69
76
|
}
|
|
70
|
-
async function
|
|
71
|
-
let e =
|
|
72
|
-
|
|
77
|
+
async function g() {
|
|
78
|
+
let e = m;
|
|
79
|
+
m = (async () => {
|
|
73
80
|
try {
|
|
74
81
|
await e;
|
|
75
82
|
} catch {}
|
|
76
|
-
for (;
|
|
77
|
-
let e =
|
|
83
|
+
for (; p != null;) {
|
|
84
|
+
let e = p, t = !0, n = { ...f };
|
|
78
85
|
for (let r of [
|
|
79
86
|
"s1",
|
|
80
87
|
"s2",
|
|
@@ -82,48 +89,144 @@ async function c(e, r = t, a) {
|
|
|
82
89
|
"s4",
|
|
83
90
|
"s5"
|
|
84
91
|
]) {
|
|
85
|
-
let a = e[r] -
|
|
86
|
-
Math.abs(a) > o ? (n[r] =
|
|
92
|
+
let a = e[r] - f[r], o = l > 0 ? i[r] * l : Infinity;
|
|
93
|
+
Math.abs(a) > o ? (n[r] = f[r] + Math.sign(a) * o, t = !1) : n[r] = e[r];
|
|
87
94
|
}
|
|
88
|
-
t && (
|
|
95
|
+
t && (p = void 0), f = { ...n }, await d.write(o(n)), t || await new Promise((e) => setTimeout(e, l));
|
|
96
|
+
}
|
|
97
|
+
})(), await m;
|
|
98
|
+
}
|
|
99
|
+
async function _(e, t) {
|
|
100
|
+
let n = m;
|
|
101
|
+
m = (async () => {
|
|
102
|
+
try {
|
|
103
|
+
await n;
|
|
104
|
+
} catch {}
|
|
105
|
+
let r = { ...f }, i = l > 0 ? Math.max(1, Math.round(t / l)) : 1;
|
|
106
|
+
p = void 0;
|
|
107
|
+
for (let t = 1; t <= i; t++) {
|
|
108
|
+
let n = c(t / i, u), a = { ...f };
|
|
109
|
+
for (let t of Object.keys(e)) a[t] = Math.round(r[t] + (e[t] - r[t]) * n);
|
|
110
|
+
f = a, await d.write(o(a)), t < i && await new Promise((e) => setTimeout(e, l));
|
|
89
111
|
}
|
|
90
|
-
})(), await
|
|
112
|
+
})(), await m;
|
|
91
113
|
}
|
|
92
114
|
return {
|
|
93
|
-
async home() {
|
|
94
|
-
|
|
115
|
+
async home(e) {
|
|
116
|
+
e != null && e > 0 ? await _({ ...n }, e) : (h({ ...n }), await g());
|
|
95
117
|
},
|
|
96
|
-
async shiftHeadTo(e) {
|
|
97
|
-
|
|
118
|
+
async shiftHeadTo(e, t) {
|
|
119
|
+
t != null && t > 0 ? await _({ s1: e }, t) : (h({ s1: e }), await g());
|
|
98
120
|
},
|
|
99
|
-
async rollHeadTo(e) {
|
|
100
|
-
|
|
121
|
+
async rollHeadTo(e, t) {
|
|
122
|
+
t != null && t > 0 ? await _({ s2: e }, t) : (h({ s2: e }), await g());
|
|
101
123
|
},
|
|
102
|
-
async pitchHeadTo(e) {
|
|
103
|
-
|
|
124
|
+
async pitchHeadTo(e, t) {
|
|
125
|
+
t != null && t > 0 ? await _({ s3: e }, t) : (h({ s3: e }), await g());
|
|
104
126
|
},
|
|
105
|
-
async liftHeadTo(e) {
|
|
106
|
-
|
|
127
|
+
async liftHeadTo(e, t) {
|
|
128
|
+
t != null && t > 0 ? await _({ s5: e }, t) : (h({ s5: e }), await g());
|
|
107
129
|
},
|
|
108
|
-
async rotateBodyTo(e) {
|
|
109
|
-
|
|
130
|
+
async rotateBodyTo(e, t) {
|
|
131
|
+
t != null && t > 0 ? await _({ s4: e }, t) : (h({ s4: e }), await g());
|
|
132
|
+
},
|
|
133
|
+
async moveTo(e, t) {
|
|
134
|
+
t != null && t > 0 ? await _(e, t) : (h(e), await g());
|
|
110
135
|
},
|
|
111
136
|
get State() {
|
|
112
|
-
return structuredClone(
|
|
137
|
+
return structuredClone(p ?? f);
|
|
113
138
|
},
|
|
114
139
|
set State(e) {
|
|
115
|
-
|
|
116
|
-
...
|
|
140
|
+
p = {
|
|
141
|
+
...f,
|
|
117
142
|
...e
|
|
118
143
|
};
|
|
119
144
|
},
|
|
120
145
|
async sendServoState() {
|
|
121
|
-
await
|
|
146
|
+
await g();
|
|
122
147
|
},
|
|
123
148
|
destroy() {
|
|
124
|
-
|
|
149
|
+
d.destroy();
|
|
125
150
|
}
|
|
126
151
|
};
|
|
127
152
|
}
|
|
153
|
+
async function u(e, t) {
|
|
154
|
+
let n = t.split("\n");
|
|
155
|
+
for (let t = 0; t < n.length; t++) {
|
|
156
|
+
let r = n[t].trim(), i = t + 1;
|
|
157
|
+
if (r === "" || r.startsWith("#")) continue;
|
|
158
|
+
let a = r.split(/\s+/), o = a[0].toLowerCase();
|
|
159
|
+
switch (!0) {
|
|
160
|
+
case o === "home":
|
|
161
|
+
await e.home();
|
|
162
|
+
break;
|
|
163
|
+
case o === "shift-to": {
|
|
164
|
+
let t = Number(a[1]);
|
|
165
|
+
if (isNaN(t)) throw Error(`line ${i}: shift-to requires a numeric angle, got '${a[1]}'`);
|
|
166
|
+
await e.shiftHeadTo(t);
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
case o === "roll-to": {
|
|
170
|
+
let t = Number(a[1]);
|
|
171
|
+
if (isNaN(t)) throw Error(`line ${i}: roll-to requires a numeric angle, got '${a[1]}'`);
|
|
172
|
+
await e.rollHeadTo(t);
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
case o === "pitch-to": {
|
|
176
|
+
let t = Number(a[1]);
|
|
177
|
+
if (isNaN(t)) throw Error(`line ${i}: pitch-to requires a numeric angle, got '${a[1]}'`);
|
|
178
|
+
await e.pitchHeadTo(t);
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
case o === "rotate-to": {
|
|
182
|
+
let t = Number(a[1]);
|
|
183
|
+
if (isNaN(t)) throw Error(`line ${i}: rotate-to requires a numeric angle, got '${a[1]}'`);
|
|
184
|
+
await e.rotateBodyTo(t);
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
case o === "lift-to": {
|
|
188
|
+
let t = Number(a[1]);
|
|
189
|
+
if (isNaN(t)) throw Error(`line ${i}: lift-to requires a numeric angle, got '${a[1]}'`);
|
|
190
|
+
await e.liftHeadTo(t);
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
case o === "move": {
|
|
194
|
+
let t = {};
|
|
195
|
+
for (let e = 1; e < a.length; e += 2) {
|
|
196
|
+
let n = a[e].toLowerCase(), r = Number(a[e + 1]);
|
|
197
|
+
if (isNaN(r)) throw Error(`line ${i}: '${n}' requires a numeric angle, got '${a[e + 1]}'`);
|
|
198
|
+
switch (n) {
|
|
199
|
+
case "shift-to":
|
|
200
|
+
t.s1 = r;
|
|
201
|
+
break;
|
|
202
|
+
case "roll-to":
|
|
203
|
+
t.s2 = r;
|
|
204
|
+
break;
|
|
205
|
+
case "pitch-to":
|
|
206
|
+
t.s3 = r;
|
|
207
|
+
break;
|
|
208
|
+
case "rotate-to":
|
|
209
|
+
t.s4 = r;
|
|
210
|
+
break;
|
|
211
|
+
case "lift-to":
|
|
212
|
+
t.s5 = r;
|
|
213
|
+
break;
|
|
214
|
+
default: throw Error(`line ${i}: unknown move argument '${n}'`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (Object.keys(t).length === 0) throw Error(`line ${i}: move requires at least one servo argument`);
|
|
218
|
+
e.State = t, await e.sendServoState();
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
case o === "wait": {
|
|
222
|
+
let e = Number(a[1]);
|
|
223
|
+
if (isNaN(e) || e < 0) throw Error(`line ${i}: wait requires a non-negative number in ms, got '${a[1]}'`);
|
|
224
|
+
await new Promise((t) => setTimeout(t, e));
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
default: throw Error(`line ${i}: unknown command '${o}'`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
128
231
|
//#endregion
|
|
129
|
-
export { t as BaudRate, n as HomePosition, r as SafeRange, i as ServoSpeed, o as buildDirectPacket,
|
|
232
|
+
export { t as BaudRate, n as HomePosition, r as SafeRange, i as ServoSpeed, o as buildDirectPacket, l as openNova, u as runScript };
|