foxglove-ros-adapter 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/dist/index.cjs ADDED
@@ -0,0 +1,1340 @@
1
+ 'use strict';
2
+
3
+ var rosmsg = require('@foxglove/rosmsg');
4
+ var rosmsg2Serialization = require('@foxglove/rosmsg2-serialization');
5
+ var zod = require('zod');
6
+
7
+ // src/ros.ts
8
+ var channelSchema = zod.z.object({
9
+ id: zod.z.number(),
10
+ topic: zod.z.string(),
11
+ encoding: zod.z.string(),
12
+ schemaName: zod.z.string(),
13
+ schema: zod.z.string(),
14
+ schemaEncoding: zod.z.string().optional()
15
+ });
16
+ var serviceSchemaSide = zod.z.object({
17
+ encoding: zod.z.string().optional(),
18
+ schemaName: zod.z.string().optional(),
19
+ schemaEncoding: zod.z.string().optional(),
20
+ schema: zod.z.string()
21
+ });
22
+ var serviceSchema = zod.z.object({
23
+ id: zod.z.number(),
24
+ name: zod.z.string(),
25
+ type: zod.z.string(),
26
+ // Legacy flat form (foxglove.websocket.v1 bridges).
27
+ requestSchema: zod.z.string().optional(),
28
+ responseSchema: zod.z.string().optional(),
29
+ // foxglove.sdk.v1 nested form.
30
+ request: serviceSchemaSide.optional(),
31
+ response: serviceSchemaSide.optional()
32
+ });
33
+ var parameterValueSchema = zod.z.object({
34
+ name: zod.z.string(),
35
+ value: zod.z.unknown(),
36
+ type: zod.z.string().optional()
37
+ });
38
+ var serverInfoMessageSchema = zod.z.object({
39
+ op: zod.z.literal("serverInfo"),
40
+ name: zod.z.string(),
41
+ capabilities: zod.z.array(zod.z.string()),
42
+ supportedEncodings: zod.z.array(zod.z.string()).optional(),
43
+ metadata: zod.z.record(zod.z.string(), zod.z.string()).optional(),
44
+ sessionId: zod.z.string().optional()
45
+ });
46
+ var advertiseMessageSchema = zod.z.object({
47
+ op: zod.z.literal("advertise"),
48
+ channels: zod.z.array(channelSchema)
49
+ });
50
+ var unadvertiseMessageSchema = zod.z.object({
51
+ op: zod.z.literal("unadvertise"),
52
+ channelIds: zod.z.array(zod.z.number())
53
+ });
54
+ var advertiseServicesMessageSchema = zod.z.object({
55
+ op: zod.z.literal("advertiseServices"),
56
+ services: zod.z.array(serviceSchema)
57
+ });
58
+ var unadvertiseServicesMessageSchema = zod.z.object({
59
+ op: zod.z.literal("unadvertiseServices"),
60
+ serviceIds: zod.z.array(zod.z.number())
61
+ });
62
+ var parameterValuesMessageSchema = zod.z.object({
63
+ op: zod.z.literal("parameterValues"),
64
+ id: zod.z.string().optional(),
65
+ parameters: zod.z.array(parameterValueSchema)
66
+ });
67
+ var statusMessageSchema = zod.z.object({
68
+ op: zod.z.literal("status"),
69
+ level: zod.z.number(),
70
+ message: zod.z.string().optional(),
71
+ msg: zod.z.string().optional()
72
+ });
73
+ var serviceCallFailureMessageSchema = zod.z.object({
74
+ op: zod.z.literal("serviceCallFailure"),
75
+ serviceId: zod.z.number(),
76
+ callId: zod.z.number(),
77
+ message: zod.z.string().optional()
78
+ });
79
+ var serverMessageSchema = zod.z.discriminatedUnion("op", [
80
+ serverInfoMessageSchema,
81
+ advertiseMessageSchema,
82
+ unadvertiseMessageSchema,
83
+ advertiseServicesMessageSchema,
84
+ unadvertiseServicesMessageSchema,
85
+ parameterValuesMessageSchema,
86
+ statusMessageSchema,
87
+ serviceCallFailureMessageSchema
88
+ ]);
89
+
90
+ // src/protocol.ts
91
+ var OP_MESSAGE_DATA = 1;
92
+ var OP_SERVICE_CALL_RESPONSE = 3;
93
+ var OP_CLIENT_MESSAGE_DATA = 1;
94
+ var OP_CLIENT_SERVICE_CALL_REQUEST = 2;
95
+ var FoxgloveProtocolClient = class {
96
+ ws = null;
97
+ handlers = {
98
+ serverInfo: /* @__PURE__ */ new Set(),
99
+ advertise: /* @__PURE__ */ new Set(),
100
+ unadvertise: /* @__PURE__ */ new Set(),
101
+ advertiseServices: /* @__PURE__ */ new Set(),
102
+ unadvertiseServices: /* @__PURE__ */ new Set(),
103
+ message: /* @__PURE__ */ new Set(),
104
+ serviceResponse: /* @__PURE__ */ new Set(),
105
+ serviceCallFailure: /* @__PURE__ */ new Set(),
106
+ parameterValues: /* @__PURE__ */ new Set(),
107
+ open: /* @__PURE__ */ new Set(),
108
+ close: /* @__PURE__ */ new Set(),
109
+ error: /* @__PURE__ */ new Set()
110
+ };
111
+ nextSubscriptionId = 1;
112
+ nextCallId = 1;
113
+ nextClientChannelId = 1;
114
+ connect(url) {
115
+ this.close();
116
+ const wsUrl = url.replace(/^http/, "ws");
117
+ this.ws = new WebSocket(wsUrl, ["foxglove.sdk.v1", "foxglove.websocket.v1"]);
118
+ this.ws.binaryType = "arraybuffer";
119
+ this.ws.onopen = () => this.dispatch(this.handlers.open, (h) => h(), "open");
120
+ this.ws.onclose = (event) => this.dispatch(this.handlers.close, (h) => h(event), "close");
121
+ this.ws.onerror = (event) => this.dispatch(this.handlers.error, (h) => h(event), "error");
122
+ this.ws.onmessage = (event) => this.handleMessage(event);
123
+ }
124
+ close() {
125
+ const ws = this.ws;
126
+ if (!ws) return;
127
+ this.ws = null;
128
+ this.dispatch(this.handlers.close, (h) => h(new CloseEvent("close")), "close");
129
+ ws.onopen = null;
130
+ ws.onclose = null;
131
+ ws.onerror = null;
132
+ ws.onmessage = null;
133
+ ws.close();
134
+ }
135
+ get isConnected() {
136
+ return this.ws?.readyState === WebSocket.OPEN;
137
+ }
138
+ // ─── Subscriptions ──────────────────────────────────────────────────────
139
+ subscribe(channelId) {
140
+ const id = this.nextSubscriptionId++;
141
+ this.sendJson({
142
+ op: "subscribe",
143
+ subscriptions: [{ id, channelId }]
144
+ });
145
+ return id;
146
+ }
147
+ unsubscribe(subscriptionId) {
148
+ this.sendJson({
149
+ op: "unsubscribe",
150
+ subscriptionIds: [subscriptionId]
151
+ });
152
+ }
153
+ // ─── Publishing ─────────────────────────────────────────────────────────
154
+ advertiseClientChannel(topic, encoding, schemaName) {
155
+ const id = this.nextClientChannelId++;
156
+ this.sendJson({
157
+ op: "advertise",
158
+ channels: [{ id, topic, encoding, schemaName }]
159
+ });
160
+ return id;
161
+ }
162
+ unadvertiseClientChannel(channelId) {
163
+ this.sendJson({
164
+ op: "unadvertise",
165
+ channelIds: [channelId]
166
+ });
167
+ }
168
+ publishMessage(channelId, data) {
169
+ if (this.ws?.readyState !== WebSocket.OPEN) return;
170
+ const msg = new Uint8Array(1 + 4 + data.byteLength);
171
+ const view = new DataView(msg.buffer);
172
+ view.setUint8(0, OP_CLIENT_MESSAGE_DATA);
173
+ view.setUint32(1, channelId, true);
174
+ msg.set(data, 5);
175
+ this.ws.send(msg);
176
+ }
177
+ // ─── Services ───────────────────────────────────────────────────────────
178
+ callService(serviceId, encoding, requestData) {
179
+ const callId = this.nextCallId++;
180
+ if (this.ws?.readyState !== WebSocket.OPEN) return callId;
181
+ const encodingBytes = new TextEncoder().encode(encoding);
182
+ const msg = new Uint8Array(
183
+ 1 + 4 + 4 + 4 + encodingBytes.byteLength + requestData.byteLength
184
+ );
185
+ const view = new DataView(msg.buffer);
186
+ let offset = 0;
187
+ view.setUint8(offset, OP_CLIENT_SERVICE_CALL_REQUEST);
188
+ offset += 1;
189
+ view.setUint32(offset, serviceId, true);
190
+ offset += 4;
191
+ view.setUint32(offset, callId, true);
192
+ offset += 4;
193
+ view.setUint32(offset, encodingBytes.byteLength, true);
194
+ offset += 4;
195
+ msg.set(encodingBytes, offset);
196
+ offset += encodingBytes.byteLength;
197
+ msg.set(requestData, offset);
198
+ this.ws.send(msg);
199
+ return callId;
200
+ }
201
+ // ─── Parameters ─────────────────────────────────────────────────────────
202
+ getParameters(names, requestId) {
203
+ this.sendJson({
204
+ op: "getParameters",
205
+ parameterNames: names,
206
+ id: requestId
207
+ });
208
+ }
209
+ setParameters(parameters, requestId) {
210
+ this.sendJson({
211
+ op: "setParameters",
212
+ parameters,
213
+ id: requestId
214
+ });
215
+ }
216
+ // ─── Event system ───────────────────────────────────────────────────────
217
+ on(event, callback) {
218
+ this.handlers[event].add(callback);
219
+ }
220
+ off(event, callback) {
221
+ this.handlers[event].delete(callback);
222
+ }
223
+ /**
224
+ * Dispatch helper that stays outside TypeScript's distributed-generic
225
+ * limitation. Each caller passes the concrete Set for one event plus a
226
+ * function that invokes a single handler with the right argument shape,
227
+ * so the spread is in a non-generic context and the types line up.
228
+ */
229
+ dispatch(handlers, invoke, event) {
230
+ for (const handler of handlers) {
231
+ try {
232
+ invoke(handler);
233
+ } catch (e) {
234
+ console.warn(`Error in ${event} handler:`, e);
235
+ }
236
+ }
237
+ }
238
+ // ─── Message handling ───────────────────────────────────────────────────
239
+ handleMessage(event) {
240
+ if (typeof event.data === "string") {
241
+ this.handleTextMessage(event.data);
242
+ } else if (event.data instanceof ArrayBuffer) {
243
+ this.handleBinaryMessage(event.data);
244
+ }
245
+ }
246
+ handleTextMessage(text) {
247
+ let parsed;
248
+ try {
249
+ parsed = JSON.parse(text);
250
+ } catch {
251
+ console.warn("Failed to parse foxglove text message:", text.slice(0, 100));
252
+ return;
253
+ }
254
+ const result = serverMessageSchema.safeParse(parsed);
255
+ if (!result.success) {
256
+ return;
257
+ }
258
+ const msg = result.data;
259
+ switch (msg.op) {
260
+ case "serverInfo":
261
+ this.dispatch(this.handlers.serverInfo, (h) => h(msg), "serverInfo");
262
+ break;
263
+ case "advertise":
264
+ this.dispatch(this.handlers.advertise, (h) => h(msg.channels), "advertise");
265
+ break;
266
+ case "unadvertise":
267
+ this.dispatch(this.handlers.unadvertise, (h) => h(msg.channelIds), "unadvertise");
268
+ break;
269
+ case "advertiseServices":
270
+ this.dispatch(
271
+ this.handlers.advertiseServices,
272
+ (h) => h(msg.services),
273
+ "advertiseServices"
274
+ );
275
+ break;
276
+ case "unadvertiseServices":
277
+ this.dispatch(
278
+ this.handlers.unadvertiseServices,
279
+ (h) => h(msg.serviceIds),
280
+ "unadvertiseServices"
281
+ );
282
+ break;
283
+ case "parameterValues":
284
+ this.dispatch(
285
+ this.handlers.parameterValues,
286
+ (h) => h(msg.id ?? "", msg.parameters),
287
+ "parameterValues"
288
+ );
289
+ break;
290
+ case "status": {
291
+ const message = msg.message ?? msg.msg;
292
+ if (msg.level === 0) console.info("[foxglove]", message);
293
+ else console.warn("[foxglove]", message);
294
+ break;
295
+ }
296
+ case "serviceCallFailure":
297
+ this.dispatch(
298
+ this.handlers.serviceCallFailure,
299
+ (h) => h({
300
+ serviceId: msg.serviceId,
301
+ callId: msg.callId,
302
+ message: msg.message ?? "service call failed"
303
+ }),
304
+ "serviceCallFailure"
305
+ );
306
+ break;
307
+ }
308
+ }
309
+ handleBinaryMessage(data) {
310
+ const bytes = new Uint8Array(data);
311
+ if (bytes.length < 1) return;
312
+ const opcode = bytes[0];
313
+ if (opcode === void 0) return;
314
+ const view = new DataView(data);
315
+ switch (opcode) {
316
+ case OP_MESSAGE_DATA: {
317
+ if (bytes.length < 13) return;
318
+ const subscriptionId = view.getUint32(1, true);
319
+ const timestamp = view.getBigUint64(5, true);
320
+ const msgData = bytes.slice(13);
321
+ this.dispatch(
322
+ this.handlers.message,
323
+ (h) => h(subscriptionId, timestamp, msgData),
324
+ "message"
325
+ );
326
+ break;
327
+ }
328
+ case OP_SERVICE_CALL_RESPONSE: {
329
+ if (bytes.length < 13) return;
330
+ const serviceId = view.getUint32(1, true);
331
+ const callId = view.getUint32(5, true);
332
+ const encodingLength = view.getUint32(9, true);
333
+ const encoding = new TextDecoder().decode(bytes.slice(13, 13 + encodingLength));
334
+ const responseData = bytes.slice(13 + encodingLength);
335
+ this.dispatch(
336
+ this.handlers.serviceResponse,
337
+ (h) => h({ serviceId, callId, encoding, data: responseData }),
338
+ "serviceResponse"
339
+ );
340
+ break;
341
+ }
342
+ default:
343
+ console.warn(
344
+ `[foxglove] unhandled binary opcode 0x${opcode.toString(16)} (len=${bytes.length})`
345
+ );
346
+ }
347
+ }
348
+ sendJson(msg) {
349
+ if (this.ws?.readyState === WebSocket.OPEN) {
350
+ this.ws.send(JSON.stringify(msg));
351
+ }
352
+ }
353
+ };
354
+
355
+ // src/ros.ts
356
+ function normalizeRosType(type, kind = "msg") {
357
+ const trimmed = type.replace(/^\/+/, "");
358
+ if (trimmed.includes(`/${kind}/`)) {
359
+ return trimmed;
360
+ }
361
+ const slash = trimmed.indexOf("/");
362
+ if (slash === -1) {
363
+ return trimmed;
364
+ }
365
+ return `${trimmed.slice(0, slash)}/${kind}/${trimmed.slice(slash + 1)}`;
366
+ }
367
+ function toFoxgloveParamName(name) {
368
+ return name.replaceAll(":", ".");
369
+ }
370
+ var Ros = class {
371
+ protocol = new FoxgloveProtocolClient();
372
+ connectionListeners = /* @__PURE__ */ new Set();
373
+ closeListeners = /* @__PURE__ */ new Set();
374
+ errorListeners = /* @__PURE__ */ new Set();
375
+ // Channel/service registries populated by foxglove_bridge advertisements
376
+ channels = /* @__PURE__ */ new Map();
377
+ // channelId → channel
378
+ channelsByTopic = /* @__PURE__ */ new Map();
379
+ // topicName → channel
380
+ services = /* @__PURE__ */ new Map();
381
+ // serviceId → service
382
+ servicesByName = /* @__PURE__ */ new Map();
383
+ // serviceName → service
384
+ // Subscription state
385
+ subscriptions = /* @__PURE__ */ new Map();
386
+ // subscriptionId → sub
387
+ subscriptionsByTopic = /* @__PURE__ */ new Map();
388
+ // topic → sub
389
+ pendingSubscribers = [];
390
+ // Publishing state
391
+ clientChannels = /* @__PURE__ */ new Map();
392
+ // topic → client channel
393
+ // Service call correlation
394
+ pendingServiceCalls = /* @__PURE__ */ new Map();
395
+ // callId → pending
396
+ // Splitting onValues/onClose lets callers distinguish "param missing"
397
+ // (resolve null) from "WebSocket closed before reply" (reject).
398
+ pendingParamRequests = /* @__PURE__ */ new Map();
399
+ nextParamRequestId = 1;
400
+ // Compiled reader/writer caches keyed by the canonical schema name plus the
401
+ // full schema text. The full text is the only collision-free identity:
402
+ // schema.length alone collides for equal-length schemas, and schemaName alone
403
+ // collides across distros that share a type name but evolve its fields.
404
+ // MessageReader / MessageWriter internally pre-compile per-type decode paths
405
+ // from a MessageDefinition list, so we reuse them across messages instead of
406
+ // re-parsing the schema on every topic/service hit.
407
+ readerCache = /* @__PURE__ */ new Map();
408
+ writerCache = /* @__PURE__ */ new Map();
409
+ isConnected = false;
410
+ constructor(options) {
411
+ this.setupProtocolHandlers();
412
+ if (options?.url) {
413
+ this.connect(options.url);
414
+ }
415
+ }
416
+ connect(url) {
417
+ this.protocol.connect(url);
418
+ }
419
+ close() {
420
+ this.protocol.close();
421
+ this.isConnected = false;
422
+ }
423
+ on(...args) {
424
+ const [event, callback] = args;
425
+ switch (event) {
426
+ case "connection":
427
+ this.connectionListeners.add(callback);
428
+ break;
429
+ case "close":
430
+ this.closeListeners.add(callback);
431
+ break;
432
+ case "error":
433
+ this.errorListeners.add(callback);
434
+ break;
435
+ }
436
+ }
437
+ off(...args) {
438
+ const [event, callback] = args;
439
+ switch (event) {
440
+ case "connection":
441
+ this.connectionListeners.delete(callback);
442
+ break;
443
+ case "close":
444
+ this.closeListeners.delete(callback);
445
+ break;
446
+ case "error":
447
+ this.errorListeners.delete(callback);
448
+ break;
449
+ }
450
+ }
451
+ /**
452
+ * roslib-compatible bulk listener removal. Tests use this to reset the
453
+ * module-level mock Ros between cases; production code does not call it.
454
+ */
455
+ removeAllListeners(event) {
456
+ if (event === void 0) {
457
+ this.connectionListeners.clear();
458
+ this.closeListeners.clear();
459
+ this.errorListeners.clear();
460
+ return;
461
+ }
462
+ switch (event) {
463
+ case "connection":
464
+ this.connectionListeners.clear();
465
+ break;
466
+ case "close":
467
+ this.closeListeners.clear();
468
+ break;
469
+ case "error":
470
+ this.errorListeners.clear();
471
+ break;
472
+ }
473
+ }
474
+ emit(event, error) {
475
+ switch (event) {
476
+ case "connection":
477
+ this.emitLifecycle("connection", this.connectionListeners);
478
+ break;
479
+ case "close":
480
+ this.emitLifecycle("close", this.closeListeners);
481
+ break;
482
+ case "error":
483
+ this.emitError(error ?? new Error("ros emit('error') called without an error"));
484
+ break;
485
+ }
486
+ }
487
+ emitLifecycle(event, listeners) {
488
+ for (const cb of listeners) {
489
+ try {
490
+ cb();
491
+ } catch (e) {
492
+ console.warn(`Error in Ros "${event}" handler:`, e);
493
+ }
494
+ }
495
+ }
496
+ emitError(error) {
497
+ for (const cb of this.errorListeners) {
498
+ try {
499
+ cb(error);
500
+ } catch (e) {
501
+ console.warn('Error in Ros "error" handler:', e);
502
+ }
503
+ }
504
+ }
505
+ // ─── Channel/Service Lookups ────────────────────────────────────────────
506
+ getChannel(topic) {
507
+ return this.channelsByTopic.get(topic);
508
+ }
509
+ getServiceByName(name) {
510
+ return this.servicesByName.get(name);
511
+ }
512
+ /**
513
+ * Return every advertised topic whose schema matches `messageType`. Matches
514
+ * roslib's `Ros#getTopicsForType(type, success, fail)` callback API so
515
+ * existing callers (e.g. `useImageTopics`) can use this adapter unchanged.
516
+ */
517
+ /**
518
+ * The roslib signature also accepts an `onError` callback. Foxglove resolves
519
+ * synchronously from the in-memory channel registry, so the failure path is
520
+ * unreachable; we drop the parameter to keep lint clean. Callers that pass
521
+ * one are unaffected — extra arguments to a JS function are ignored.
522
+ */
523
+ getTopicsForType(messageType, onSuccess) {
524
+ const canonical = normalizeRosType(messageType, "msg");
525
+ const topics = [];
526
+ for (const ch of this.channels.values()) {
527
+ if (normalizeRosType(ch.schemaName, "msg") === canonical) {
528
+ topics.push(ch.topic);
529
+ }
530
+ }
531
+ onSuccess(topics);
532
+ }
533
+ // ─── Topic Subscription ─────────────────────────────────────────────────
534
+ subscribeTopic(topic, messageType, callback) {
535
+ const existing = this.subscriptionsByTopic.get(topic);
536
+ if (existing) {
537
+ existing.callbacks.add(callback);
538
+ return;
539
+ }
540
+ const channel = this.channelsByTopic.get(topic);
541
+ if (channel) {
542
+ this.createSubscription(channel, callback);
543
+ } else {
544
+ this.pendingSubscribers.push({ topic, messageType, callback });
545
+ }
546
+ }
547
+ unsubscribeTopic(topic, callback) {
548
+ const keep = this.pendingSubscribers.filter(
549
+ (p) => p.topic !== topic || callback !== void 0 && p.callback !== callback
550
+ );
551
+ if (keep.length !== this.pendingSubscribers.length) {
552
+ this.pendingSubscribers.length = 0;
553
+ this.pendingSubscribers.push(...keep);
554
+ }
555
+ const sub = this.subscriptionsByTopic.get(topic);
556
+ if (!sub) return;
557
+ if (callback) {
558
+ sub.callbacks.delete(callback);
559
+ if (sub.callbacks.size > 0) return;
560
+ }
561
+ this.protocol.unsubscribe(sub.subscriptionId);
562
+ this.subscriptions.delete(sub.subscriptionId);
563
+ this.subscriptionsByTopic.delete(topic);
564
+ }
565
+ createSubscription(channel, callback) {
566
+ const reader = this.getReader(channel.schemaName, channel.schema);
567
+ const subscriptionId = this.protocol.subscribe(channel.id);
568
+ const sub = {
569
+ subscriptionId,
570
+ channelId: channel.id,
571
+ callbacks: /* @__PURE__ */ new Set([callback]),
572
+ reader
573
+ };
574
+ this.subscriptions.set(subscriptionId, sub);
575
+ this.subscriptionsByTopic.set(channel.topic, sub);
576
+ }
577
+ // ─── Topic Publishing ───────────────────────────────────────────────────
578
+ publishTopic(topic, messageType, message) {
579
+ let clientCh = this.clientChannels.get(topic);
580
+ if (!clientCh) {
581
+ const schemaName = normalizeRosType(messageType, "msg");
582
+ const writer = this.findWriterForSchema(schemaName);
583
+ const encoding = writer ? "cdr" : "json";
584
+ const clientChannelId = this.protocol.advertiseClientChannel(
585
+ topic,
586
+ encoding,
587
+ schemaName
588
+ );
589
+ clientCh = { clientChannelId, topic, schemaName, encoding, writer };
590
+ this.clientChannels.set(topic, clientCh);
591
+ }
592
+ if (clientCh.encoding === "cdr") {
593
+ if (!clientCh.writer) {
594
+ clientCh.writer = this.findWriterForSchema(clientCh.schemaName);
595
+ }
596
+ if (!clientCh.writer) {
597
+ throw new Error(
598
+ `Cannot publish CDR to "${topic}": schema "${clientCh.schemaName}" disappeared from advertised channels.`
599
+ );
600
+ }
601
+ const cdrData = clientCh.writer.writeMessage(message);
602
+ this.protocol.publishMessage(clientCh.clientChannelId, cdrData);
603
+ return;
604
+ }
605
+ const jsonData = new TextEncoder().encode(JSON.stringify(message));
606
+ this.protocol.publishMessage(clientCh.clientChannelId, jsonData);
607
+ }
608
+ findWriterForSchema(schemaName) {
609
+ const canonical = normalizeRosType(schemaName, "msg");
610
+ for (const channel of this.channels.values()) {
611
+ if (normalizeRosType(channel.schemaName, "msg") === canonical) {
612
+ return this.getWriter(channel.schemaName, channel.schema);
613
+ }
614
+ }
615
+ return null;
616
+ }
617
+ unpublishTopic(topic) {
618
+ const clientCh = this.clientChannels.get(topic);
619
+ if (clientCh) {
620
+ this.protocol.unadvertiseClientChannel(clientCh.clientChannelId);
621
+ this.clientChannels.delete(topic);
622
+ }
623
+ }
624
+ // ─── Service Calls ──────────────────────────────────────────────────────
625
+ callService(serviceName, serviceType, request) {
626
+ return new Promise((resolve, reject) => {
627
+ const svc = this.servicesByName.get(serviceName);
628
+ if (!svc) {
629
+ reject(new Error(`Service ${serviceName} not available`));
630
+ return;
631
+ }
632
+ const canonicalType = normalizeRosType(serviceType, "srv");
633
+ const requestSchema = svc.request?.schema ?? svc.requestSchema ?? "";
634
+ const responseSchema = svc.response?.schema ?? svc.responseSchema ?? "";
635
+ if (!requestSchema) {
636
+ reject(new Error(`No request definition for service ${serviceName}`));
637
+ return;
638
+ }
639
+ const requestWriter = this.getWriter(canonicalType + "_Request", requestSchema);
640
+ const responseReader = responseSchema ? this.getReader(canonicalType + "_Response", responseSchema) : null;
641
+ const requestData = requestWriter.writeMessage(request);
642
+ const callId = this.protocol.callService(svc.id, "cdr", requestData);
643
+ this.pendingServiceCalls.set(callId, { resolve, reject, responseReader });
644
+ });
645
+ }
646
+ // ─── Parameters ─────────────────────────────────────────────────────────
647
+ getParam(name) {
648
+ return new Promise((resolve, reject) => {
649
+ const wireName = toFoxgloveParamName(name);
650
+ const requestId = `param_get_${this.nextParamRequestId++}`;
651
+ this.pendingParamRequests.set(requestId, {
652
+ onValues: (params) => {
653
+ const param = params.find((p) => toFoxgloveParamName(p.name) === wireName);
654
+ resolve(param?.value ?? null);
655
+ },
656
+ onClose: () => reject(new Error(`getParam(${name}): WebSocket closed before response`))
657
+ });
658
+ this.protocol.getParameters([wireName], requestId);
659
+ });
660
+ }
661
+ setParam(name, value) {
662
+ return new Promise((resolve, reject) => {
663
+ const wireName = toFoxgloveParamName(name);
664
+ const requestId = `param_set_${this.nextParamRequestId++}`;
665
+ this.pendingParamRequests.set(requestId, {
666
+ onValues: () => resolve(),
667
+ onClose: () => reject(new Error(`setParam(${name}): WebSocket closed before response`))
668
+ });
669
+ this.protocol.setParameters([{ name: wireName, value }], requestId);
670
+ });
671
+ }
672
+ // ─── Internal: Protocol Event Handlers ──────────────────────────────────
673
+ setupProtocolHandlers() {
674
+ this.protocol.on("open", () => {
675
+ this.isConnected = true;
676
+ this.emit("connection");
677
+ });
678
+ this.protocol.on("close", () => {
679
+ this.isConnected = false;
680
+ this.channels.clear();
681
+ this.channelsByTopic.clear();
682
+ this.services.clear();
683
+ this.servicesByName.clear();
684
+ this.subscriptions.clear();
685
+ this.subscriptionsByTopic.clear();
686
+ this.clientChannels.clear();
687
+ this.readerCache.clear();
688
+ this.writerCache.clear();
689
+ const closeError = new Error("WebSocket closed before response received");
690
+ for (const pending of this.pendingServiceCalls.values()) {
691
+ try {
692
+ pending.reject(closeError);
693
+ } catch (e) {
694
+ console.warn("Error rejecting pending service call on close:", e);
695
+ }
696
+ }
697
+ this.pendingServiceCalls.clear();
698
+ for (const pending of this.pendingParamRequests.values()) {
699
+ try {
700
+ pending.onClose();
701
+ } catch (e) {
702
+ console.warn("Error completing pending param request on close:", e);
703
+ }
704
+ }
705
+ this.pendingParamRequests.clear();
706
+ this.pendingSubscribers.length = 0;
707
+ this.emit("close");
708
+ });
709
+ this.protocol.on("error", () => {
710
+ this.emit("error", new Error("Foxglove WebSocket error"));
711
+ });
712
+ this.protocol.on("advertise", (channels) => {
713
+ for (const ch of channels) {
714
+ this.channels.set(ch.id, ch);
715
+ this.channelsByTopic.set(ch.topic, ch);
716
+ }
717
+ this.processPendingSubscribers();
718
+ });
719
+ this.protocol.on("unadvertise", (channelIds) => {
720
+ for (const id of channelIds) {
721
+ const ch = this.channels.get(id);
722
+ if (ch) {
723
+ this.channelsByTopic.delete(ch.topic);
724
+ this.channels.delete(id);
725
+ }
726
+ }
727
+ });
728
+ this.protocol.on("advertiseServices", (svcs) => {
729
+ for (const svc of svcs) {
730
+ this.services.set(svc.id, svc);
731
+ this.servicesByName.set(svc.name, svc);
732
+ }
733
+ });
734
+ this.protocol.on("unadvertiseServices", (serviceIds) => {
735
+ for (const id of serviceIds) {
736
+ const svc = this.services.get(id);
737
+ if (svc) {
738
+ this.servicesByName.delete(svc.name);
739
+ this.services.delete(id);
740
+ }
741
+ }
742
+ });
743
+ this.protocol.on(
744
+ "message",
745
+ (subscriptionId, _timestamp, data) => {
746
+ const sub = this.subscriptions.get(subscriptionId);
747
+ if (!sub) return;
748
+ try {
749
+ const msg = sub.reader.readMessage(data);
750
+ for (const cb of sub.callbacks) {
751
+ cb(msg);
752
+ }
753
+ } catch (e) {
754
+ console.warn("CDR deserialization error:", e);
755
+ }
756
+ }
757
+ );
758
+ this.protocol.on("serviceResponse", (response) => {
759
+ const pending = this.pendingServiceCalls.get(response.callId);
760
+ if (!pending) return;
761
+ this.pendingServiceCalls.delete(response.callId);
762
+ try {
763
+ if (pending.responseReader) {
764
+ const result = pending.responseReader.readMessage(
765
+ response.data
766
+ );
767
+ pending.resolve(result);
768
+ return;
769
+ }
770
+ pending.resolve({});
771
+ } catch (e) {
772
+ pending.reject(e instanceof Error ? e : new Error(String(e)));
773
+ }
774
+ });
775
+ this.protocol.on("serviceCallFailure", (failure) => {
776
+ const pending = this.pendingServiceCalls.get(failure.callId);
777
+ if (!pending) return;
778
+ this.pendingServiceCalls.delete(failure.callId);
779
+ pending.reject(new Error(failure.message));
780
+ });
781
+ this.protocol.on("parameterValues", (id, params) => {
782
+ const pending = this.pendingParamRequests.get(id);
783
+ if (pending) {
784
+ this.pendingParamRequests.delete(id);
785
+ pending.onValues(params);
786
+ }
787
+ });
788
+ }
789
+ processPendingSubscribers() {
790
+ const remaining = [];
791
+ for (const pending of this.pendingSubscribers) {
792
+ const channel = this.channelsByTopic.get(pending.topic);
793
+ if (channel) {
794
+ const existing = this.subscriptionsByTopic.get(pending.topic);
795
+ if (existing) {
796
+ existing.callbacks.add(pending.callback);
797
+ } else {
798
+ this.createSubscription(channel, pending.callback);
799
+ }
800
+ } else {
801
+ remaining.push(pending);
802
+ }
803
+ }
804
+ this.pendingSubscribers.length = 0;
805
+ this.pendingSubscribers.push(...remaining);
806
+ }
807
+ getReader(schemaName, schema) {
808
+ const key = `${normalizeRosType(schemaName, "msg")} ${schema}`;
809
+ let reader = this.readerCache.get(key);
810
+ if (!reader) {
811
+ reader = new rosmsg2Serialization.MessageReader(rosmsg.parse(schema, { ros2: true }));
812
+ this.readerCache.set(key, reader);
813
+ }
814
+ return reader;
815
+ }
816
+ getWriter(schemaName, schema) {
817
+ const key = `${normalizeRosType(schemaName, "msg")} ${schema}`;
818
+ let writer = this.writerCache.get(key);
819
+ if (!writer) {
820
+ writer = new rosmsg2Serialization.MessageWriter(rosmsg.parse(schema, { ros2: true }));
821
+ this.writerCache.set(key, writer);
822
+ }
823
+ return writer;
824
+ }
825
+ };
826
+
827
+ // src/topic.ts
828
+ var Topic = class {
829
+ ros;
830
+ name;
831
+ messageType;
832
+ messageSchema;
833
+ /**
834
+ * Throttle window in ms. `0` disables throttling — in that case `subscribe`
835
+ * skips the throttling code path entirely and registers an unwrapped callback
836
+ * so the per-message hot path has no throttle-related work.
837
+ */
838
+ throttleMs;
839
+ /**
840
+ * Original user callback → wrapped MessageCallback registered with Ros.
841
+ * Keyed as `object` (all JS functions are objects) so `Topic<T>` stays
842
+ * variance-friendly for callers that pass a `Topic<Foo>` into a slot typed
843
+ * as `Topic<unknown>` — otherwise T ends up invariant through this field
844
+ * and blocks assignments that are safe at runtime.
845
+ */
846
+ wrappedCallbacks = /* @__PURE__ */ new Map();
847
+ constructor(options) {
848
+ this.ros = options.ros;
849
+ this.name = options.name;
850
+ this.messageType = options.messageType;
851
+ this.messageSchema = options.messageSchema;
852
+ const rate = options.throttle_rate ?? 0;
853
+ this.throttleMs = Number.isFinite(rate) && rate > 0 ? rate : 0;
854
+ }
855
+ /**
856
+ * advertise() is a no-op: the foxglove adapter advertises a client channel
857
+ * lazily on the first publish() and reuses it afterwards, so explicit
858
+ * registration has no effect on the wire.
859
+ */
860
+ advertise() {
861
+ }
862
+ /**
863
+ * Release the client channel allocated by the lazy advertise on first publish().
864
+ * Callers that publish once then tear down (e.g. joint-control-spinners' StoreJointState
865
+ * burst) rely on this to drop the channel registration rather than leaking it for the
866
+ * life of the Ros connection.
867
+ */
868
+ unadvertise() {
869
+ this.ros.unpublishTopic(this.name);
870
+ }
871
+ subscribe(callback) {
872
+ if (this.wrappedCallbacks.has(callback)) {
873
+ return;
874
+ }
875
+ const messageSchema = this.messageSchema;
876
+ const deliver = messageSchema ? (msg) => callback(messageSchema.parse(msg)) : callback;
877
+ let wrappedCb;
878
+ if (this.throttleMs === 0) {
879
+ wrappedCb = deliver;
880
+ } else {
881
+ const windowMs = this.throttleMs;
882
+ let lastFiredAt = -Infinity;
883
+ wrappedCb = (msg) => {
884
+ const now = performance.now();
885
+ if (now - lastFiredAt < windowMs) return;
886
+ lastFiredAt = now;
887
+ deliver(msg);
888
+ };
889
+ }
890
+ this.wrappedCallbacks.set(callback, wrappedCb);
891
+ this.ros.subscribeTopic(this.name, this.messageType, wrappedCb);
892
+ }
893
+ unsubscribe(callback) {
894
+ if (callback) {
895
+ const wrapped = this.wrappedCallbacks.get(callback);
896
+ if (wrapped) {
897
+ this.ros.unsubscribeTopic(this.name, wrapped);
898
+ this.wrappedCallbacks.delete(callback);
899
+ }
900
+ return;
901
+ }
902
+ for (const wrapped of this.wrappedCallbacks.values()) {
903
+ this.ros.unsubscribeTopic(this.name, wrapped);
904
+ }
905
+ this.wrappedCallbacks.clear();
906
+ }
907
+ publish(message) {
908
+ this.ros.publishTopic(this.name, this.messageType, message);
909
+ }
910
+ };
911
+
912
+ // src/service.ts
913
+ var Service = class {
914
+ ros;
915
+ name;
916
+ serviceType;
917
+ responseSchema;
918
+ constructor(options) {
919
+ this.ros = options.ros;
920
+ this.name = options.name;
921
+ this.serviceType = options.serviceType;
922
+ this.responseSchema = options.responseSchema;
923
+ }
924
+ /**
925
+ * Call the service (roslib-compatible callback API).
926
+ */
927
+ callService(request, onSuccess, onError) {
928
+ const responseSchema = this.responseSchema;
929
+ const deliver = onSuccess && responseSchema ? (response) => onSuccess(responseSchema.parse(response)) : onSuccess;
930
+ this.ros.callService(this.name, this.serviceType, request).then((response) => deliver?.(response)).catch((err) => {
931
+ const msg = err instanceof Error ? err.message : String(err);
932
+ onError?.(msg);
933
+ });
934
+ }
935
+ };
936
+
937
+ // src/param.ts
938
+ var Param = class {
939
+ ros;
940
+ name;
941
+ constructor(options) {
942
+ this.ros = options.ros;
943
+ this.name = options.name;
944
+ }
945
+ get(callback) {
946
+ this.ros.getParam(this.name).then(callback).catch((err) => {
947
+ console.warn(`Param.get("${this.name}") failed:`, err);
948
+ callback(null);
949
+ });
950
+ }
951
+ set(value, onSuccess, onError) {
952
+ this.ros.setParam(this.name, value).then(() => onSuccess?.()).catch((err) => {
953
+ const msg = err instanceof Error ? err.message : String(err);
954
+ onError?.(msg);
955
+ });
956
+ }
957
+ };
958
+
959
+ // src/types.ts
960
+ var Vector3 = class {
961
+ x;
962
+ y;
963
+ z;
964
+ constructor(options) {
965
+ this.x = options?.x ?? 0;
966
+ this.y = options?.y ?? 0;
967
+ this.z = options?.z ?? 0;
968
+ }
969
+ };
970
+ var Quaternion = class {
971
+ x;
972
+ y;
973
+ z;
974
+ w;
975
+ constructor(options) {
976
+ this.x = options?.x ?? 0;
977
+ this.y = options?.y ?? 0;
978
+ this.z = options?.z ?? 0;
979
+ this.w = options?.w ?? 1;
980
+ }
981
+ };
982
+ var Transform = class _Transform {
983
+ translation;
984
+ rotation;
985
+ constructor(options) {
986
+ this.translation = options?.translation ?? new Vector3();
987
+ this.rotation = options?.rotation ?? new Quaternion();
988
+ }
989
+ static identity() {
990
+ return new _Transform();
991
+ }
992
+ /**
993
+ * Invert a rigid transform: if `this` is a→b, the result is b→a. For a unit
994
+ * quaternion the inverse rotation is its conjugate, and the inverse
995
+ * translation is that conjugate applied to the negated translation.
996
+ */
997
+ inverse() {
998
+ const r = this.rotation;
999
+ const invRotation = new Quaternion({ x: -r.x, y: -r.y, z: -r.z, w: r.w });
1000
+ const rotated = rotateVectorByQuaternion(this.translation, invRotation);
1001
+ return new _Transform({
1002
+ translation: new Vector3({ x: -rotated.x, y: -rotated.y, z: -rotated.z }),
1003
+ rotation: invRotation
1004
+ });
1005
+ }
1006
+ /**
1007
+ * Compose two rigid transforms: `this` (a→b) composed with `other` (b→c)
1008
+ * yields the transform from a→c.
1009
+ */
1010
+ multiply(other) {
1011
+ const rotated = rotateVectorByQuaternion(other.translation, this.rotation);
1012
+ const translation = new Vector3({
1013
+ x: this.translation.x + rotated.x,
1014
+ y: this.translation.y + rotated.y,
1015
+ z: this.translation.z + rotated.z
1016
+ });
1017
+ const rotation = multiplyQuaternions(this.rotation, other.rotation);
1018
+ return new _Transform({ translation, rotation });
1019
+ }
1020
+ };
1021
+ function multiplyQuaternions(a, b) {
1022
+ return new Quaternion({
1023
+ x: a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y,
1024
+ y: a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x,
1025
+ z: a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w,
1026
+ w: a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z
1027
+ });
1028
+ }
1029
+ function rotateVectorByQuaternion(v, q) {
1030
+ const { x, y, z: z3 } = v;
1031
+ const qx = q.x;
1032
+ const qy = q.y;
1033
+ const qz = q.z;
1034
+ const qw = q.w;
1035
+ const tx = 2 * (qy * z3 - qz * y);
1036
+ const ty = 2 * (qz * x - qx * z3);
1037
+ const tz = 2 * (qx * y - qy * x);
1038
+ return new Vector3({
1039
+ x: x + qw * tx + (qy * tz - qz * ty),
1040
+ y: y + qw * ty + (qz * tx - qx * tz),
1041
+ z: z3 + qw * tz + (qx * ty - qy * tx)
1042
+ });
1043
+ }
1044
+
1045
+ // src/tf-client.ts
1046
+ var tfMessageSchema = zod.z.object({
1047
+ transforms: zod.z.array(
1048
+ zod.z.object({
1049
+ header: zod.z.object({
1050
+ stamp: zod.z.object({ sec: zod.z.number(), nanosec: zod.z.number() }),
1051
+ frame_id: zod.z.string()
1052
+ }),
1053
+ child_frame_id: zod.z.string(),
1054
+ transform: zod.z.object({
1055
+ translation: zod.z.object({ x: zod.z.number(), y: zod.z.number(), z: zod.z.number() }),
1056
+ rotation: zod.z.object({
1057
+ x: zod.z.number(),
1058
+ y: zod.z.number(),
1059
+ z: zod.z.number(),
1060
+ w: zod.z.number()
1061
+ })
1062
+ })
1063
+ })
1064
+ )
1065
+ });
1066
+ function normalizeFrameId(frame) {
1067
+ return frame.replace(/^\/+/, "");
1068
+ }
1069
+ function normalizeQuaternion(q) {
1070
+ const norm = Math.hypot(q.x, q.y, q.z, q.w);
1071
+ if (norm === 0) {
1072
+ return new Quaternion({ x: 0, y: 0, z: 0, w: 1 });
1073
+ }
1074
+ return new Quaternion({ x: q.x / norm, y: q.y / norm, z: q.z / norm, w: q.w / norm });
1075
+ }
1076
+ function throttleLeadingEdge(fn, windowMs) {
1077
+ let lastFiredAt = -Infinity;
1078
+ return (msg) => {
1079
+ const now = performance.now();
1080
+ if (now - lastFiredAt < windowMs) return;
1081
+ lastFiredAt = now;
1082
+ fn(msg);
1083
+ };
1084
+ }
1085
+ var ROS2TFClient = class {
1086
+ ros;
1087
+ fixedFrame;
1088
+ frameCallbacks = /* @__PURE__ */ new Map();
1089
+ /** child_frame_id → {parent, local transform}. One entry per child. */
1090
+ directTransforms = /* @__PURE__ */ new Map();
1091
+ // Every frame id ever seen as a parent or child, for enumeration. Grows
1092
+ // monotonically for the life of the client (only dispose() clears it), which
1093
+ // assumes frame ids are stable for a connection — true for a fixed robot, but
1094
+ // a source that mints transient frame ids would accumulate stale entries.
1095
+ knownFrames = /* @__PURE__ */ new Set();
1096
+ framesListeners = /* @__PURE__ */ new Set();
1097
+ // Children for which a conflicting parent has already been reported, so the
1098
+ // multi-authority warning fires once per child rather than every tick.
1099
+ multiParentWarned = /* @__PURE__ */ new Set();
1100
+ tfSub = null;
1101
+ tfStaticSub = null;
1102
+ onReconnect;
1103
+ throttleMs;
1104
+ parseWarned = false;
1105
+ disposed = false;
1106
+ constructor(options) {
1107
+ this.ros = options.ros;
1108
+ this.fixedFrame = normalizeFrameId(options.fixedFrame ?? "world");
1109
+ const rate = options.rate ?? 0;
1110
+ this.throttleMs = Number.isFinite(rate) && rate > 0 ? 1e3 / rate : 0;
1111
+ this.onReconnect = () => this.subscribeTF();
1112
+ this.ros.on("connection", this.onReconnect);
1113
+ this.subscribeTF();
1114
+ }
1115
+ subscribe(frameId, callback) {
1116
+ if (this.warnIfDisposed("subscribe")) return;
1117
+ const key = normalizeFrameId(frameId);
1118
+ let callbacks = this.frameCallbacks.get(key);
1119
+ if (!callbacks) {
1120
+ callbacks = /* @__PURE__ */ new Set();
1121
+ this.frameCallbacks.set(key, callbacks);
1122
+ }
1123
+ callbacks.add(callback);
1124
+ const resolved = this.resolveTransform(key);
1125
+ if (resolved) {
1126
+ callback(resolved);
1127
+ }
1128
+ }
1129
+ unsubscribe(frameId, callback) {
1130
+ const key = normalizeFrameId(frameId);
1131
+ if (callback) {
1132
+ const callbacks = this.frameCallbacks.get(key);
1133
+ callbacks?.delete(callback);
1134
+ if (callbacks?.size === 0) {
1135
+ this.frameCallbacks.delete(key);
1136
+ }
1137
+ } else {
1138
+ this.frameCallbacks.delete(key);
1139
+ }
1140
+ }
1141
+ /**
1142
+ * Sorted list of every frame seen so far on /tf or /tf_static (as a parent or
1143
+ * child). MoveIt Pro extension used to drive the TF visualization frame list.
1144
+ */
1145
+ getFrameIds() {
1146
+ return [...this.knownFrames].sort((a, b) => a.localeCompare(b));
1147
+ }
1148
+ /**
1149
+ * Register a listener fired whenever new frames appear. Invoked immediately
1150
+ * with the current frame list so callers don't miss frames seen before they
1151
+ * subscribed. MoveIt Pro extension, not part of roslib's ROS2TFClient.
1152
+ */
1153
+ addFramesListener(callback) {
1154
+ if (this.warnIfDisposed("addFramesListener")) return;
1155
+ this.framesListeners.add(callback);
1156
+ callback(this.getFrameIds());
1157
+ }
1158
+ removeFramesListener(callback) {
1159
+ this.framesListeners.delete(callback);
1160
+ }
1161
+ dispose() {
1162
+ this.disposed = true;
1163
+ this.ros.off("connection", this.onReconnect);
1164
+ if (this.tfSub) {
1165
+ this.ros.unsubscribeTopic("/tf", this.tfSub);
1166
+ this.tfSub = null;
1167
+ }
1168
+ if (this.tfStaticSub) {
1169
+ this.ros.unsubscribeTopic("/tf_static", this.tfStaticSub);
1170
+ this.tfStaticSub = null;
1171
+ }
1172
+ this.frameCallbacks.clear();
1173
+ this.directTransforms.clear();
1174
+ this.knownFrames.clear();
1175
+ this.framesListeners.clear();
1176
+ this.multiParentWarned.clear();
1177
+ }
1178
+ /** Warn once if a mutating method is called on a disposed client. */
1179
+ warnIfDisposed(method) {
1180
+ if (this.disposed) {
1181
+ console.warn(`ROS2TFClient.${method}() called after dispose(); ignoring.`);
1182
+ }
1183
+ return this.disposed;
1184
+ }
1185
+ subscribeTF() {
1186
+ if (this.tfSub) this.ros.unsubscribeTopic("/tf", this.tfSub);
1187
+ if (this.tfStaticSub) this.ros.unsubscribeTopic("/tf_static", this.tfStaticSub);
1188
+ const dispatch = (msg) => this.dispatchTFMessage(msg);
1189
+ this.tfSub = this.throttleMs > 0 ? throttleLeadingEdge(dispatch, this.throttleMs) : dispatch;
1190
+ this.ros.subscribeTopic("/tf", "tf2_msgs/msg/TFMessage", this.tfSub);
1191
+ this.tfStaticSub = (msg) => this.dispatchTFMessage(msg);
1192
+ this.ros.subscribeTopic("/tf_static", "tf2_msgs/msg/TFMessage", this.tfStaticSub);
1193
+ }
1194
+ dispatchTFMessage(msg) {
1195
+ const parsed = tfMessageSchema.safeParse(msg);
1196
+ if (parsed.success) {
1197
+ this.handleTFMessage(parsed.data);
1198
+ } else if (!this.parseWarned) {
1199
+ this.parseWarned = true;
1200
+ console.warn("ROS2TFClient: ignoring /tf message that failed schema validation.");
1201
+ }
1202
+ }
1203
+ /**
1204
+ * Warn once per child when it is published with a parent different from the
1205
+ * one already stored. A frame with two live parents is a malformed tree (tf2
1206
+ * surfaces this as a multiple-authority warning). We keep last-writer-wins so
1207
+ * resolution still works, but a silent pick can resolve through a different
1208
+ * parent than tf2/RViz would, so make the misconfiguration visible.
1209
+ */
1210
+ warnIfReparented(childFrame, parentFrame) {
1211
+ const existing = this.directTransforms.get(childFrame);
1212
+ if (!existing || existing.parentFrame === parentFrame || this.multiParentWarned.has(childFrame)) {
1213
+ return;
1214
+ }
1215
+ this.multiParentWarned.add(childFrame);
1216
+ console.warn(
1217
+ `TF: frame "${childFrame}" is published with multiple parents ("${existing.parentFrame}" and "${parentFrame}"); using the most recent. This is a malformed TF tree and may render differently from RViz.`
1218
+ );
1219
+ }
1220
+ handleTFMessage(msg) {
1221
+ let anyChanged = false;
1222
+ const framesBefore = this.knownFrames.size;
1223
+ for (const tfStamped of msg.transforms) {
1224
+ const childFrame = normalizeFrameId(tfStamped.child_frame_id);
1225
+ const parentFrame = normalizeFrameId(tfStamped.header.frame_id);
1226
+ const t = tfStamped.transform;
1227
+ this.knownFrames.add(childFrame);
1228
+ this.knownFrames.add(parentFrame);
1229
+ const transform = new Transform({
1230
+ translation: new Vector3({
1231
+ x: t.translation.x,
1232
+ y: t.translation.y,
1233
+ z: t.translation.z
1234
+ }),
1235
+ // Normalize at the ingest boundary so downstream inverse/compose can
1236
+ // assume unit quaternions (see normalizeQuaternion).
1237
+ rotation: normalizeQuaternion(t.rotation)
1238
+ });
1239
+ this.warnIfReparented(childFrame, parentFrame);
1240
+ this.directTransforms.set(childFrame, { parentFrame, transform });
1241
+ anyChanged = true;
1242
+ }
1243
+ if (this.knownFrames.size !== framesBefore) {
1244
+ const frames = this.getFrameIds();
1245
+ for (const listener of this.framesListeners) {
1246
+ listener(frames);
1247
+ }
1248
+ }
1249
+ if (!anyChanged) return;
1250
+ for (const [frameId, callbacks] of this.frameCallbacks) {
1251
+ if (callbacks.size === 0) continue;
1252
+ const resolved = this.resolveTransform(frameId);
1253
+ if (resolved) {
1254
+ for (const cb of callbacks) {
1255
+ cb(resolved);
1256
+ }
1257
+ }
1258
+ }
1259
+ }
1260
+ /**
1261
+ * Resolve the transform from `fixedFrame` to `frameId`, i.e. the pose of
1262
+ * `frameId` expressed in `fixedFrame`.
1263
+ *
1264
+ * tf2 (and RViz) can relate any two connected frames, not just a frame and
1265
+ * its descendant: it walks both frames up to their lowest common ancestor and
1266
+ * inverts one side. We do the same. Walking only child→parent from `frameId`
1267
+ * to `fixedFrame` (the previous approach) silently failed for any frame that
1268
+ * is an ancestor of — or in a sibling branch to — `fixedFrame`, which is the
1269
+ * common case for mobile-base trees where the reference frame sits below
1270
+ * `map`/`odom`.
1271
+ *
1272
+ * Returns null if the two frames are not connected (yet) or the tree is
1273
+ * malformed (cycle).
1274
+ *
1275
+ * Time semantics: this composes each edge's most recent value. Unlike tf2 it
1276
+ * does not interpolate edges to a common timestamp, so during fast motion with
1277
+ * edges published at different rates the result can briefly lead or lag tf2's.
1278
+ * For a visualization layer this is an accepted simplification.
1279
+ */
1280
+ resolveTransform(frameId) {
1281
+ if (frameId === this.fixedFrame) {
1282
+ return Transform.identity();
1283
+ }
1284
+ const direct = this.directTransforms.get(frameId);
1285
+ if (direct !== void 0) {
1286
+ if (direct.parentFrame === this.fixedFrame) {
1287
+ return direct.transform;
1288
+ }
1289
+ }
1290
+ const targetAncestors = this.ancestorTransforms(frameId);
1291
+ if (!targetAncestors) return null;
1292
+ let current = this.fixedFrame;
1293
+ let tFixed = Transform.identity();
1294
+ const visited = /* @__PURE__ */ new Set();
1295
+ for (; ; ) {
1296
+ const tToTarget = targetAncestors.get(current);
1297
+ if (tToTarget) {
1298
+ return tFixed.inverse().multiply(tToTarget);
1299
+ }
1300
+ if (visited.has(current)) return null;
1301
+ visited.add(current);
1302
+ const direct2 = this.directTransforms.get(current);
1303
+ if (!direct2) return null;
1304
+ tFixed = direct2.transform.multiply(tFixed);
1305
+ current = direct2.parentFrame;
1306
+ }
1307
+ }
1308
+ /**
1309
+ * Walk from `frameId` up to its tree root, returning a map from every
1310
+ * ancestor frame (including `frameId`) to the transform expressing `frameId`
1311
+ * in that ancestor's frame. Returns null on a cycle.
1312
+ */
1313
+ ancestorTransforms(frameId) {
1314
+ const ancestors = /* @__PURE__ */ new Map();
1315
+ let current = frameId;
1316
+ let accumulated = Transform.identity();
1317
+ ancestors.set(current, accumulated);
1318
+ const visited = /* @__PURE__ */ new Set();
1319
+ for (; ; ) {
1320
+ if (visited.has(current)) return null;
1321
+ visited.add(current);
1322
+ const direct = this.directTransforms.get(current);
1323
+ if (!direct) return ancestors;
1324
+ accumulated = direct.transform.multiply(accumulated);
1325
+ current = direct.parentFrame;
1326
+ ancestors.set(current, accumulated);
1327
+ }
1328
+ }
1329
+ };
1330
+
1331
+ exports.Param = Param;
1332
+ exports.Quaternion = Quaternion;
1333
+ exports.ROS2TFClient = ROS2TFClient;
1334
+ exports.Ros = Ros;
1335
+ exports.Service = Service;
1336
+ exports.Topic = Topic;
1337
+ exports.Transform = Transform;
1338
+ exports.Vector3 = Vector3;
1339
+ //# sourceMappingURL=index.cjs.map
1340
+ //# sourceMappingURL=index.cjs.map