tassello 0.1.0 → 0.2.0
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/CHANGELOG.md +26 -0
- package/README.md +66 -3
- package/dist/server/behavior_tree_file.d.ts +17 -0
- package/dist/server/behavior_tree_file.js +232 -0
- package/dist/server/behavior_tree_files.d.ts +18 -0
- package/dist/server/behavior_tree_files.js +405 -0
- package/dist/server/behavior_tree_log.d.ts +10 -0
- package/dist/server/behavior_tree_log.js +132 -0
- package/dist/server/behavior_tree_playback.d.ts +57 -0
- package/dist/server/behavior_tree_playback.js +188 -0
- package/dist/server/behavior_tree_state.d.ts +38 -0
- package/dist/server/behavior_tree_state.js +220 -0
- package/dist/server/behavior_trees.d.ts +29 -0
- package/dist/server/behavior_trees.js +362 -0
- package/dist/server/files.d.ts +5 -2
- package/dist/server/files.js +32 -13
- package/dist/server/http.js +7 -5
- package/dist/server/rosbag.d.ts +25 -0
- package/dist/server/rosbag.js +418 -0
- package/dist/server/rosout.d.ts +16 -0
- package/dist/server/rosout.js +203 -0
- package/dist/server/rosout_log.d.ts +20 -0
- package/dist/server/rosout_log.js +66 -0
- package/dist/server/server.js +20 -4
- package/dist/server/session.d.ts +7 -0
- package/dist/server/session.js +63 -0
- package/dist/server/systemd.js +1 -1
- package/dist/server/systemd_format.d.ts +1 -0
- package/dist/server/systemd_format.js +19 -3
- package/dist/shared/bt_nodes.d.ts +25 -0
- package/dist/shared/bt_nodes.js +180 -0
- package/dist/shared/protocol.d.ts +157 -2
- package/dist/shared/protocol.js +4 -0
- package/dist/shared/xml.d.ts +25 -0
- package/dist/shared/xml.js +164 -0
- package/dist/types/client/api.d.ts +4 -3
- package/dist/types/client/connection.d.ts +31 -1
- package/dist/types/client/dialogs.d.ts +2 -1
- package/dist/types/client/icons.d.ts +24 -0
- package/dist/types/client/sdk/index.d.ts +3 -2
- package/dist/types/client/widgets/types.d.ts +5 -1
- package/dist/types/shared/bt_nodes.d.ts +25 -0
- package/dist/types/shared/protocol.d.ts +157 -2
- package/dist/www/assets/Joystick.js +1 -1
- package/dist/www/assets/RobotModelView.js +2 -2
- package/dist/www/assets/icons.js +1 -1
- package/dist/www/assets/index.css +1 -1
- package/dist/www/assets/index.js +28 -1
- package/dist/www/assets/shared/tassello-sdk.js +1 -1
- package/docs/plugins.md +8 -0
- package/package.json +2 -1
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
// The logs of behavior trees (nav2_msgs/msg/BehaviorTreeLog), for the
|
|
2
|
+
// behavior tree inspector widgets: the tree a widget names (its XML file, see
|
|
3
|
+
// behavior_tree_file.ts) and what its nodes are doing (behavior_tree_state.ts).
|
|
4
|
+
//
|
|
5
|
+
// A log is a stream of transitions, not of states: none of them may be lost,
|
|
6
|
+
// unlike the latest-only messages of topics.ts. Each topic is subscribed once,
|
|
7
|
+
// with its publishers' QoS and a deep queue; each tree of it keeps its nodes'
|
|
8
|
+
// state, sent to its pages as it changes, at most BT_RATE a second. The state
|
|
9
|
+
// outlives its last page for LINGER_MS: a page reloaded, or a panel shown
|
|
10
|
+
// again, finds the nodes as they are, which the log only says when they change.
|
|
11
|
+
//
|
|
12
|
+
// A topic is recorded into a rosbag (ros2 bag record) when a page asks, until
|
|
13
|
+
// one asks to stop: every page following it is told. A bag is played back for
|
|
14
|
+
// the page that opens it (behavior_tree_playback.ts).
|
|
15
|
+
import { promises as fs } from 'node:fs';
|
|
16
|
+
import { BT_RATE } from '../shared/protocol.js';
|
|
17
|
+
import { readTree } from './behavior_tree_file.js';
|
|
18
|
+
import { logTransitions } from './behavior_tree_log.js';
|
|
19
|
+
import { BehaviorTreePlayback } from './behavior_tree_playback.js';
|
|
20
|
+
import { BehaviorTreeState } from './behavior_tree_state.js';
|
|
21
|
+
import { publishersQos } from './qos.js';
|
|
22
|
+
import { BagRecorder, newBagFolder } from './rosbag.js';
|
|
23
|
+
import { absoluteTopic, errorMessage } from './util.js';
|
|
24
|
+
// How often the graph is checked for the topic's publishers, until it has one
|
|
25
|
+
const RETRY_PERIOD_MS = 1000;
|
|
26
|
+
// Messages queued while the server is busy: a tree logs bursts of them
|
|
27
|
+
const QUEUE_DEPTH = 100;
|
|
28
|
+
const LINGER_MS = 10 * 60_000;
|
|
29
|
+
// How often the tree's files are checked for changes, while watched
|
|
30
|
+
const FILE_CHECK_MS = 3000;
|
|
31
|
+
// The subscription to a topic, shared by its trees
|
|
32
|
+
class LogTopic {
|
|
33
|
+
listeners = new Set();
|
|
34
|
+
node;
|
|
35
|
+
logger;
|
|
36
|
+
topic;
|
|
37
|
+
subscription = null;
|
|
38
|
+
retryTimer = null;
|
|
39
|
+
state = 'waiting';
|
|
40
|
+
message = '';
|
|
41
|
+
constructor(node, logger, topic) {
|
|
42
|
+
this.node = node;
|
|
43
|
+
this.logger = logger;
|
|
44
|
+
this.topic = topic;
|
|
45
|
+
this.subscribe();
|
|
46
|
+
}
|
|
47
|
+
add(listener) {
|
|
48
|
+
this.listeners.add(listener);
|
|
49
|
+
listener.onStatus(this.state, this.message);
|
|
50
|
+
}
|
|
51
|
+
destroy() {
|
|
52
|
+
if (this.retryTimer) {
|
|
53
|
+
clearTimeout(this.retryTimer);
|
|
54
|
+
}
|
|
55
|
+
if (this.subscription) {
|
|
56
|
+
this.node.destroySubscription(this.subscription);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
subscribe() {
|
|
60
|
+
this.retryTimer = null;
|
|
61
|
+
const match = publishersQos(this.node, this.topic, null, QUEUE_DEPTH);
|
|
62
|
+
if (!('qos' in match)) {
|
|
63
|
+
this.setState(match.state, match.message);
|
|
64
|
+
this.retryTimer = setTimeout(() => this.subscribe(), RETRY_PERIOD_MS);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
this.subscription = this.node.createSubscription(
|
|
69
|
+
// @ts-expect-error: the type is a runtime string, not one of the literal types
|
|
70
|
+
match.type, this.topic, { qos: match.qos }, (raw) => this.onMessage(raw, match.type));
|
|
71
|
+
}
|
|
72
|
+
catch (e) {
|
|
73
|
+
this.setState('error', `Cannot subscribe to ${this.topic}: ${errorMessage(e)}`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
this.setState('waiting', `Waiting for transitions on ${this.topic}`);
|
|
77
|
+
}
|
|
78
|
+
onMessage(raw, type) {
|
|
79
|
+
let transitions;
|
|
80
|
+
try {
|
|
81
|
+
transitions = logTransitions(raw);
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
this.setState('error', `${this.topic} is a ${type}, ${errorMessage(e)}`);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
this.setState('streaming', type);
|
|
88
|
+
for (const listener of this.listeners) {
|
|
89
|
+
listener.onTransitions(transitions);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
setState(state, message) {
|
|
93
|
+
if (state === this.state && message === this.message) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (state === 'error') {
|
|
97
|
+
this.logger.warn(message);
|
|
98
|
+
}
|
|
99
|
+
this.state = state;
|
|
100
|
+
this.message = message;
|
|
101
|
+
for (const listener of this.listeners) {
|
|
102
|
+
listener.onStatus(state, message);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// A tree of a topic: its file, its nodes' state, its pages
|
|
107
|
+
class TreeMonitor {
|
|
108
|
+
watchers = new Set();
|
|
109
|
+
lingerTimer = null;
|
|
110
|
+
source;
|
|
111
|
+
uploadsDir;
|
|
112
|
+
logger;
|
|
113
|
+
tree = null;
|
|
114
|
+
recording;
|
|
115
|
+
state = new BehaviorTreeState([]);
|
|
116
|
+
// Transitions that came while the tree was read, applied after
|
|
117
|
+
queued = [];
|
|
118
|
+
// The files read, and their versions then
|
|
119
|
+
versions = new Map();
|
|
120
|
+
status = { state: 'waiting', message: '' };
|
|
121
|
+
flushTimer = null;
|
|
122
|
+
lastFlush = 0;
|
|
123
|
+
fileTimer = null;
|
|
124
|
+
destroyed = false;
|
|
125
|
+
constructor(source, uploadsDir, logger, recording) {
|
|
126
|
+
this.source = source;
|
|
127
|
+
this.uploadsDir = uploadsDir;
|
|
128
|
+
this.logger = logger;
|
|
129
|
+
this.recording = recording;
|
|
130
|
+
void this.load();
|
|
131
|
+
}
|
|
132
|
+
add(watcher) {
|
|
133
|
+
if (this.lingerTimer) {
|
|
134
|
+
clearTimeout(this.lingerTimer);
|
|
135
|
+
this.lingerTimer = null;
|
|
136
|
+
}
|
|
137
|
+
// What the others have not had yet, which the new one gets with the rest
|
|
138
|
+
this.flush();
|
|
139
|
+
this.watchers.add(watcher);
|
|
140
|
+
watcher.onStatus(this.status.state, this.status.message);
|
|
141
|
+
watcher.onRecording(this.recording);
|
|
142
|
+
if (this.tree) {
|
|
143
|
+
watcher.onTree(this.tree);
|
|
144
|
+
watcher.onUpdate({ reset: true, ...this.state.snapshot() });
|
|
145
|
+
}
|
|
146
|
+
this.fileTimer ??= setInterval(() => void this.checkFiles(), FILE_CHECK_MS);
|
|
147
|
+
}
|
|
148
|
+
remove(watcher) {
|
|
149
|
+
this.watchers.delete(watcher);
|
|
150
|
+
if (this.watchers.size === 0 && this.fileTimer) {
|
|
151
|
+
clearInterval(this.fileTimer);
|
|
152
|
+
this.fileTimer = null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
destroy() {
|
|
156
|
+
this.destroyed = true;
|
|
157
|
+
for (const timer of [this.flushTimer, this.lingerTimer]) {
|
|
158
|
+
if (timer) {
|
|
159
|
+
clearTimeout(timer);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (this.fileTimer) {
|
|
163
|
+
clearInterval(this.fileTimer);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
setRecording(recording) {
|
|
167
|
+
this.recording = recording;
|
|
168
|
+
for (const watcher of this.watchers) {
|
|
169
|
+
watcher.onRecording(recording);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
onStatus(state, message) {
|
|
173
|
+
this.status = { state, message };
|
|
174
|
+
for (const watcher of this.watchers) {
|
|
175
|
+
watcher.onStatus(state, message);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
onTransitions(transitions) {
|
|
179
|
+
const now = Date.now();
|
|
180
|
+
if (this.queued) {
|
|
181
|
+
this.queued.push({ transitions, at: now });
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
for (const transition of transitions) {
|
|
185
|
+
this.state.apply(transition, now);
|
|
186
|
+
}
|
|
187
|
+
this.scheduleFlush();
|
|
188
|
+
}
|
|
189
|
+
async load() {
|
|
190
|
+
const { tree, files } = await readTree(this.source, this.uploadsDir, this.logger);
|
|
191
|
+
if (this.destroyed) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
this.versions = new Map(await Promise.all(files.map(async (file) => [file, await version(file)])));
|
|
195
|
+
this.tree = tree;
|
|
196
|
+
this.state = new BehaviorTreeState(tree.nodes);
|
|
197
|
+
for (const { transitions, at } of this.queued ?? []) {
|
|
198
|
+
for (const transition of transitions) {
|
|
199
|
+
this.state.apply(transition, at);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
this.queued = null;
|
|
203
|
+
for (const watcher of this.watchers) {
|
|
204
|
+
watcher.onTree(tree);
|
|
205
|
+
watcher.onUpdate({ reset: true, ...this.state.snapshot() });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
// The tree is read again when one of its files changed: its pages start over
|
|
209
|
+
async checkFiles() {
|
|
210
|
+
if (this.queued || this.versions.size === 0) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
for (const [file, known] of this.versions) {
|
|
214
|
+
if ((await version(file)) !== known) {
|
|
215
|
+
this.queued = [];
|
|
216
|
+
await this.load();
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
scheduleFlush() {
|
|
222
|
+
if (this.flushTimer || this.watchers.size === 0) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const wait = Math.max(0, this.lastFlush + 1000 / BT_RATE - Date.now());
|
|
226
|
+
this.flushTimer = setTimeout(() => this.flush(), wait);
|
|
227
|
+
}
|
|
228
|
+
flush() {
|
|
229
|
+
if (this.flushTimer) {
|
|
230
|
+
clearTimeout(this.flushTimer);
|
|
231
|
+
this.flushTimer = null;
|
|
232
|
+
}
|
|
233
|
+
this.lastFlush = Date.now();
|
|
234
|
+
const changes = this.state.takeChanges();
|
|
235
|
+
if (!changes) {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
for (const watcher of this.watchers) {
|
|
239
|
+
watcher.onUpdate({ reset: false, ...changes });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// Size and time of a file, '' if gone
|
|
244
|
+
async function version(file) {
|
|
245
|
+
const stat = await fs.stat(file).catch(() => null);
|
|
246
|
+
return stat ? `${stat.size}-${stat.mtimeMs}` : '';
|
|
247
|
+
}
|
|
248
|
+
export class BehaviorTrees {
|
|
249
|
+
node;
|
|
250
|
+
logger;
|
|
251
|
+
uploadsDir;
|
|
252
|
+
topics = new Map();
|
|
253
|
+
// By topic, then tree source
|
|
254
|
+
monitors = new Map();
|
|
255
|
+
// By topic
|
|
256
|
+
recordings = new Map();
|
|
257
|
+
constructor(node, logger, uploadsDir) {
|
|
258
|
+
this.node = node;
|
|
259
|
+
this.logger = logger;
|
|
260
|
+
this.uploadsDir = uploadsDir;
|
|
261
|
+
}
|
|
262
|
+
// Until the returned function is called
|
|
263
|
+
watch(topic, tree, watcher) {
|
|
264
|
+
const name = absoluteTopic(topic);
|
|
265
|
+
const source = tree.trim();
|
|
266
|
+
let trees = this.monitors.get(name);
|
|
267
|
+
if (!trees) {
|
|
268
|
+
trees = new Map();
|
|
269
|
+
this.monitors.set(name, trees);
|
|
270
|
+
}
|
|
271
|
+
let monitor = trees.get(source);
|
|
272
|
+
if (!monitor) {
|
|
273
|
+
monitor = new TreeMonitor(source, this.uploadsDir, this.logger, this.recordingOf(name));
|
|
274
|
+
trees.set(source, monitor);
|
|
275
|
+
let log = this.topics.get(name);
|
|
276
|
+
if (!log) {
|
|
277
|
+
log = new LogTopic(this.node, this.logger, name);
|
|
278
|
+
this.topics.set(name, log);
|
|
279
|
+
}
|
|
280
|
+
log.add(monitor);
|
|
281
|
+
}
|
|
282
|
+
const shared = monitor;
|
|
283
|
+
shared.add(watcher);
|
|
284
|
+
return () => {
|
|
285
|
+
shared.remove(watcher);
|
|
286
|
+
if (shared.watchers.size === 0 && !shared.lingerTimer) {
|
|
287
|
+
shared.lingerTimer = setTimeout(() => this.forget(name, source, shared), LINGER_MS);
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
// Records the topic into a new folder, or stops (path null): resolves with
|
|
292
|
+
// the bag's folder, null if it was not recording
|
|
293
|
+
async record(topic, path) {
|
|
294
|
+
const name = absoluteTopic(topic);
|
|
295
|
+
const current = this.recordings.get(name);
|
|
296
|
+
if (path === null) {
|
|
297
|
+
if (!current?.recorder) {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
this.recordings.delete(name);
|
|
301
|
+
this.announce(name);
|
|
302
|
+
await current.recorder.stop();
|
|
303
|
+
this.logger.info(`Recorded ${name} into ${current.path}`);
|
|
304
|
+
return current.path;
|
|
305
|
+
}
|
|
306
|
+
if (current) {
|
|
307
|
+
throw new Error(`${name} is being recorded already, into ${current.path}`);
|
|
308
|
+
}
|
|
309
|
+
const recording = { path: '', since: 0, recorder: null };
|
|
310
|
+
this.recordings.set(name, recording);
|
|
311
|
+
try {
|
|
312
|
+
recording.path = await newBagFolder(path);
|
|
313
|
+
recording.recorder = await BagRecorder.start(recording.path, [name]);
|
|
314
|
+
}
|
|
315
|
+
catch (e) {
|
|
316
|
+
this.recordings.delete(name);
|
|
317
|
+
throw new Error(`Cannot record ${name}: ${errorMessage(e)}`);
|
|
318
|
+
}
|
|
319
|
+
recording.since = Date.now();
|
|
320
|
+
recording.recorder.onExit = (reason) => {
|
|
321
|
+
if (this.recordings.get(name) === recording) {
|
|
322
|
+
this.recordings.delete(name);
|
|
323
|
+
this.announce(name);
|
|
324
|
+
this.logger.warn(`Recording ${name} stopped: ${reason}`);
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
this.logger.info(`Recording ${name} into ${recording.path}`);
|
|
328
|
+
this.announce(name);
|
|
329
|
+
return recording.path;
|
|
330
|
+
}
|
|
331
|
+
// A bag played back for a page, until closed
|
|
332
|
+
play(bag, topic, tree, watcher) {
|
|
333
|
+
return new BehaviorTreePlayback({ bag, topic, tree: tree.trim(), uploadsDir: this.uploadsDir, logger: this.logger }, watcher);
|
|
334
|
+
}
|
|
335
|
+
// The recordings completed, as the server stops
|
|
336
|
+
async stopAll() {
|
|
337
|
+
const recorders = [...this.recordings.values()].flatMap((recording) => recording.recorder ?? []);
|
|
338
|
+
this.recordings.clear();
|
|
339
|
+
await Promise.all(recorders.map((recorder) => recorder.stop()));
|
|
340
|
+
}
|
|
341
|
+
recordingOf(topic) {
|
|
342
|
+
const recording = this.recordings.get(topic);
|
|
343
|
+
return recording?.recorder ? { path: recording.path, since: recording.since } : null;
|
|
344
|
+
}
|
|
345
|
+
announce(topic) {
|
|
346
|
+
for (const monitor of this.monitors.get(topic)?.values() ?? []) {
|
|
347
|
+
monitor.setRecording(this.recordingOf(topic));
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
forget(topic, source, monitor) {
|
|
351
|
+
monitor.destroy();
|
|
352
|
+
const trees = this.monitors.get(topic);
|
|
353
|
+
trees?.delete(source);
|
|
354
|
+
const log = this.topics.get(topic);
|
|
355
|
+
log?.listeners.delete(monitor);
|
|
356
|
+
if (trees?.size === 0) {
|
|
357
|
+
this.monitors.delete(topic);
|
|
358
|
+
log?.destroy();
|
|
359
|
+
this.topics.delete(topic);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
package/dist/server/files.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export declare const IMAGE_EXTENSIONS: Set<string>;
|
|
2
|
+
export type FileKind = 'image' | 'behavior-tree' | 'bag' | 'folder';
|
|
2
3
|
export declare const MAX_UPLOAD_BYTES: number;
|
|
4
|
+
export declare function fileKind(value: string | null): FileKind;
|
|
3
5
|
export declare function resolveImagePath(input: string, home?: string): string;
|
|
4
6
|
export declare function imageType(data: Buffer): string | null;
|
|
5
7
|
export interface DirectoryListing {
|
|
@@ -7,9 +9,10 @@ export interface DirectoryListing {
|
|
|
7
9
|
parent: string | null;
|
|
8
10
|
entries: {
|
|
9
11
|
name: string;
|
|
10
|
-
kind: 'dir' |
|
|
12
|
+
kind: 'dir' | FileKind;
|
|
11
13
|
}[];
|
|
12
14
|
}
|
|
13
|
-
export declare function listDirectory(input: string, home?: string): Promise<DirectoryListing>;
|
|
15
|
+
export declare function listDirectory(input: string, kind?: FileKind, home?: string): Promise<DirectoryListing>;
|
|
14
16
|
export declare function saveUpload(uploadsDir: string, data: Buffer): Promise<string>;
|
|
17
|
+
export declare function isBehaviorTreeXml(text: string): boolean;
|
|
15
18
|
export declare function isUploadName(name: string): boolean;
|
package/dist/server/files.js
CHANGED
|
@@ -1,16 +1,29 @@
|
|
|
1
|
-
// Images for the image viewer widgets
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Images for the image viewer widgets, and behavior trees (XML) for the
|
|
2
|
+
// behavior tree inspectors: files of the robot, which a page picks by
|
|
3
|
+
// browsing its directories, and files uploaded from a page, kept by the
|
|
4
|
+
// server next to the panels. Only those: the dashboard does not hand out any
|
|
5
|
+
// file of the robot. A page also browses for rosbags, and for folders.
|
|
5
6
|
import { createHash } from 'node:crypto';
|
|
6
7
|
import { promises as fs } from 'node:fs';
|
|
7
8
|
import os from 'node:os';
|
|
8
9
|
import path from 'node:path';
|
|
9
10
|
import { HttpError } from './panels.js';
|
|
10
11
|
export const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']);
|
|
12
|
+
const EXTENSIONS = {
|
|
13
|
+
image: IMAGE_EXTENSIONS,
|
|
14
|
+
'behavior-tree': new Set(['.xml']),
|
|
15
|
+
bag: new Set(['.mcap', '.db3']),
|
|
16
|
+
folder: new Set(),
|
|
17
|
+
};
|
|
18
|
+
const FILE_KINDS = new Set(Object.keys(EXTENSIONS));
|
|
11
19
|
export const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
|
|
12
20
|
// Uploaded files are named after their content: no clash, no duplicate
|
|
13
|
-
const UPLOAD_NAME = /^[0-9a-f]{16}\.(png|jpg|gif|webp|bmp|svg)$/;
|
|
21
|
+
const UPLOAD_NAME = /^[0-9a-f]{16}\.(png|jpg|gif|webp|bmp|svg|xml)$/;
|
|
22
|
+
// BT.CPP's XML, whose root element is <root>
|
|
23
|
+
const BEHAVIOR_TREE = /^(<\?xml[^>]*>\s*)?(<!--[\s\S]*?-->\s*)*<root[\s>]/;
|
|
24
|
+
export function fileKind(value) {
|
|
25
|
+
return value && FILE_KINDS.has(value) ? value : 'image';
|
|
26
|
+
}
|
|
14
27
|
// A robot path (absolute or ~/...) to an image file; throws for anything else
|
|
15
28
|
export function resolveImagePath(input, home = os.homedir()) {
|
|
16
29
|
const trimmed = input.trim();
|
|
@@ -48,8 +61,8 @@ export function imageType(data) {
|
|
|
48
61
|
}
|
|
49
62
|
return null;
|
|
50
63
|
}
|
|
51
|
-
// Of the directory (~ by default)
|
|
52
|
-
export async function listDirectory(input, home = os.homedir()) {
|
|
64
|
+
// Of the directory (~ by default): its directories and files of that kind
|
|
65
|
+
export async function listDirectory(input, kind = 'image', home = os.homedir()) {
|
|
53
66
|
const trimmed = input.trim() || '~';
|
|
54
67
|
const expanded = trimmed === '~' || trimmed.startsWith('~/') ? path.join(home, trimmed.slice(1)) : trimmed;
|
|
55
68
|
if (!path.isAbsolute(expanded)) {
|
|
@@ -74,26 +87,32 @@ export async function listDirectory(input, home = os.homedir()) {
|
|
|
74
87
|
? await fs.stat(path.join(dir, entry.name)).catch(() => null)
|
|
75
88
|
: entry;
|
|
76
89
|
if (target?.isDirectory()) {
|
|
77
|
-
|
|
90
|
+
const bag = kind === 'bag' && (await fs.stat(path.join(dir, entry.name, 'metadata.yaml')).catch(() => null));
|
|
91
|
+
entries.push({ name: entry.name, kind: bag ? 'bag' : 'dir' });
|
|
78
92
|
}
|
|
79
|
-
else if (target?.isFile() &&
|
|
80
|
-
entries.push({ name: entry.name, kind
|
|
93
|
+
else if (target?.isFile() && EXTENSIONS[kind].has(path.extname(entry.name).toLowerCase())) {
|
|
94
|
+
entries.push({ name: entry.name, kind });
|
|
81
95
|
}
|
|
82
96
|
}
|
|
83
97
|
entries.sort((a, b) => (a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === 'dir' ? -1 : 1));
|
|
84
98
|
return { dir, parent: dir === path.parse(dir).root ? null : path.dirname(dir), entries };
|
|
85
99
|
}
|
|
86
|
-
// Saves an uploaded image; returns its name in the uploads
|
|
100
|
+
// Saves an uploaded image or behavior tree; returns its name in the uploads
|
|
101
|
+
// directory
|
|
87
102
|
export async function saveUpload(uploadsDir, data) {
|
|
88
|
-
const type = imageType(data);
|
|
103
|
+
const type = imageType(data) ?? (isBehaviorTreeXml(data.subarray(0, 64 * 1024).toString('utf8')) ? 'xml' : null);
|
|
89
104
|
if (!type) {
|
|
90
|
-
throw new HttpError(415, 'Not an image
|
|
105
|
+
throw new HttpError(415, 'Not an image (PNG, JPEG, GIF, WebP, BMP, SVG) nor a behavior tree (XML)');
|
|
91
106
|
}
|
|
92
107
|
const name = `${createHash('sha256').update(data).digest('hex').slice(0, 16)}.${type}`;
|
|
93
108
|
await fs.mkdir(uploadsDir, { recursive: true });
|
|
94
109
|
await fs.writeFile(path.join(uploadsDir, name), data);
|
|
95
110
|
return name;
|
|
96
111
|
}
|
|
112
|
+
// Whether the text is BT.CPP's XML, by its first element: <root>
|
|
113
|
+
export function isBehaviorTreeXml(text) {
|
|
114
|
+
return BEHAVIOR_TREE.test(text.slice(0, 64 * 1024).trimStart());
|
|
115
|
+
}
|
|
97
116
|
export function isUploadName(name) {
|
|
98
117
|
return UPLOAD_NAME.test(name);
|
|
99
118
|
}
|
package/dist/server/http.js
CHANGED
|
@@ -12,9 +12,10 @@
|
|
|
12
12
|
// directory, as package://<package>/<path> names it: the meshes of
|
|
13
13
|
// the robot description
|
|
14
14
|
// GET /api/files?path=<path> an image of the robot (absolute or ~/ path)
|
|
15
|
-
// GET /api/files/list?dir=<dir>
|
|
16
|
-
//
|
|
17
|
-
//
|
|
15
|
+
// GET /api/files/list?dir=<dir>[&kind=behavior-tree]
|
|
16
|
+
// its subdirectories and images (or XML files)
|
|
17
|
+
// POST /api/uploads an image or a behavior tree (the body) -> {name}
|
|
18
|
+
// GET /api/uploads/<name> an uploaded file
|
|
18
19
|
// GET /api/plugins [PluginInfo] of the plugins loaded
|
|
19
20
|
// GET /plugins/<name>/<path> a file of the plugin's client folder
|
|
20
21
|
import { createReadStream, promises as fs } from 'node:fs';
|
|
@@ -22,7 +23,7 @@ import path from 'node:path';
|
|
|
22
23
|
import { promisify } from 'node:util';
|
|
23
24
|
import { gzip } from 'node:zlib';
|
|
24
25
|
import { DASHBOARD_CLIENT_HEADER } from '../shared/protocol.js';
|
|
25
|
-
import { isUploadName, listDirectory, MAX_UPLOAD_BYTES, resolveImagePath, saveUpload } from './files.js';
|
|
26
|
+
import { fileKind, isUploadName, listDirectory, MAX_UPLOAD_BYTES, resolveImagePath, saveUpload } from './files.js';
|
|
26
27
|
import { HttpError } from './panels.js';
|
|
27
28
|
import { errorMessage, packageShareDirectory } from './util.js';
|
|
28
29
|
// By lowercase extension: meshes come as .STL as often as .stl
|
|
@@ -38,6 +39,7 @@ const CONTENT_TYPES = {
|
|
|
38
39
|
'.jpeg': 'image/jpeg',
|
|
39
40
|
'.gif': 'image/gif',
|
|
40
41
|
'.webp': 'image/webp',
|
|
42
|
+
'.xml': 'application/xml; charset=utf-8',
|
|
41
43
|
'.bmp': 'image/bmp',
|
|
42
44
|
'.ico': 'image/x-icon',
|
|
43
45
|
'.woff2': 'font/woff2',
|
|
@@ -143,7 +145,7 @@ async function api({ store, topics, uploadsDir, plugins }, req, res, route) {
|
|
|
143
145
|
return serveFile(path.dirname(file), path.basename(file), req, res, SANDBOXED);
|
|
144
146
|
}
|
|
145
147
|
if (id === 'list' && rest.length === 0) {
|
|
146
|
-
return sendJson(res, 200, await listDirectory(query.get('dir') ?? ''));
|
|
148
|
+
return sendJson(res, 200, await listDirectory(query.get('dir') ?? '', fileKind(query.get('kind'))));
|
|
147
149
|
}
|
|
148
150
|
throw new HttpError(404, `No API at ${req.url}`);
|
|
149
151
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface BagTopic {
|
|
2
|
+
name: string;
|
|
3
|
+
type: string;
|
|
4
|
+
}
|
|
5
|
+
export interface BagMessage {
|
|
6
|
+
time: number;
|
|
7
|
+
data: Uint8Array;
|
|
8
|
+
}
|
|
9
|
+
export type BagTopics = Map<string, {
|
|
10
|
+
topic: BagTopic;
|
|
11
|
+
messages: BagMessage[];
|
|
12
|
+
}>;
|
|
13
|
+
export declare function bagFiles(input: string, home?: string): Promise<string[]>;
|
|
14
|
+
export declare function readBag(files: string[], accept: (topic: BagTopic) => boolean): Promise<BagTopics>;
|
|
15
|
+
export declare function newBagFolder(input: string, home?: string): Promise<string>;
|
|
16
|
+
export declare class BagRecorder {
|
|
17
|
+
readonly folder: string;
|
|
18
|
+
onExit: ((reason: string) => void) | null;
|
|
19
|
+
private readonly child;
|
|
20
|
+
private output;
|
|
21
|
+
private stopping;
|
|
22
|
+
private constructor();
|
|
23
|
+
static start(folder: string, topics: string[]): Promise<BagRecorder>;
|
|
24
|
+
stop(): Promise<void>;
|
|
25
|
+
}
|