tassello 0.1.1 → 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,405 @@
|
|
|
1
|
+
// The behavior trees' XML files, for the behavior tree editors: read for
|
|
2
|
+
// their pages, and again whenever they change (saved by a page, or edited on
|
|
3
|
+
// the robot), with the nodes they can use: Nav2's, those of their includes
|
|
4
|
+
// and of the libraries the pages name (TreeNodesModel). And saved, only over
|
|
5
|
+
// behavior trees, and only if unchanged since the page read them.
|
|
6
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
7
|
+
import { promises as fs } from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { readModels } from '../shared/bt_nodes.js';
|
|
10
|
+
import { parseXmlSource, sourceText } from '../shared/xml.js';
|
|
11
|
+
import { resolveTreeFile } from './behavior_tree_file.js';
|
|
12
|
+
import { isBehaviorTreeXml } from './files.js';
|
|
13
|
+
import { errorMessage, packageShareDirectory } from './util.js';
|
|
14
|
+
// How often the files are checked for changes
|
|
15
|
+
const POLL_MS = 2000;
|
|
16
|
+
const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
|
17
|
+
// Includes of includes, at most
|
|
18
|
+
const MAX_INCLUDES = 50;
|
|
19
|
+
// A file of models kept parsed while unchanged; this many
|
|
20
|
+
const MODEL_CACHE = 64;
|
|
21
|
+
// Of a file's content
|
|
22
|
+
export function contentVersion(text) {
|
|
23
|
+
return createHash('sha256').update(text).digest('hex').slice(0, 16);
|
|
24
|
+
}
|
|
25
|
+
// Size, time and inode of a file, '' if gone
|
|
26
|
+
async function stamp(file) {
|
|
27
|
+
const stat = await fs.stat(file).catch(() => null);
|
|
28
|
+
return stat ? `${stat.size}-${stat.mtimeMs}-${stat.ino}` : '';
|
|
29
|
+
}
|
|
30
|
+
async function readText(file) {
|
|
31
|
+
const stat = await fs.stat(file);
|
|
32
|
+
if (!stat.isFile()) {
|
|
33
|
+
throw new Error(`${file} is not a file`);
|
|
34
|
+
}
|
|
35
|
+
if (stat.size > MAX_FILE_BYTES) {
|
|
36
|
+
throw new Error(`${file} is larger than ${MAX_FILE_BYTES / 1024 / 1024} MB`);
|
|
37
|
+
}
|
|
38
|
+
return fs.readFile(file, 'utf8');
|
|
39
|
+
}
|
|
40
|
+
function modelElement(text, element) {
|
|
41
|
+
return {
|
|
42
|
+
name: element.name,
|
|
43
|
+
attributes: element.attributes,
|
|
44
|
+
children: element.children.flatMap((child) => (child.type === 'element' ? [modelElement(text, child)] : [])),
|
|
45
|
+
text: sourceText(text, element),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
// The includes of a tree's root, resolved: relative to its file, or to their
|
|
49
|
+
// ros_pkg's share directory
|
|
50
|
+
function includes(root, file, warnings) {
|
|
51
|
+
return root.children.flatMap((child) => {
|
|
52
|
+
if (child.type !== 'element' || child.name !== 'include') {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
const { ros_pkg: rosPackage, path: relative = '' } = child.attributes;
|
|
56
|
+
const share = rosPackage ? packageShareDirectory(rosPackage) : path.dirname(file);
|
|
57
|
+
if (!share) {
|
|
58
|
+
warnings.push(`Not included: ${relative}, of the package ${rosPackage}, not found`);
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
return [path.resolve(share, relative)];
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
const modelCache = new Map();
|
|
65
|
+
// A file's models and BehaviorTrees, parsed again only when it changes
|
|
66
|
+
async function readModelFile(file) {
|
|
67
|
+
const current = await stamp(file);
|
|
68
|
+
const cached = modelCache.get(file);
|
|
69
|
+
if (cached && cached.stamp === current) {
|
|
70
|
+
return cached.parsed;
|
|
71
|
+
}
|
|
72
|
+
const text = await readText(file).catch((e) => {
|
|
73
|
+
throw new Error(`Cannot read ${file}: ${e.code ?? errorMessage(e)}`);
|
|
74
|
+
});
|
|
75
|
+
const root = parseXmlSource(text);
|
|
76
|
+
if (root.name !== 'root') {
|
|
77
|
+
throw new Error(`${file} is not of BT.CPP: its root element is <${root.name}>, not <root>`);
|
|
78
|
+
}
|
|
79
|
+
const parsed = {
|
|
80
|
+
models: readModels(modelElement(text, root)),
|
|
81
|
+
trees: root.children.flatMap((child) => (child.type === 'element' && child.name === 'BehaviorTree' && child.attributes.ID ? [child.attributes.ID] : [])),
|
|
82
|
+
root,
|
|
83
|
+
};
|
|
84
|
+
modelCache.delete(file);
|
|
85
|
+
modelCache.set(file, { stamp: current, parsed });
|
|
86
|
+
if (modelCache.size > MODEL_CACHE) {
|
|
87
|
+
modelCache.delete(modelCache.keys().next().value);
|
|
88
|
+
}
|
|
89
|
+
return parsed;
|
|
90
|
+
}
|
|
91
|
+
// The models of Nav2's nodes, if installed
|
|
92
|
+
function nav2Models() {
|
|
93
|
+
const share = packageShareDirectory('nav2_behavior_tree');
|
|
94
|
+
return share && path.join(share, 'nav2_tree_nodes.xml');
|
|
95
|
+
}
|
|
96
|
+
// What a page edits: the file, read again when it changes, for all its pages
|
|
97
|
+
class WatchedFile {
|
|
98
|
+
file;
|
|
99
|
+
readOnly;
|
|
100
|
+
watchers = new Set();
|
|
101
|
+
state = null;
|
|
102
|
+
stamp = null;
|
|
103
|
+
timer = null;
|
|
104
|
+
checking = null;
|
|
105
|
+
constructor(file, readOnly) {
|
|
106
|
+
this.file = file;
|
|
107
|
+
this.readOnly = readOnly;
|
|
108
|
+
}
|
|
109
|
+
get text() {
|
|
110
|
+
return this.state?.text ?? null;
|
|
111
|
+
}
|
|
112
|
+
add(watcher) {
|
|
113
|
+
this.watchers.add(watcher);
|
|
114
|
+
if (this.state) {
|
|
115
|
+
watcher.onFile(this.state);
|
|
116
|
+
}
|
|
117
|
+
this.timer ??= setInterval(() => void this.check(), POLL_MS);
|
|
118
|
+
void this.check();
|
|
119
|
+
}
|
|
120
|
+
// false once it has no watcher left
|
|
121
|
+
remove(watcher) {
|
|
122
|
+
this.watchers.delete(watcher);
|
|
123
|
+
if (this.watchers.size > 0) {
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
this.stop();
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
stop() {
|
|
130
|
+
if (this.timer) {
|
|
131
|
+
clearInterval(this.timer);
|
|
132
|
+
this.timer = null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// Reads it again if it changed: once at a time
|
|
136
|
+
check() {
|
|
137
|
+
this.checking ??= this.read().finally(() => (this.checking = null));
|
|
138
|
+
return this.checking;
|
|
139
|
+
}
|
|
140
|
+
// After a check started before it changed
|
|
141
|
+
async refresh() {
|
|
142
|
+
await this.checking;
|
|
143
|
+
await this.check();
|
|
144
|
+
}
|
|
145
|
+
async read() {
|
|
146
|
+
const current = await stamp(this.file);
|
|
147
|
+
if (current === this.stamp) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
this.stamp = current;
|
|
151
|
+
let state;
|
|
152
|
+
try {
|
|
153
|
+
const text = await readText(this.file);
|
|
154
|
+
let readOnly = this.readOnly;
|
|
155
|
+
if (!readOnly) {
|
|
156
|
+
readOnly = await fs.access(await fs.realpath(this.file), fs.constants.W_OK).then(() => null, () => 'The server’s user cannot write it: save it elsewhere');
|
|
157
|
+
}
|
|
158
|
+
state = { file: this.file, text, version: contentVersion(text), readOnly, error: null };
|
|
159
|
+
}
|
|
160
|
+
catch (e) {
|
|
161
|
+
const code = e.code;
|
|
162
|
+
const error = code === 'ENOENT' ? `No file ${this.file}` : `Cannot read ${this.file}: ${code ?? errorMessage(e)}`;
|
|
163
|
+
state = { file: this.file, text: null, version: null, readOnly: this.readOnly, error };
|
|
164
|
+
}
|
|
165
|
+
if (state.version !== this.state?.version || state.error !== this.state?.error || state.readOnly !== this.state?.readOnly) {
|
|
166
|
+
this.state = state;
|
|
167
|
+
for (const watcher of this.watchers) {
|
|
168
|
+
watcher.onFile(state);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// The models a page can use with its file and libraries: again whenever the
|
|
174
|
+
// file's includes, or any file read for them, change
|
|
175
|
+
class ModelsWatch {
|
|
176
|
+
watched;
|
|
177
|
+
libraries;
|
|
178
|
+
uploadsDir;
|
|
179
|
+
onModels;
|
|
180
|
+
// The files read, and their stamps then
|
|
181
|
+
stamps = new Map();
|
|
182
|
+
text = undefined;
|
|
183
|
+
sent = '';
|
|
184
|
+
timer;
|
|
185
|
+
running = false;
|
|
186
|
+
// Asked again while checking
|
|
187
|
+
again = false;
|
|
188
|
+
closed = false;
|
|
189
|
+
constructor(watched, libraries, uploadsDir, onModels) {
|
|
190
|
+
this.watched = watched;
|
|
191
|
+
this.libraries = libraries;
|
|
192
|
+
this.uploadsDir = uploadsDir;
|
|
193
|
+
this.onModels = onModels;
|
|
194
|
+
this.timer = setInterval(() => void this.check(), POLL_MS);
|
|
195
|
+
}
|
|
196
|
+
close() {
|
|
197
|
+
this.closed = true;
|
|
198
|
+
clearInterval(this.timer);
|
|
199
|
+
}
|
|
200
|
+
async check() {
|
|
201
|
+
if (this.running) {
|
|
202
|
+
this.again = true;
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
this.running = true;
|
|
206
|
+
try {
|
|
207
|
+
do {
|
|
208
|
+
this.again = false;
|
|
209
|
+
let changed = this.watched.text !== this.text;
|
|
210
|
+
for (const [file, known] of this.stamps) {
|
|
211
|
+
changed ||= (await stamp(file)) !== known;
|
|
212
|
+
}
|
|
213
|
+
if (changed && !this.closed) {
|
|
214
|
+
await this.read();
|
|
215
|
+
}
|
|
216
|
+
} while (this.again && !this.closed);
|
|
217
|
+
}
|
|
218
|
+
finally {
|
|
219
|
+
this.running = false;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
async read() {
|
|
223
|
+
this.text = this.watched.text;
|
|
224
|
+
const stamps = new Map();
|
|
225
|
+
const libraries = [];
|
|
226
|
+
// The first of each ID
|
|
227
|
+
const trees = new Map();
|
|
228
|
+
const warnings = [];
|
|
229
|
+
const load = async (file) => {
|
|
230
|
+
stamps.set(file, await stamp(file));
|
|
231
|
+
return readModelFile(file);
|
|
232
|
+
};
|
|
233
|
+
const nav2 = nav2Models();
|
|
234
|
+
if (nav2) {
|
|
235
|
+
try {
|
|
236
|
+
libraries.push({ name: 'Nav2', file: nav2, models: (await load(nav2)).models });
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
// Not readable: Nav2's nodes are known by name anyway
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (this.text !== null) {
|
|
243
|
+
// The includes, and theirs
|
|
244
|
+
const queue = [];
|
|
245
|
+
try {
|
|
246
|
+
queue.push(...includes(parseXmlSource(this.text), this.watched.file, warnings));
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
// Not XML, as the page will say
|
|
250
|
+
}
|
|
251
|
+
const seen = new Set([this.watched.file]);
|
|
252
|
+
while (queue.length > 0 && seen.size <= MAX_INCLUDES) {
|
|
253
|
+
const file = queue.shift();
|
|
254
|
+
if (seen.has(file)) {
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
seen.add(file);
|
|
258
|
+
try {
|
|
259
|
+
const included = await load(file);
|
|
260
|
+
for (const id of included.trees) {
|
|
261
|
+
if (!trees.has(id)) {
|
|
262
|
+
trees.set(id, file);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (included.models.length > 0) {
|
|
266
|
+
libraries.push({ name: path.basename(file), file, models: included.models });
|
|
267
|
+
}
|
|
268
|
+
queue.push(...includes(included.root, file, warnings));
|
|
269
|
+
}
|
|
270
|
+
catch (e) {
|
|
271
|
+
warnings.push(`Not included: ${errorMessage(e)}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
for (const source of this.libraries) {
|
|
276
|
+
try {
|
|
277
|
+
const file = resolveTreeFile(source, this.uploadsDir);
|
|
278
|
+
const library = await load(file);
|
|
279
|
+
if (library.models.length === 0) {
|
|
280
|
+
warnings.push(`No node models (TreeNodesModel) in the library ${source}`);
|
|
281
|
+
}
|
|
282
|
+
libraries.push({ name: source.startsWith('uploads/') ? source : path.basename(file), file, models: library.models });
|
|
283
|
+
}
|
|
284
|
+
catch (e) {
|
|
285
|
+
warnings.push(`Library ${source}: ${errorMessage(e)}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (this.closed) {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
this.stamps = stamps;
|
|
292
|
+
const models = { libraries, trees: [...trees].map(([id, file]) => ({ id, file })), warnings };
|
|
293
|
+
const text = JSON.stringify(models);
|
|
294
|
+
if (text !== this.sent) {
|
|
295
|
+
this.sent = text;
|
|
296
|
+
this.onModels(models);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
// Writes the file anew, at once: a temporary file renamed over it, keeping
|
|
301
|
+
// its mode; in place if that cannot be (a folder the server cannot write in,
|
|
302
|
+
// a file of another user's)
|
|
303
|
+
async function writeFile(file, text) {
|
|
304
|
+
const stat = await fs.stat(file);
|
|
305
|
+
if (stat.uid === process.getuid?.()) {
|
|
306
|
+
const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${randomBytes(4).toString('hex')}.tmp`);
|
|
307
|
+
try {
|
|
308
|
+
await fs.writeFile(temporary, text, { mode: stat.mode & 0o7777, flag: 'wx' });
|
|
309
|
+
await fs.rename(temporary, file);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
await fs.rm(temporary, { force: true });
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
await fs.writeFile(file, text);
|
|
317
|
+
}
|
|
318
|
+
export class BehaviorTreeFiles {
|
|
319
|
+
uploadsDir;
|
|
320
|
+
logger;
|
|
321
|
+
// By file
|
|
322
|
+
watched = new Map();
|
|
323
|
+
constructor(uploadsDir, logger) {
|
|
324
|
+
this.uploadsDir = uploadsDir;
|
|
325
|
+
this.logger = logger;
|
|
326
|
+
}
|
|
327
|
+
// The file the page names, and the models it can use: returns what ends it
|
|
328
|
+
watch(source, libraries, watcher) {
|
|
329
|
+
let file;
|
|
330
|
+
try {
|
|
331
|
+
file = resolveTreeFile(source, this.uploadsDir);
|
|
332
|
+
}
|
|
333
|
+
catch (e) {
|
|
334
|
+
watcher.onFile({ file: '', text: null, version: null, readOnly: null, error: errorMessage(e) });
|
|
335
|
+
watcher.onModels({ libraries: [], trees: [], warnings: [] });
|
|
336
|
+
return () => { };
|
|
337
|
+
}
|
|
338
|
+
let watched = this.watched.get(file);
|
|
339
|
+
if (!watched) {
|
|
340
|
+
watched = new WatchedFile(file, source.trim().startsWith('uploads/') ? 'An upload: save it on the robot' : null);
|
|
341
|
+
this.watched.set(file, watched);
|
|
342
|
+
}
|
|
343
|
+
const shared = watched;
|
|
344
|
+
const models = new ModelsWatch(shared, libraries, this.uploadsDir, watcher.onModels);
|
|
345
|
+
// Its includes may have changed with it
|
|
346
|
+
const own = {
|
|
347
|
+
onFile: (state) => {
|
|
348
|
+
watcher.onFile(state);
|
|
349
|
+
void models.check();
|
|
350
|
+
},
|
|
351
|
+
onModels: watcher.onModels,
|
|
352
|
+
};
|
|
353
|
+
shared.add(own);
|
|
354
|
+
void models.check();
|
|
355
|
+
return () => {
|
|
356
|
+
models.close();
|
|
357
|
+
if (!shared.remove(own)) {
|
|
358
|
+
this.watched.delete(file);
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
// Saves the file: over a behavior tree, unless it changed since base (null:
|
|
363
|
+
// whatever it is), or a new one (create)
|
|
364
|
+
async save(source, text, base, create) {
|
|
365
|
+
if (source.trim().startsWith('uploads/')) {
|
|
366
|
+
throw new Error('Uploads are not saved: save it on the robot');
|
|
367
|
+
}
|
|
368
|
+
const file = resolveTreeFile(source, this.uploadsDir);
|
|
369
|
+
if (Buffer.byteLength(text) > MAX_FILE_BYTES) {
|
|
370
|
+
throw new Error(`Larger than ${MAX_FILE_BYTES / 1024 / 1024} MB`);
|
|
371
|
+
}
|
|
372
|
+
const root = parseXmlSource(text);
|
|
373
|
+
if (root.name !== 'root') {
|
|
374
|
+
throw new Error(`Not a behavior tree: its root element is <${root.name}>, not <root>`);
|
|
375
|
+
}
|
|
376
|
+
if (create) {
|
|
377
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
378
|
+
await fs.writeFile(file, text, { flag: 'wx' }).catch((e) => {
|
|
379
|
+
throw new Error(e.code === 'EEXIST' ? `${file} exists already` : errorMessage(e));
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
const real = await fs.realpath(file).catch(() => {
|
|
384
|
+
throw new Error(`No file ${file}`);
|
|
385
|
+
});
|
|
386
|
+
const current = await readText(real);
|
|
387
|
+
if (!isBehaviorTreeXml(current)) {
|
|
388
|
+
throw new Error(`${file} is not a behavior tree (its root element is not <root>): not overwritten`);
|
|
389
|
+
}
|
|
390
|
+
if (base !== null && contentVersion(current) !== base) {
|
|
391
|
+
return { version: contentVersion(current), conflict: true };
|
|
392
|
+
}
|
|
393
|
+
await writeFile(real, text);
|
|
394
|
+
}
|
|
395
|
+
this.logger.info(`Behavior tree saved: ${file}`);
|
|
396
|
+
await this.watched.get(file)?.refresh();
|
|
397
|
+
return { version: contentVersion(text), conflict: false };
|
|
398
|
+
}
|
|
399
|
+
stopAll() {
|
|
400
|
+
for (const watched of this.watched.values()) {
|
|
401
|
+
watched.stop();
|
|
402
|
+
}
|
|
403
|
+
this.watched.clear();
|
|
404
|
+
}
|
|
405
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface LogTransition {
|
|
2
|
+
uid: number | null;
|
|
3
|
+
name: string;
|
|
4
|
+
from: string;
|
|
5
|
+
to: string;
|
|
6
|
+
time: number;
|
|
7
|
+
}
|
|
8
|
+
export declare function logTransitions(raw: unknown): LogTransition[];
|
|
9
|
+
export declare function decodeLog(data: Uint8Array, uids: boolean): LogTransition[];
|
|
10
|
+
export declare function logLayout(messages: Iterable<Uint8Array>): boolean | null;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// The messages of a behavior tree's log (nav2_msgs/msg/BehaviorTreeLog): as
|
|
2
|
+
// rclnodejs gives them, live, and as rosbags keep them, CDR serialized. Their
|
|
3
|
+
// transitions in either case.
|
|
4
|
+
//
|
|
5
|
+
// builtin_interfaces/Time timestamp
|
|
6
|
+
// BehaviorTreeStatusChange[] event_log
|
|
7
|
+
// builtin_interfaces/Time timestamp
|
|
8
|
+
// string node_name
|
|
9
|
+
// uint16 uid (since Jazzy)
|
|
10
|
+
// string previous_status
|
|
11
|
+
// string current_status
|
|
12
|
+
const STATUSES = new Set(['IDLE', 'RUNNING', 'SUCCESS', 'FAILURE', 'SKIPPED']);
|
|
13
|
+
const toMs = (time) => (time?.sec ? time.sec * 1000 + (time.nanosec ?? 0) / 1e6 : 0);
|
|
14
|
+
// A message's transitions, as rclnodejs gives it; throws if it is not a log
|
|
15
|
+
export function logTransitions(raw) {
|
|
16
|
+
const log = raw;
|
|
17
|
+
if (!Array.isArray(log?.event_log)) {
|
|
18
|
+
throw new Error('not a log of transitions (nav2_msgs/msg/BehaviorTreeLog)');
|
|
19
|
+
}
|
|
20
|
+
const sent = toMs(log.timestamp) || Date.now();
|
|
21
|
+
return log.event_log.map((event) => ({
|
|
22
|
+
uid: typeof event.uid === 'number' && event.uid > 0 ? event.uid : null,
|
|
23
|
+
name: String(event.node_name ?? ''),
|
|
24
|
+
from: String(event.previous_status ?? '').toUpperCase(),
|
|
25
|
+
to: String(event.current_status ?? '').toUpperCase(),
|
|
26
|
+
time: toMs(event.timestamp) || sent,
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
29
|
+
const utf8 = new TextDecoder();
|
|
30
|
+
// Reads CDR (XCDR 1 or 2, of final types: the same here) after its 4 byte
|
|
31
|
+
// encapsulation header, which says its endianness; aligned from after it
|
|
32
|
+
class CdrReader {
|
|
33
|
+
offset = 4;
|
|
34
|
+
view;
|
|
35
|
+
little;
|
|
36
|
+
constructor(data) {
|
|
37
|
+
if (data.length < 4) {
|
|
38
|
+
throw new Error('Truncated message');
|
|
39
|
+
}
|
|
40
|
+
this.view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
41
|
+
this.little = (data[1] & 1) === 1;
|
|
42
|
+
}
|
|
43
|
+
take(size) {
|
|
44
|
+
this.offset += (size - ((this.offset - 4) % size)) % size;
|
|
45
|
+
const at = this.offset;
|
|
46
|
+
if (at + size > this.view.byteLength) {
|
|
47
|
+
throw new Error('Truncated message');
|
|
48
|
+
}
|
|
49
|
+
this.offset += size;
|
|
50
|
+
return at;
|
|
51
|
+
}
|
|
52
|
+
int32() {
|
|
53
|
+
return this.view.getInt32(this.take(4), this.little);
|
|
54
|
+
}
|
|
55
|
+
uint32() {
|
|
56
|
+
return this.view.getUint32(this.take(4), this.little);
|
|
57
|
+
}
|
|
58
|
+
uint16() {
|
|
59
|
+
return this.view.getUint16(this.take(2), this.little);
|
|
60
|
+
}
|
|
61
|
+
// Its length counts the terminating null
|
|
62
|
+
string() {
|
|
63
|
+
const length = this.uint32();
|
|
64
|
+
if (length === 0) {
|
|
65
|
+
return '';
|
|
66
|
+
}
|
|
67
|
+
if (this.offset + length > this.view.byteLength) {
|
|
68
|
+
throw new Error('Truncated message');
|
|
69
|
+
}
|
|
70
|
+
const text = utf8.decode(new Uint8Array(this.view.buffer, this.view.byteOffset + this.offset, length - 1));
|
|
71
|
+
this.offset += length;
|
|
72
|
+
return text;
|
|
73
|
+
}
|
|
74
|
+
time() {
|
|
75
|
+
const sec = this.int32();
|
|
76
|
+
return sec * 1000 + this.uint32() / 1e6;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// A serialized log's transitions, with UIDs (Jazzy's) or without; throws if
|
|
80
|
+
// it is not a log of that layout
|
|
81
|
+
export function decodeLog(data, uids) {
|
|
82
|
+
const reader = new CdrReader(data);
|
|
83
|
+
const sent = reader.time();
|
|
84
|
+
const count = reader.uint32();
|
|
85
|
+
const transitions = [];
|
|
86
|
+
for (let i = 0; i < count; i++) {
|
|
87
|
+
const time = reader.time();
|
|
88
|
+
const name = reader.string();
|
|
89
|
+
const uid = uids ? reader.uint16() : 0;
|
|
90
|
+
const from = reader.string();
|
|
91
|
+
const to = reader.string();
|
|
92
|
+
if (!STATUSES.has(from) || !STATUSES.has(to)) {
|
|
93
|
+
throw new Error(`not a log of transitions: statuses “${from}”, “${to}”`);
|
|
94
|
+
}
|
|
95
|
+
transitions.push({ uid: uid > 0 ? uid : null, name, from, to, time: time || sent });
|
|
96
|
+
}
|
|
97
|
+
return transitions;
|
|
98
|
+
}
|
|
99
|
+
// How many messages tell the layout
|
|
100
|
+
const LAYOUT_SAMPLES = 200;
|
|
101
|
+
// Whether the serialized logs have UIDs, from their first transitions; null
|
|
102
|
+
// if neither layout reads them. Either may read the other's, the UIDs in the
|
|
103
|
+
// strings' padding: with UIDs, those of a log without them read 0, which
|
|
104
|
+
// BT.CPP never numbers.
|
|
105
|
+
export function logLayout(messages) {
|
|
106
|
+
const samples = [];
|
|
107
|
+
for (const data of messages) {
|
|
108
|
+
if (samples.length === LAYOUT_SAMPLES) {
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
// Not empty: an empty log reads either way
|
|
112
|
+
if (data.length > 16) {
|
|
113
|
+
samples.push(data);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const read = (uids) => {
|
|
117
|
+
const transitions = [];
|
|
118
|
+
try {
|
|
119
|
+
for (const data of samples) {
|
|
120
|
+
transitions.push(...decodeLog(data, uids));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
return transitions;
|
|
127
|
+
};
|
|
128
|
+
if (read(true)?.some((transition) => transition.uid !== null)) {
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
return read(false)?.length ? false : null;
|
|
132
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { type BehaviorTreePlaybackState } from '../shared/protocol.ts';
|
|
2
|
+
import { type LogTransition } from './behavior_tree_log.ts';
|
|
3
|
+
import type { BehaviorTreeWatcher } from './behavior_trees.ts';
|
|
4
|
+
import { type Logger } from './util.ts';
|
|
5
|
+
export type PlaybackState = Omit<BehaviorTreePlaybackState, 'type' | 'id'>;
|
|
6
|
+
export type PlaybackWatcher = Omit<BehaviorTreeWatcher, 'onRecording'> & {
|
|
7
|
+
onPlayback: (state: PlaybackState) => void;
|
|
8
|
+
};
|
|
9
|
+
export interface PlaybackOptions {
|
|
10
|
+
bag: string;
|
|
11
|
+
topic: string;
|
|
12
|
+
tree: string;
|
|
13
|
+
uploadsDir: string;
|
|
14
|
+
logger: Logger;
|
|
15
|
+
}
|
|
16
|
+
interface Timed {
|
|
17
|
+
time: number;
|
|
18
|
+
transitions: LogTransition[];
|
|
19
|
+
}
|
|
20
|
+
export declare function readLog(bag: string, topic: string): Promise<{
|
|
21
|
+
topic: string;
|
|
22
|
+
messages: Timed[];
|
|
23
|
+
}>;
|
|
24
|
+
export declare class BehaviorTreePlayback {
|
|
25
|
+
private readonly watcher;
|
|
26
|
+
private nodes;
|
|
27
|
+
private messages;
|
|
28
|
+
private state;
|
|
29
|
+
private next;
|
|
30
|
+
private topic;
|
|
31
|
+
private start;
|
|
32
|
+
private end;
|
|
33
|
+
private time;
|
|
34
|
+
private playing;
|
|
35
|
+
private speed;
|
|
36
|
+
private error;
|
|
37
|
+
private timer;
|
|
38
|
+
private lastTick;
|
|
39
|
+
private lastFlush;
|
|
40
|
+
private lastState;
|
|
41
|
+
private closed;
|
|
42
|
+
constructor(options: PlaybackOptions, watcher: PlaybackWatcher);
|
|
43
|
+
control({ playing, speed, time }: {
|
|
44
|
+
playing?: boolean;
|
|
45
|
+
speed?: number;
|
|
46
|
+
time?: number;
|
|
47
|
+
}): void;
|
|
48
|
+
close(): void;
|
|
49
|
+
private open;
|
|
50
|
+
private tick;
|
|
51
|
+
private advance;
|
|
52
|
+
private seek;
|
|
53
|
+
private flush;
|
|
54
|
+
private sendState;
|
|
55
|
+
private stopTimer;
|
|
56
|
+
}
|
|
57
|
+
export {};
|