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.
Files changed (51) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +66 -3
  3. package/dist/server/behavior_tree_file.d.ts +17 -0
  4. package/dist/server/behavior_tree_file.js +232 -0
  5. package/dist/server/behavior_tree_files.d.ts +18 -0
  6. package/dist/server/behavior_tree_files.js +405 -0
  7. package/dist/server/behavior_tree_log.d.ts +10 -0
  8. package/dist/server/behavior_tree_log.js +132 -0
  9. package/dist/server/behavior_tree_playback.d.ts +57 -0
  10. package/dist/server/behavior_tree_playback.js +188 -0
  11. package/dist/server/behavior_tree_state.d.ts +38 -0
  12. package/dist/server/behavior_tree_state.js +220 -0
  13. package/dist/server/behavior_trees.d.ts +29 -0
  14. package/dist/server/behavior_trees.js +362 -0
  15. package/dist/server/files.d.ts +5 -2
  16. package/dist/server/files.js +32 -13
  17. package/dist/server/http.js +7 -5
  18. package/dist/server/rosbag.d.ts +25 -0
  19. package/dist/server/rosbag.js +418 -0
  20. package/dist/server/rosout.d.ts +16 -0
  21. package/dist/server/rosout.js +203 -0
  22. package/dist/server/rosout_log.d.ts +20 -0
  23. package/dist/server/rosout_log.js +66 -0
  24. package/dist/server/server.js +20 -4
  25. package/dist/server/session.d.ts +7 -0
  26. package/dist/server/session.js +63 -0
  27. package/dist/server/systemd.js +1 -1
  28. package/dist/server/systemd_format.d.ts +1 -0
  29. package/dist/server/systemd_format.js +19 -3
  30. package/dist/shared/bt_nodes.d.ts +25 -0
  31. package/dist/shared/bt_nodes.js +180 -0
  32. package/dist/shared/protocol.d.ts +157 -2
  33. package/dist/shared/protocol.js +4 -0
  34. package/dist/shared/xml.d.ts +25 -0
  35. package/dist/shared/xml.js +164 -0
  36. package/dist/types/client/api.d.ts +4 -3
  37. package/dist/types/client/connection.d.ts +31 -1
  38. package/dist/types/client/dialogs.d.ts +2 -1
  39. package/dist/types/client/icons.d.ts +24 -0
  40. package/dist/types/client/sdk/index.d.ts +3 -2
  41. package/dist/types/client/widgets/types.d.ts +5 -1
  42. package/dist/types/shared/bt_nodes.d.ts +25 -0
  43. package/dist/types/shared/protocol.d.ts +157 -2
  44. package/dist/www/assets/Joystick.js +1 -1
  45. package/dist/www/assets/RobotModelView.js +2 -2
  46. package/dist/www/assets/icons.js +1 -1
  47. package/dist/www/assets/index.css +1 -1
  48. package/dist/www/assets/index.js +28 -1
  49. package/dist/www/assets/shared/tassello-sdk.js +1 -1
  50. package/docs/plugins.md +8 -0
  51. 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[];