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,418 @@
|
|
|
1
|
+
// Rosbags (rosbag2), for the behavior tree inspectors' record and playback:
|
|
2
|
+
// the messages of some of their topics, read from either storage (MCAP,
|
|
3
|
+
// Jazzy's default, or SQLite, Humble's), serialized as recorded; and topics
|
|
4
|
+
// recorded into a new one by ros2 bag record, the robot's own tool.
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { promises as fs } from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import zlib from 'node:zlib';
|
|
10
|
+
import { errorMessage, stripAnsi } from './util.js';
|
|
11
|
+
const BAG_FILE = /\.(mcap|db3)$/i;
|
|
12
|
+
function absolute(input, home) {
|
|
13
|
+
const trimmed = input.trim();
|
|
14
|
+
const expanded = trimmed === '~' || trimmed.startsWith('~/') ? path.join(home, trimmed.slice(1)) : trimmed;
|
|
15
|
+
if (!path.isAbsolute(expanded)) {
|
|
16
|
+
throw new Error(`Not a path: "${input}" (/…, ~/…)`);
|
|
17
|
+
}
|
|
18
|
+
return path.resolve(expanded);
|
|
19
|
+
}
|
|
20
|
+
const nsToMs = (ns) => Number(ns / 1000n) / 1000;
|
|
21
|
+
// The files of a bag: its folder's (split bags' in order), or one of them
|
|
22
|
+
export async function bagFiles(input, home = os.homedir()) {
|
|
23
|
+
const location = absolute(input, home);
|
|
24
|
+
const stat = await fs.stat(location).catch(() => null);
|
|
25
|
+
if (!stat) {
|
|
26
|
+
throw new Error(`No rosbag at ${location}`);
|
|
27
|
+
}
|
|
28
|
+
if (stat.isDirectory()) {
|
|
29
|
+
const names = await fs.readdir(location);
|
|
30
|
+
const files = names.filter((name) => BAG_FILE.test(name)).sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
|
31
|
+
if (files.length === 0) {
|
|
32
|
+
const compressed = names.some((name) => /\.(mcap|db3)\.\w+$/i.test(name));
|
|
33
|
+
throw new Error(`No rosbag in ${location}: no .mcap nor .db3 file${compressed ? ' (compressed files are not read)' : ''}`);
|
|
34
|
+
}
|
|
35
|
+
return files.map((name) => path.join(location, name));
|
|
36
|
+
}
|
|
37
|
+
if (!BAG_FILE.test(location)) {
|
|
38
|
+
throw new Error(`Not a rosbag: ${location} (a bag's folder, an .mcap or .db3 file)`);
|
|
39
|
+
}
|
|
40
|
+
return [location];
|
|
41
|
+
}
|
|
42
|
+
// The messages of the topics accepted, by topic, in the order recorded
|
|
43
|
+
export async function readBag(files, accept) {
|
|
44
|
+
const topics = new Map();
|
|
45
|
+
for (const file of files) {
|
|
46
|
+
const read = /\.mcap$/i.test(file) ? await readMcap(file, accept) : await readSqlite(file, accept);
|
|
47
|
+
for (const [name, found] of read) {
|
|
48
|
+
const known = topics.get(name);
|
|
49
|
+
if (known) {
|
|
50
|
+
known.messages = known.messages.concat(found.messages);
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
topics.set(name, found);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
for (const { messages } of topics.values()) {
|
|
58
|
+
messages.sort((a, b) => a.time - b.time);
|
|
59
|
+
}
|
|
60
|
+
return topics;
|
|
61
|
+
}
|
|
62
|
+
// ── MCAP ─────────────────────────────────────────────────────────────────────
|
|
63
|
+
// Records: opcode (u8), length (u64), content; little endian, strings and
|
|
64
|
+
// maps prefixed with their u32 byte length (https://mcap.dev/spec)
|
|
65
|
+
const MCAP_MAGIC = [0x89, 0x4d, 0x43, 0x41, 0x50, 0x30, 0x0d, 0x0a];
|
|
66
|
+
const FOOTER = 0x02;
|
|
67
|
+
const SCHEMA = 0x03;
|
|
68
|
+
const CHANNEL = 0x04;
|
|
69
|
+
const MESSAGE = 0x05;
|
|
70
|
+
const CHUNK = 0x06;
|
|
71
|
+
const CHUNK_INDEX = 0x08;
|
|
72
|
+
const DATA_END = 0x0f;
|
|
73
|
+
// Opcode, length, summary start, summary offset start, summary CRC
|
|
74
|
+
const FOOTER_BYTES = 1 + 8 + 8 + 8 + 4;
|
|
75
|
+
// Reads a buffer; past its end throws (RangeError)
|
|
76
|
+
class Cursor {
|
|
77
|
+
offset = 0;
|
|
78
|
+
data;
|
|
79
|
+
constructor(data) {
|
|
80
|
+
this.data = data;
|
|
81
|
+
}
|
|
82
|
+
u8() {
|
|
83
|
+
return this.data.readUInt8(this.offset++);
|
|
84
|
+
}
|
|
85
|
+
u16() {
|
|
86
|
+
const value = this.data.readUInt16LE(this.offset);
|
|
87
|
+
this.offset += 2;
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
90
|
+
u32() {
|
|
91
|
+
const value = this.data.readUInt32LE(this.offset);
|
|
92
|
+
this.offset += 4;
|
|
93
|
+
return value;
|
|
94
|
+
}
|
|
95
|
+
u64() {
|
|
96
|
+
const value = this.data.readBigUInt64LE(this.offset);
|
|
97
|
+
this.offset += 8;
|
|
98
|
+
return value;
|
|
99
|
+
}
|
|
100
|
+
// Offsets and lengths: well within a number
|
|
101
|
+
size() {
|
|
102
|
+
return Number(this.u64());
|
|
103
|
+
}
|
|
104
|
+
bytes(length) {
|
|
105
|
+
if (this.offset + length > this.data.length) {
|
|
106
|
+
throw new RangeError('Truncated record');
|
|
107
|
+
}
|
|
108
|
+
const bytes = this.data.subarray(this.offset, this.offset + length);
|
|
109
|
+
this.offset += length;
|
|
110
|
+
return bytes;
|
|
111
|
+
}
|
|
112
|
+
string() {
|
|
113
|
+
return this.bytes(this.u32()).toString('utf8');
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function decompress(compression, data) {
|
|
117
|
+
if (!compression) {
|
|
118
|
+
return data;
|
|
119
|
+
}
|
|
120
|
+
if (compression === 'zstd') {
|
|
121
|
+
if (typeof zlib.zstdDecompressSync !== 'function') {
|
|
122
|
+
throw new Error('Reading zstd compressed MCAP needs Node.js 22.15 or newer');
|
|
123
|
+
}
|
|
124
|
+
return zlib.zstdDecompressSync(data);
|
|
125
|
+
}
|
|
126
|
+
throw new Error(`MCAP compressed with ${compression} is not read: record with zstd, or without compression`);
|
|
127
|
+
}
|
|
128
|
+
// What the records of a file say: its schemas and channels, and the messages
|
|
129
|
+
// of those accepted
|
|
130
|
+
class McapRecords {
|
|
131
|
+
topics = new Map();
|
|
132
|
+
channels = new Map();
|
|
133
|
+
schemas = new Map();
|
|
134
|
+
accept;
|
|
135
|
+
constructor(accept) {
|
|
136
|
+
this.accept = accept;
|
|
137
|
+
}
|
|
138
|
+
// The channels whose messages are read
|
|
139
|
+
wanted() {
|
|
140
|
+
return new Set([...this.channels].flatMap(([id, topic]) => (topic ? [id] : [])));
|
|
141
|
+
}
|
|
142
|
+
record(opcode, content) {
|
|
143
|
+
const cursor = new Cursor(content);
|
|
144
|
+
switch (opcode) {
|
|
145
|
+
case SCHEMA:
|
|
146
|
+
this.schemas.set(cursor.u16(), cursor.string());
|
|
147
|
+
break;
|
|
148
|
+
case CHANNEL: {
|
|
149
|
+
const id = cursor.u16();
|
|
150
|
+
const schema = cursor.u16();
|
|
151
|
+
const topic = { name: cursor.string(), type: this.schemas.get(schema) ?? '' };
|
|
152
|
+
if (!this.channels.has(id)) {
|
|
153
|
+
this.channels.set(id, this.accept(topic) ? topic : null);
|
|
154
|
+
}
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
case MESSAGE: {
|
|
158
|
+
const topic = this.channels.get(cursor.u16());
|
|
159
|
+
if (!topic) {
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
cursor.u32();
|
|
163
|
+
const time = nsToMs(cursor.u64());
|
|
164
|
+
cursor.u64();
|
|
165
|
+
// A copy: not to keep the whole chunk
|
|
166
|
+
const data = Uint8Array.prototype.slice.call(content, cursor.offset);
|
|
167
|
+
const known = this.topics.get(topic.name);
|
|
168
|
+
if (known) {
|
|
169
|
+
known.messages.push({ time, data });
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
this.topics.set(topic.name, { topic, messages: [{ time, data }] });
|
|
173
|
+
}
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
case CHUNK: {
|
|
177
|
+
cursor.offset += 8 + 8 + 8 + 4;
|
|
178
|
+
const compression = cursor.string();
|
|
179
|
+
this.records(decompress(compression, cursor.bytes(cursor.size())));
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// A sequence of records, e.g. a chunk's
|
|
185
|
+
records(data) {
|
|
186
|
+
const cursor = new Cursor(data);
|
|
187
|
+
while (cursor.offset + 9 <= data.length) {
|
|
188
|
+
const opcode = cursor.u8();
|
|
189
|
+
this.record(opcode, cursor.bytes(cursor.size()));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
async function readMcap(file, accept) {
|
|
194
|
+
const handle = await fs.open(file, 'r').catch((e) => {
|
|
195
|
+
throw new Error(`Cannot read ${file}: ${e.code ?? errorMessage(e)}`);
|
|
196
|
+
});
|
|
197
|
+
try {
|
|
198
|
+
const { size } = await handle.stat();
|
|
199
|
+
const read = async (position, length) => {
|
|
200
|
+
const buffer = Buffer.alloc(Math.max(0, length));
|
|
201
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, position);
|
|
202
|
+
return buffer.subarray(0, bytesRead);
|
|
203
|
+
};
|
|
204
|
+
const head = await read(0, 8);
|
|
205
|
+
if (!MCAP_MAGIC.every((byte, i) => head[i] === byte)) {
|
|
206
|
+
throw new Error(`${file} is not an MCAP file`);
|
|
207
|
+
}
|
|
208
|
+
const records = new McapRecords(accept);
|
|
209
|
+
if (!(await readIndexed(records, read, size))) {
|
|
210
|
+
await readSequentially(records, read, size);
|
|
211
|
+
}
|
|
212
|
+
return records.topics;
|
|
213
|
+
}
|
|
214
|
+
finally {
|
|
215
|
+
await handle.close();
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// From the summary at the end of the file: the channels, and where the
|
|
219
|
+
// chunks with messages of those accepted are, which alone are read. false if
|
|
220
|
+
// the file has no summary (unfinished, or written without chunks).
|
|
221
|
+
async function readIndexed(records, read, size) {
|
|
222
|
+
const footerAt = size - 8 - FOOTER_BYTES;
|
|
223
|
+
if (footerAt < 8) {
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
const footer = new Cursor(await read(footerAt, FOOTER_BYTES));
|
|
227
|
+
if (footer.u8() !== FOOTER) {
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
footer.size();
|
|
231
|
+
const summaryAt = footer.size();
|
|
232
|
+
if (summaryAt === 0 || summaryAt >= footerAt) {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
const chunks = [];
|
|
236
|
+
const summary = new Cursor(await read(summaryAt, footerAt - summaryAt));
|
|
237
|
+
while (summary.offset + 9 <= summary.data.length) {
|
|
238
|
+
const opcode = summary.u8();
|
|
239
|
+
const content = summary.bytes(summary.size());
|
|
240
|
+
if (opcode !== CHUNK_INDEX) {
|
|
241
|
+
records.record(opcode, content);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const index = new Cursor(content);
|
|
245
|
+
const start = index.u64();
|
|
246
|
+
index.u64();
|
|
247
|
+
const at = index.size();
|
|
248
|
+
const length = index.size();
|
|
249
|
+
const map = new Cursor(index.bytes(index.u32()));
|
|
250
|
+
const channels = [];
|
|
251
|
+
while (map.offset < map.data.length) {
|
|
252
|
+
channels.push(map.u16());
|
|
253
|
+
map.u64();
|
|
254
|
+
}
|
|
255
|
+
chunks.push({ at, length, start, channels });
|
|
256
|
+
}
|
|
257
|
+
if (chunks.length === 0) {
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
const wanted = records.wanted();
|
|
261
|
+
chunks.sort((a, b) => (a.start < b.start ? -1 : a.start > b.start ? 1 : 0));
|
|
262
|
+
for (const chunk of chunks.filter((c) => c.channels.some((id) => wanted.has(id)))) {
|
|
263
|
+
const record = new Cursor(await read(chunk.at, chunk.length));
|
|
264
|
+
const opcode = record.u8();
|
|
265
|
+
records.record(opcode, record.bytes(record.size()));
|
|
266
|
+
}
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
// Record after record from the start, as far as the file goes
|
|
270
|
+
async function readSequentially(records, read, size) {
|
|
271
|
+
let position = 8;
|
|
272
|
+
while (position + 9 <= size) {
|
|
273
|
+
const header = new Cursor(await read(position, 9));
|
|
274
|
+
const opcode = header.u8();
|
|
275
|
+
const length = header.size();
|
|
276
|
+
if (opcode === FOOTER || opcode === DATA_END) {
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
if ([SCHEMA, CHANNEL, MESSAGE, CHUNK].includes(opcode)) {
|
|
280
|
+
const content = await read(position + 9, length);
|
|
281
|
+
if (content.length < length) {
|
|
282
|
+
// Cut short: a recording that did not end
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
records.record(opcode, content);
|
|
286
|
+
}
|
|
287
|
+
position += 9 + length;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
// ── SQLite ───────────────────────────────────────────────────────────────────
|
|
291
|
+
async function readSqlite(file, accept) {
|
|
292
|
+
let sqlite;
|
|
293
|
+
try {
|
|
294
|
+
sqlite = await import('node:sqlite');
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
throw new Error('Reading SQLite rosbags (.db3) needs Node.js 22.13 or newer');
|
|
298
|
+
}
|
|
299
|
+
let db;
|
|
300
|
+
try {
|
|
301
|
+
db = new sqlite.DatabaseSync(file, { readOnly: true });
|
|
302
|
+
}
|
|
303
|
+
catch (e) {
|
|
304
|
+
throw new Error(`Cannot read ${file}: ${errorMessage(e)}`);
|
|
305
|
+
}
|
|
306
|
+
try {
|
|
307
|
+
const topics = new Map();
|
|
308
|
+
const rows = db.prepare('SELECT id, name, type FROM topics').all();
|
|
309
|
+
const select = db.prepare('SELECT timestamp, data FROM messages WHERE topic_id = ? ORDER BY timestamp');
|
|
310
|
+
// Nanoseconds since the epoch: beyond a number's integers
|
|
311
|
+
select.setReadBigInts(true);
|
|
312
|
+
for (const row of rows) {
|
|
313
|
+
const topic = { name: row.name, type: row.type };
|
|
314
|
+
if (accept(topic)) {
|
|
315
|
+
const messages = select.all(row.id).map((m) => ({
|
|
316
|
+
time: nsToMs(m.timestamp),
|
|
317
|
+
data: m.data,
|
|
318
|
+
}));
|
|
319
|
+
topics.set(topic.name, { topic, messages });
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return topics;
|
|
323
|
+
}
|
|
324
|
+
catch (e) {
|
|
325
|
+
throw new Error(`Cannot read ${file}: ${errorMessage(e)}`);
|
|
326
|
+
}
|
|
327
|
+
finally {
|
|
328
|
+
db.close();
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
// ── Recording ────────────────────────────────────────────────────────────────
|
|
332
|
+
// Waited for ros2 bag record to say it records; quiet, it is taken as doing so
|
|
333
|
+
const START_TIMEOUT_MS = 8000;
|
|
334
|
+
// Then it is killed: the bag may lack its metadata
|
|
335
|
+
const STOP_TIMEOUT_MS = 10_000;
|
|
336
|
+
const READY = /Subscribed to topic|Listening for topics|Recording\.\.\./;
|
|
337
|
+
const OUTPUT_LIMIT = 4000;
|
|
338
|
+
// The folder a recording makes: a new one, absolute or ~/…, its parent made
|
|
339
|
+
export async function newBagFolder(input, home = os.homedir()) {
|
|
340
|
+
const folder = absolute(input.trim().replace(/(.)\/+$/, '$1'), home);
|
|
341
|
+
if (await fs.stat(folder).catch(() => null)) {
|
|
342
|
+
throw new Error(`${folder} exists already: a recording makes a new folder`);
|
|
343
|
+
}
|
|
344
|
+
await fs.mkdir(path.dirname(folder), { recursive: true });
|
|
345
|
+
return folder;
|
|
346
|
+
}
|
|
347
|
+
// ros2 bag record of topics into a new folder, until stopped
|
|
348
|
+
export class BagRecorder {
|
|
349
|
+
folder;
|
|
350
|
+
// Called if it stops by itself, with why
|
|
351
|
+
onExit = null;
|
|
352
|
+
child;
|
|
353
|
+
output = '';
|
|
354
|
+
stopping = false;
|
|
355
|
+
constructor(child, folder) {
|
|
356
|
+
this.child = child;
|
|
357
|
+
this.folder = folder;
|
|
358
|
+
}
|
|
359
|
+
// Resolves once it records, rejects with why it could not
|
|
360
|
+
static start(folder, topics) {
|
|
361
|
+
return new Promise((resolve, reject) => {
|
|
362
|
+
const child = spawn('ros2', ['bag', 'record', '-o', folder, ...topics], {
|
|
363
|
+
cwd: os.homedir(),
|
|
364
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
365
|
+
});
|
|
366
|
+
const recorder = new BagRecorder(child, folder);
|
|
367
|
+
let settled = false;
|
|
368
|
+
const ready = () => {
|
|
369
|
+
if (!settled) {
|
|
370
|
+
settled = true;
|
|
371
|
+
clearTimeout(timer);
|
|
372
|
+
resolve(recorder);
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
const timer = setTimeout(ready, START_TIMEOUT_MS);
|
|
376
|
+
const onOutput = (chunk) => {
|
|
377
|
+
recorder.output = (recorder.output + stripAnsi(chunk.toString())).slice(-OUTPUT_LIMIT);
|
|
378
|
+
if (READY.test(recorder.output)) {
|
|
379
|
+
ready();
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
child.stdout?.on('data', onOutput);
|
|
383
|
+
child.stderr?.on('data', onOutput);
|
|
384
|
+
child.on('error', (e) => {
|
|
385
|
+
recorder.output = e.code === 'ENOENT' ? 'ros2 not found: is ROS sourced?' : errorMessage(e);
|
|
386
|
+
if (!settled) {
|
|
387
|
+
settled = true;
|
|
388
|
+
clearTimeout(timer);
|
|
389
|
+
reject(new Error(recorder.output));
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
child.on('close', (code, signal) => {
|
|
393
|
+
clearTimeout(timer);
|
|
394
|
+
const lines = recorder.output.trim().split('\n').slice(-2).join(' ').trim();
|
|
395
|
+
const reason = lines || `ros2 bag record exited (${signal ?? code})`;
|
|
396
|
+
if (!settled) {
|
|
397
|
+
settled = true;
|
|
398
|
+
reject(new Error(reason));
|
|
399
|
+
}
|
|
400
|
+
else if (!recorder.stopping) {
|
|
401
|
+
recorder.onExit?.(reason);
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
// As Ctrl+C does, which completes the bag
|
|
407
|
+
async stop() {
|
|
408
|
+
this.stopping = true;
|
|
409
|
+
if (this.child.exitCode !== null || this.child.signalCode !== null) {
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
const closed = new Promise((resolve) => this.child.once('close', resolve));
|
|
413
|
+
this.child.kill('SIGINT');
|
|
414
|
+
const timer = setTimeout(() => this.child.kill('SIGKILL'), STOP_TIMEOUT_MS);
|
|
415
|
+
await closed;
|
|
416
|
+
clearTimeout(timer);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type rclnodejs from 'rclnodejs';
|
|
2
|
+
import { type RosoutUpdate } from '../shared/protocol.ts';
|
|
3
|
+
import type { StreamState } from './frame_stream.ts';
|
|
4
|
+
import { type Logger } from './util.ts';
|
|
5
|
+
export interface RosoutWatcher {
|
|
6
|
+
onStatus: (state: StreamState, message: string) => void;
|
|
7
|
+
onUpdate: (update: Omit<RosoutUpdate, 'type' | 'id'>) => void;
|
|
8
|
+
}
|
|
9
|
+
export declare class Rosout {
|
|
10
|
+
private readonly node;
|
|
11
|
+
private readonly logger;
|
|
12
|
+
private readonly logs;
|
|
13
|
+
constructor(node: rclnodejs.Node, logger: Logger);
|
|
14
|
+
watch(topic: string, watcher: RosoutWatcher): () => void;
|
|
15
|
+
private forget;
|
|
16
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// The ROS log for the logs widgets: the rcl_interfaces/msg/Log every node's
|
|
2
|
+
// logger publishes on /rosout, and the nodes of the ROS graph.
|
|
3
|
+
//
|
|
4
|
+
// A log is followed whole, like the behavior trees' (behavior_trees.ts): each
|
|
5
|
+
// topic is subscribed once, with its publishers' QoS (rcl's: reliable and
|
|
6
|
+
// transient local, so that each node's latest messages arrive first) and a
|
|
7
|
+
// deep queue. The server keeps its latest ROSOUT_HISTORY messages, and sends
|
|
8
|
+
// its pages the new ones at most ROSOUT_RATE times a second, at most
|
|
9
|
+
// MAX_BATCH at a time: a node logging faster than that has the oldest of each
|
|
10
|
+
// batch skipped, which the page is told. The log outlives its last page for
|
|
11
|
+
// LINGER_MS: a page reloaded, or a panel shown again, finds what was logged
|
|
12
|
+
// meanwhile.
|
|
13
|
+
import { ROSOUT_HISTORY, ROSOUT_RATE } from '../shared/protocol.js';
|
|
14
|
+
import { publishersQos } from './qos.js';
|
|
15
|
+
import { LogHistory, nodeNames } from './rosout_log.js';
|
|
16
|
+
import { absoluteTopic, errorMessage } from './util.js';
|
|
17
|
+
const LOG_TYPE = 'rcl_interfaces/msg/Log';
|
|
18
|
+
// How often the graph is checked for the topic's publishers, until it has one
|
|
19
|
+
const RETRY_PERIOD_MS = 1000;
|
|
20
|
+
// Messages queued while the server is busy: nodes log bursts as they start.
|
|
21
|
+
// The depth of rcl's own rosout publishers.
|
|
22
|
+
const QUEUE_DEPTH = 1000;
|
|
23
|
+
// ROSOUT_RATE times this a second, at most, to a page
|
|
24
|
+
const MAX_BATCH = 500;
|
|
25
|
+
const LINGER_MS = 10 * 60_000;
|
|
26
|
+
// How often the graph is checked for its nodes, while a page watches
|
|
27
|
+
const NODES_CHECK_MS = 2000;
|
|
28
|
+
// A log topic: its subscription, its messages, its pages
|
|
29
|
+
class RosoutLog {
|
|
30
|
+
watchers = new Set();
|
|
31
|
+
lingerTimer = null;
|
|
32
|
+
node;
|
|
33
|
+
logger;
|
|
34
|
+
topic;
|
|
35
|
+
started = Date.now();
|
|
36
|
+
history = new LogHistory(ROSOUT_HISTORY);
|
|
37
|
+
subscription = null;
|
|
38
|
+
retryTimer = null;
|
|
39
|
+
state = 'waiting';
|
|
40
|
+
message = '';
|
|
41
|
+
nodes = [];
|
|
42
|
+
// Since the last update
|
|
43
|
+
nodesChanged = false;
|
|
44
|
+
nodesTimer = null;
|
|
45
|
+
flushTimer = null;
|
|
46
|
+
lastFlush = 0;
|
|
47
|
+
constructor(node, logger, topic) {
|
|
48
|
+
this.node = node;
|
|
49
|
+
this.logger = logger;
|
|
50
|
+
this.topic = topic;
|
|
51
|
+
this.subscribe();
|
|
52
|
+
}
|
|
53
|
+
add(watcher) {
|
|
54
|
+
if (this.lingerTimer) {
|
|
55
|
+
clearTimeout(this.lingerTimer);
|
|
56
|
+
this.lingerTimer = null;
|
|
57
|
+
}
|
|
58
|
+
this.checkNodes();
|
|
59
|
+
// What the others have not had yet, which the new one gets with the rest
|
|
60
|
+
this.flush();
|
|
61
|
+
this.watchers.add(watcher);
|
|
62
|
+
watcher.onStatus(this.state, this.message);
|
|
63
|
+
watcher.onUpdate({
|
|
64
|
+
reset: true,
|
|
65
|
+
started: this.started,
|
|
66
|
+
entries: this.history.snapshot(),
|
|
67
|
+
skipped: 0,
|
|
68
|
+
nodes: this.nodes,
|
|
69
|
+
});
|
|
70
|
+
this.nodesTimer ??= setInterval(() => this.checkNodes(), NODES_CHECK_MS);
|
|
71
|
+
}
|
|
72
|
+
remove(watcher) {
|
|
73
|
+
this.watchers.delete(watcher);
|
|
74
|
+
if (this.watchers.size === 0 && this.nodesTimer) {
|
|
75
|
+
clearInterval(this.nodesTimer);
|
|
76
|
+
this.nodesTimer = null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
destroy() {
|
|
80
|
+
for (const timer of [this.retryTimer, this.flushTimer, this.lingerTimer]) {
|
|
81
|
+
if (timer) {
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (this.nodesTimer) {
|
|
86
|
+
clearInterval(this.nodesTimer);
|
|
87
|
+
}
|
|
88
|
+
if (this.subscription) {
|
|
89
|
+
this.node.destroySubscription(this.subscription);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
subscribe() {
|
|
93
|
+
this.retryTimer = null;
|
|
94
|
+
const match = publishersQos(this.node, this.topic, LOG_TYPE, QUEUE_DEPTH);
|
|
95
|
+
if (!('qos' in match)) {
|
|
96
|
+
this.setState(match.state, match.message);
|
|
97
|
+
this.retryTimer = setTimeout(() => this.subscribe(), RETRY_PERIOD_MS);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
this.subscription = this.node.createSubscription(LOG_TYPE, this.topic, { qos: match.qos }, (raw) => this.onMessage(raw));
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
this.setState('error', `Cannot subscribe to ${this.topic}: ${errorMessage(e)}`);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
this.setState('waiting', `Waiting for messages on ${this.topic}`);
|
|
108
|
+
}
|
|
109
|
+
onMessage(raw) {
|
|
110
|
+
this.history.add(raw);
|
|
111
|
+
this.setState('streaming', LOG_TYPE);
|
|
112
|
+
this.scheduleFlush();
|
|
113
|
+
}
|
|
114
|
+
checkNodes() {
|
|
115
|
+
let nodes;
|
|
116
|
+
try {
|
|
117
|
+
nodes = nodeNames(this.node.getNodeNamesAndNamespaces());
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (nodes.join('\n') !== this.nodes.join('\n')) {
|
|
123
|
+
this.nodes = nodes;
|
|
124
|
+
this.nodesChanged = true;
|
|
125
|
+
this.scheduleFlush();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
scheduleFlush() {
|
|
129
|
+
if (this.flushTimer || this.watchers.size === 0) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const wait = Math.max(0, this.lastFlush + 1000 / ROSOUT_RATE - Date.now());
|
|
133
|
+
this.flushTimer = setTimeout(() => this.flush(), wait);
|
|
134
|
+
}
|
|
135
|
+
flush() {
|
|
136
|
+
if (this.flushTimer) {
|
|
137
|
+
clearTimeout(this.flushTimer);
|
|
138
|
+
this.flushTimer = null;
|
|
139
|
+
}
|
|
140
|
+
this.lastFlush = Date.now();
|
|
141
|
+
const batch = this.history.takeBatch(MAX_BATCH);
|
|
142
|
+
const nodes = this.nodesChanged ? this.nodes : null;
|
|
143
|
+
this.nodesChanged = false;
|
|
144
|
+
if (!batch && !nodes) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const update = {
|
|
148
|
+
reset: false,
|
|
149
|
+
started: this.started,
|
|
150
|
+
entries: batch?.entries ?? [],
|
|
151
|
+
skipped: batch?.skipped ?? 0,
|
|
152
|
+
nodes,
|
|
153
|
+
};
|
|
154
|
+
for (const watcher of this.watchers) {
|
|
155
|
+
watcher.onUpdate(update);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
setState(state, message) {
|
|
159
|
+
if (state === this.state && message === this.message) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (state === 'error') {
|
|
163
|
+
this.logger.warn(message);
|
|
164
|
+
}
|
|
165
|
+
this.state = state;
|
|
166
|
+
this.message = message;
|
|
167
|
+
for (const watcher of this.watchers) {
|
|
168
|
+
watcher.onStatus(state, message);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
export class Rosout {
|
|
173
|
+
node;
|
|
174
|
+
logger;
|
|
175
|
+
logs = new Map();
|
|
176
|
+
constructor(node, logger) {
|
|
177
|
+
this.node = node;
|
|
178
|
+
this.logger = logger;
|
|
179
|
+
}
|
|
180
|
+
// Until the returned function is called
|
|
181
|
+
watch(topic, watcher) {
|
|
182
|
+
const name = absoluteTopic(topic);
|
|
183
|
+
let log = this.logs.get(name);
|
|
184
|
+
if (!log) {
|
|
185
|
+
log = new RosoutLog(this.node, this.logger, name);
|
|
186
|
+
this.logs.set(name, log);
|
|
187
|
+
}
|
|
188
|
+
const shared = log;
|
|
189
|
+
shared.add(watcher);
|
|
190
|
+
return () => {
|
|
191
|
+
shared.remove(watcher);
|
|
192
|
+
if (shared.watchers.size === 0 && !shared.lingerTimer) {
|
|
193
|
+
shared.lingerTimer = setTimeout(() => this.forget(name, shared), LINGER_MS);
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
forget(topic, log) {
|
|
198
|
+
log.destroy();
|
|
199
|
+
if (this.logs.get(topic) === log) {
|
|
200
|
+
this.logs.delete(topic);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { LogEntry } from '../shared/protocol.ts';
|
|
2
|
+
export declare function logEntry(raw: unknown, seq: number): LogEntry;
|
|
3
|
+
export interface LogBatch {
|
|
4
|
+
entries: LogEntry[];
|
|
5
|
+
skipped: number;
|
|
6
|
+
}
|
|
7
|
+
export declare class LogHistory {
|
|
8
|
+
private readonly capacity;
|
|
9
|
+
private entries;
|
|
10
|
+
private last;
|
|
11
|
+
private taken;
|
|
12
|
+
constructor(capacity: number);
|
|
13
|
+
add(raw: unknown): void;
|
|
14
|
+
snapshot(): LogEntry[];
|
|
15
|
+
takeBatch(max: number): LogBatch | null;
|
|
16
|
+
}
|
|
17
|
+
export declare function nodeNames(nodes: {
|
|
18
|
+
name: string;
|
|
19
|
+
namespace: string;
|
|
20
|
+
}[]): string[];
|