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/LICENSE +21 -0
- package/README.md +159 -0
- package/dist/index.cjs +1340 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +394 -0
- package/dist/index.d.ts +394 -0
- package/dist/index.js +1331 -0
- package/dist/index.js.map +1 -0
- package/package.json +78 -0
- package/src/index.ts +18 -0
- package/src/param.ts +56 -0
- package/src/protocol.ts +384 -0
- package/src/ros.ts +676 -0
- package/src/service.ts +64 -0
- package/src/tf-client.ts +438 -0
- package/src/topic.ts +148 -0
- package/src/types.ts +148 -0
- package/src/wire-schemas.ts +115 -0
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
|