ros-mobile-bridge 0.1.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,2288 @@
1
+ 'use strict';
2
+
3
+ var rosmsg = require('@foxglove/rosmsg');
4
+ var ros2idlParser = require('@foxglove/ros2idl-parser');
5
+ var rosmsg2Serialization = require('@foxglove/rosmsg2-serialization');
6
+
7
+ var __defProp = Object.defineProperty;
8
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
9
+ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
10
+
11
+ // src/CircuitBreaker.ts
12
+ var HEALTHY_DEBOUNCE_MS = 4e3;
13
+ var WARMUP_MS = 2e3;
14
+ var CircuitBreaker = class {
15
+ constructor(config) {
16
+ __publicField(this, "config", config);
17
+ __publicField(this, "state", "closed");
18
+ __publicField(this, "tripCount", 0);
19
+ __publicField(this, "saturationObservedAt", 0);
20
+ __publicField(this, "healthyObservedAt", 0);
21
+ __publicField(this, "cooldownTimer", null);
22
+ __publicField(this, "subscriptionStartedAt", Date.now());
23
+ __publicField(this, "nextRetryAt", null);
24
+ }
25
+ getState() {
26
+ return this.state;
27
+ }
28
+ /**
29
+ * `Date.now()` when auto-retry is scheduled to fire, or `null` if no
30
+ * cooldown is running (closed / half_open / tripped_manual).
31
+ */
32
+ getNextRetryAt() {
33
+ return this.nextRetryAt;
34
+ }
35
+ /**
36
+ * Single entry point for "I saw a message; here's the current bandwidth
37
+ * and JS-thread lag." Internally gates by `HEAVY_BANDWIDTH_THRESHOLD_BYTES_PER_SEC`
38
+ * and the breaker's `lagThresholdMs` so the trip policy lives in one place
39
+ * instead of being replicated at every call site.
40
+ */
41
+ recordObservation(now, bytesPerSec, lagMs) {
42
+ if (bytesPerSec > HEAVY_BANDWIDTH_THRESHOLD_BYTES_PER_SEC && lagMs > this.config.lagThresholdMs) {
43
+ this.recordOverloaded(now);
44
+ } else {
45
+ this.recordHealthy(now);
46
+ }
47
+ }
48
+ /**
49
+ * Called from the protocol client's `recordBytes` when the tracker is in
50
+ * the heavily-throttled range (top buckets) and lag exceeds the threshold.
51
+ */
52
+ recordOverloaded(now) {
53
+ if (now - this.subscriptionStartedAt < WARMUP_MS) return;
54
+ if (this.state === "closed" || this.state === "half_open") {
55
+ if (this.saturationObservedAt === 0) {
56
+ this.saturationObservedAt = now;
57
+ } else if (now - this.saturationObservedAt >= this.config.tripDwellMs) {
58
+ this.trip();
59
+ }
60
+ this.healthyObservedAt = 0;
61
+ }
62
+ }
63
+ /**
64
+ * Called from `recordBytes` when lag is below threshold or the tracker is
65
+ * below the heavily-throttled range.
66
+ */
67
+ recordHealthy(now) {
68
+ if (now - this.subscriptionStartedAt < WARMUP_MS) return;
69
+ if (this.state !== "closed" && this.state !== "half_open") return;
70
+ if (this.healthyObservedAt === 0) {
71
+ this.healthyObservedAt = now;
72
+ }
73
+ const healthyDwell = now - this.healthyObservedAt;
74
+ if (this.saturationObservedAt > 0 && healthyDwell >= HEALTHY_DEBOUNCE_MS) {
75
+ this.saturationObservedAt = 0;
76
+ }
77
+ if (this.state === "half_open" && healthyDwell >= this.config.recoveryDwellMs) {
78
+ this.close();
79
+ }
80
+ }
81
+ /**
82
+ * User pressed "try again now" — bypasses the cooldown timer and jumps
83
+ * straight to `half_open`. Works from any tripped state.
84
+ */
85
+ retry() {
86
+ if (this.state === "tripped_auto" || this.state === "tripped_manual") {
87
+ this.clearCooldown();
88
+ this.openHalf();
89
+ }
90
+ }
91
+ /**
92
+ * User pressed "stop trying" — switches `tripped_auto` to `tripped_manual`
93
+ * and cancels the auto-retry timer. The subscription remains paused until
94
+ * the user manually retries.
95
+ */
96
+ disable() {
97
+ if (this.state === "tripped_auto") {
98
+ this.clearCooldown();
99
+ this.transition("tripped_manual");
100
+ }
101
+ }
102
+ /**
103
+ * Called by the protocol client on disconnect or unsubscribe so the
104
+ * cooldown timer doesn't outlive the connection.
105
+ */
106
+ destroy() {
107
+ this.clearCooldown();
108
+ }
109
+ // ── Internal transitions ─────────────────────────────────────────────
110
+ trip() {
111
+ this.tripCount++;
112
+ this.saturationObservedAt = 0;
113
+ const cooldownIdx = Math.min(this.tripCount - 1, this.config.cooldownsMs.length - 1);
114
+ const cooldownMs = this.config.cooldownsMs[cooldownIdx] ?? 3e4;
115
+ this.nextRetryAt = Date.now() + cooldownMs;
116
+ this.transition("tripped_auto");
117
+ this.cooldownTimer = setTimeout(() => {
118
+ this.cooldownTimer = null;
119
+ this.nextRetryAt = null;
120
+ if (this.state === "tripped_auto") {
121
+ this.openHalf();
122
+ }
123
+ }, cooldownMs);
124
+ }
125
+ openHalf() {
126
+ const now = Date.now();
127
+ this.saturationObservedAt = 0;
128
+ this.healthyObservedAt = 0;
129
+ this.subscriptionStartedAt = now;
130
+ this.transition("half_open");
131
+ }
132
+ close() {
133
+ this.saturationObservedAt = 0;
134
+ this.transition("closed");
135
+ }
136
+ transition(next) {
137
+ const prev = this.state;
138
+ if (prev === next) return;
139
+ this.state = next;
140
+ try {
141
+ this.config.onStateChange(next, prev);
142
+ } catch {
143
+ }
144
+ }
145
+ clearCooldown() {
146
+ if (this.cooldownTimer !== null) {
147
+ clearTimeout(this.cooldownTimer);
148
+ this.cooldownTimer = null;
149
+ }
150
+ this.nextRetryAt = null;
151
+ }
152
+ };
153
+ var DEFAULT_BREAKER_CONFIG = {
154
+ lagThresholdMs: 250,
155
+ tripDwellMs: 5e3,
156
+ recoveryDwellMs: 1e4,
157
+ cooldownsMs: [3e4, 6e4, 12e4, 3e5]
158
+ };
159
+ var HEAVY_BANDWIDTH_THRESHOLD_BYTES_PER_SEC = 5e5;
160
+
161
+ // src/EventLoopMonitor.ts
162
+ var PROBE_INTERVAL_MS = 200;
163
+ var WINDOW_MS = 1e3;
164
+ var HISTORY_MAX = 600;
165
+ var started = false;
166
+ var lastTickAt = 0;
167
+ var samples = [];
168
+ var history = [];
169
+ var modeGetter = () => "auto";
170
+ function setModeGetter(fn) {
171
+ modeGetter = fn;
172
+ }
173
+ function startMonitor() {
174
+ if (started) return;
175
+ started = true;
176
+ lastTickAt = Date.now();
177
+ const handle = setInterval(() => {
178
+ const now = Date.now();
179
+ const elapsed = now - lastTickAt;
180
+ const lag = Math.max(0, elapsed - PROBE_INTERVAL_MS);
181
+ lastTickAt = now;
182
+ samples.push({ t: now, lag });
183
+ const cutoff = now - WINDOW_MS;
184
+ while (samples.length > 0) {
185
+ const head = samples[0];
186
+ if (!head || head.t >= cutoff) break;
187
+ samples.shift();
188
+ }
189
+ let mode = "auto";
190
+ try {
191
+ mode = modeGetter();
192
+ } catch {
193
+ }
194
+ history.push({ t: now, lag, mode });
195
+ if (history.length > HISTORY_MAX) {
196
+ history.shift();
197
+ }
198
+ }, PROBE_INTERVAL_MS);
199
+ const unref = handle?.unref;
200
+ if (typeof unref === "function") {
201
+ unref.call(handle);
202
+ }
203
+ }
204
+ function getMaxLagMs() {
205
+ if (!started) startMonitor();
206
+ let max = 0;
207
+ for (const s of samples) {
208
+ if (s.lag > max) max = s.lag;
209
+ }
210
+ return max;
211
+ }
212
+ function getLagStats() {
213
+ if (history.length === 0) return null;
214
+ const sorted = history.map((s) => s.lag).sort((a, b) => a - b);
215
+ const pick = (q) => {
216
+ const idx = Math.min(sorted.length - 1, Math.floor(sorted.length * q));
217
+ return sorted[idx] ?? 0;
218
+ };
219
+ const first = history[0];
220
+ const last = history[history.length - 1];
221
+ const span = first && last ? (last.t - first.t) / 1e3 : 0;
222
+ return {
223
+ count: history.length,
224
+ durationSec: Math.round(span),
225
+ p50: pick(0.5),
226
+ p90: pick(0.9),
227
+ p99: pick(0.99),
228
+ max: sorted[sorted.length - 1] ?? 0
229
+ };
230
+ }
231
+ function getLagHistoryCsv() {
232
+ const header = "timestamp_ms,lag_ms,mode";
233
+ const rows = history.map((s) => `${s.t},${s.lag},${s.mode}`);
234
+ return [header, ...rows].join("\n");
235
+ }
236
+ function clearLagHistory() {
237
+ history.length = 0;
238
+ }
239
+
240
+ // src/SubscriptionBandwidth.ts
241
+ var BANDWIDTH_WINDOW_MS = 1e3;
242
+ var TIGHTEN_DWELL_MS = 0;
243
+ var RELAX_DWELL_MS = 3e3;
244
+ var DEFAULT_PRESETS = {
245
+ performance: [{ threshold: 0, minIntervalMs: 0, label: "none" }],
246
+ auto: [
247
+ { threshold: 0, minIntervalMs: 0, label: "none" },
248
+ { threshold: 100, minIntervalMs: 100, label: "10 Hz" },
249
+ { threshold: 150, minIntervalMs: 200, label: "5 Hz" },
250
+ { threshold: 200, minIntervalMs: 1e3, label: "1 Hz" },
251
+ { threshold: 350, minIntervalMs: 2e3, label: "0.5 Hz" }
252
+ ],
253
+ efficient: [
254
+ { threshold: 0, minIntervalMs: 0, label: "none" },
255
+ { threshold: 80, minIntervalMs: 100, label: "10 Hz" },
256
+ { threshold: 130, minIntervalMs: 200, label: "5 Hz" },
257
+ { threshold: 200, minIntervalMs: 1e3, label: "1 Hz" },
258
+ { threshold: 350, minIntervalMs: 2e3, label: "0.5 Hz" }
259
+ ]
260
+ };
261
+ var THROTTLE_MODES = ["performance", "auto", "efficient"];
262
+ var INITIAL_BUCKET_PER_MODE = {
263
+ performance: 0,
264
+ auto: 2,
265
+ efficient: 3
266
+ };
267
+ function validatePreset(buckets, mode, logger) {
268
+ if (!buckets || buckets.length === 0) {
269
+ logger.warn(
270
+ `[ros-mobile-bridge] presetOverrides.${mode}: empty bucket array; using default preset for this mode`
271
+ );
272
+ return false;
273
+ }
274
+ const first = buckets[0];
275
+ if (!first || first.threshold !== 0) {
276
+ logger.warn(
277
+ `[ros-mobile-bridge] presetOverrides.${mode}: first bucket must have threshold === 0 (the "no throttle" base case); using default preset for this mode`
278
+ );
279
+ return false;
280
+ }
281
+ return true;
282
+ }
283
+ function buildEffectivePresets(overrides, logger) {
284
+ const merged = {
285
+ performance: DEFAULT_PRESETS.performance,
286
+ auto: DEFAULT_PRESETS.auto,
287
+ efficient: DEFAULT_PRESETS.efficient
288
+ };
289
+ if (!overrides) return merged;
290
+ for (const mode of THROTTLE_MODES) {
291
+ const override = overrides[mode];
292
+ if (override === void 0) continue;
293
+ if (validatePreset(override, mode, logger)) {
294
+ merged[mode] = override;
295
+ }
296
+ }
297
+ return merged;
298
+ }
299
+ function createBandwidthTracker(mode = "auto", presets = DEFAULT_PRESETS) {
300
+ const initialBucket = INITIAL_BUCKET_PER_MODE[mode] ?? 0;
301
+ const preset = presets[mode];
302
+ const safeInitial = Math.min(initialBucket, Math.max(0, preset.length - 1));
303
+ const initialIntervalMs = preset[safeInitial]?.minIntervalMs ?? 0;
304
+ return {
305
+ window: [],
306
+ bytesPerSec: 0,
307
+ currentBucket: safeInitial,
308
+ targetBucket: safeInitial,
309
+ targetObservedAt: 0,
310
+ adaptiveMinIntervalMs: initialIntervalMs,
311
+ presets
312
+ };
313
+ }
314
+ function recordBytes(tracker, now, byteSize, mode = "auto") {
315
+ tracker.window.push({ t: now, b: byteSize });
316
+ const cutoff = now - BANDWIDTH_WINDOW_MS;
317
+ while (tracker.window.length > 0) {
318
+ const head = tracker.window[0];
319
+ if (!head || head.t >= cutoff) break;
320
+ tracker.window.shift();
321
+ }
322
+ let total = 0;
323
+ for (const e of tracker.window) total += e.b;
324
+ tracker.bytesPerSec = total / (BANDWIDTH_WINDOW_MS / 1e3);
325
+ const buckets = tracker.presets[mode];
326
+ const lagMs = getMaxLagMs();
327
+ let targetBucket = 0;
328
+ for (let i = buckets.length - 1; i >= 1; i--) {
329
+ const bucket = buckets[i];
330
+ if (bucket && lagMs >= bucket.threshold) {
331
+ targetBucket = i;
332
+ break;
333
+ }
334
+ }
335
+ if (targetBucket !== tracker.targetBucket) {
336
+ tracker.targetBucket = targetBucket;
337
+ tracker.targetObservedAt = now;
338
+ return;
339
+ }
340
+ if (targetBucket === tracker.currentBucket) {
341
+ return;
342
+ }
343
+ const dwell = now - tracker.targetObservedAt;
344
+ const tighten = targetBucket > tracker.currentBucket;
345
+ const requiredDwell = tighten ? TIGHTEN_DWELL_MS : RELAX_DWELL_MS;
346
+ if (dwell < requiredDwell) return;
347
+ if (tighten) {
348
+ tracker.currentBucket = targetBucket;
349
+ } else {
350
+ tracker.currentBucket = Math.max(targetBucket, tracker.currentBucket - 1);
351
+ tracker.targetObservedAt = now;
352
+ }
353
+ tracker.adaptiveMinIntervalMs = buckets[tracker.currentBucket]?.minIntervalMs ?? 0;
354
+ }
355
+ function effectiveMinInterval(userMinIntervalMs, disableAdaptive, tracker) {
356
+ const user = userMinIntervalMs ?? 0;
357
+ if (disableAdaptive) return user;
358
+ return Math.max(user, tracker.adaptiveMinIntervalMs);
359
+ }
360
+ function setTrackerToDeepest(tracker, mode) {
361
+ const buckets = tracker.presets[mode];
362
+ const lastIdx = Math.max(0, buckets.length - 1);
363
+ tracker.currentBucket = lastIdx;
364
+ tracker.targetBucket = lastIdx;
365
+ tracker.targetObservedAt = 0;
366
+ tracker.adaptiveMinIntervalMs = buckets[lastIdx]?.minIntervalMs ?? 0;
367
+ }
368
+ function getTrackerBucketLabel(tracker, mode) {
369
+ const buckets = tracker.presets[mode];
370
+ return buckets[tracker.currentBucket]?.label ?? "none";
371
+ }
372
+ function bucketLabelForLag(mode, lagMs) {
373
+ const buckets = DEFAULT_PRESETS[mode];
374
+ let idx = 0;
375
+ for (let i = buckets.length - 1; i >= 1; i--) {
376
+ const bucket = buckets[i];
377
+ if (bucket && lagMs >= bucket.threshold) {
378
+ idx = i;
379
+ break;
380
+ }
381
+ }
382
+ return buckets[idx]?.label ?? "none";
383
+ }
384
+
385
+ // src/schemaToTemplate.ts
386
+ var PRIMITIVE_DEFAULTS = {
387
+ bool: false,
388
+ int8: 0,
389
+ uint8: 0,
390
+ int16: 0,
391
+ uint16: 0,
392
+ int32: 0,
393
+ uint32: 0,
394
+ int64: 0,
395
+ uint64: 0,
396
+ float32: 0,
397
+ float64: 0,
398
+ string: "",
399
+ wstring: "",
400
+ time: { sec: 0, nsec: 0 },
401
+ duration: { sec: 0, nsec: 0 },
402
+ "builtin_interfaces/Time": { sec: 0, nanosec: 0 },
403
+ "builtin_interfaces/Duration": { sec: 0, nanosec: 0 },
404
+ "builtin_interfaces/msg/Time": { sec: 0, nanosec: 0 },
405
+ "builtin_interfaces/msg/Duration": { sec: 0, nanosec: 0 }
406
+ };
407
+ function schemaToTemplate(definitions) {
408
+ if (!definitions.length) return {};
409
+ const typeMap = /* @__PURE__ */ new Map();
410
+ for (const def of definitions) {
411
+ if (def.name) {
412
+ typeMap.set(def.name, def);
413
+ }
414
+ }
415
+ const root = definitions[0];
416
+ if (!root) return {};
417
+ return buildObject(root, typeMap, 0);
418
+ }
419
+ function buildObject(def, typeMap, depth) {
420
+ if (depth > 10) return {};
421
+ const result = {};
422
+ for (const field of def.definitions) {
423
+ if (field.isConstant) continue;
424
+ const defaultVal = getFieldDefault(field.type, field.isComplex, typeMap, depth);
425
+ if (field.isArray) {
426
+ result[field.name] = [];
427
+ } else {
428
+ result[field.name] = defaultVal;
429
+ }
430
+ }
431
+ return result;
432
+ }
433
+ function getFieldDefault(typeName, isComplex, typeMap, depth) {
434
+ if (PRIMITIVE_DEFAULTS[typeName] !== void 0) {
435
+ const val = PRIMITIVE_DEFAULTS[typeName];
436
+ return typeof val === "object" ? JSON.parse(JSON.stringify(val)) : val;
437
+ }
438
+ if (isComplex) {
439
+ const subDef = typeMap.get(typeName);
440
+ if (subDef) {
441
+ return buildObject(subDef, typeMap, depth + 1);
442
+ }
443
+ for (const [key, def] of typeMap) {
444
+ if (key.endsWith(`/${typeName}`) || typeName.endsWith(`/${key}`)) {
445
+ return buildObject(def, typeMap, depth + 1);
446
+ }
447
+ }
448
+ }
449
+ return "";
450
+ }
451
+
452
+ // src/jsonSchemaToTemplate.ts
453
+ function jsonSchemaToTemplate(schema) {
454
+ if (!schema || typeof schema !== "object" || Array.isArray(schema)) return null;
455
+ const s = schema;
456
+ if ("default" in s) return s.default;
457
+ const type = s.type;
458
+ if (type === "object") {
459
+ const props = s.properties;
460
+ if (!props || typeof props !== "object" || Array.isArray(props)) return {};
461
+ const result = {};
462
+ for (const [key, sub] of Object.entries(props)) {
463
+ result[key] = jsonSchemaToTemplate(sub);
464
+ }
465
+ return result;
466
+ }
467
+ if (type === "array") {
468
+ return [];
469
+ }
470
+ if (type === "string") return "";
471
+ if (type === "number" || type === "integer") return 0;
472
+ if (type === "boolean") return false;
473
+ if (type === "null") return null;
474
+ if (Array.isArray(type)) {
475
+ const first = type.find((t) => t !== "null") ?? type[0];
476
+ return jsonSchemaToTemplate({ ...s, type: first });
477
+ }
478
+ return null;
479
+ }
480
+
481
+ // src/FoxgloveClient.ts
482
+ var NOOP_LOGGER = { log() {
483
+ }, warn() {
484
+ }, error() {
485
+ } };
486
+ var TEXT_ENCODER = new TextEncoder();
487
+ var TEXT_DECODER = new TextDecoder();
488
+ var SUBPROTOCOLS = ["foxglove.sdk.v1", "foxglove.websocket.v1"];
489
+ var PING_INTERVAL_MS = 5e3;
490
+ var PONG_TIMEOUT_MS = 1e4;
491
+ var MAX_RECONNECT_ATTEMPTS = 5;
492
+ var BASE_RECONNECT_DELAY_MS = 1e3;
493
+ var CONNECTION_TIMEOUT_MS = 1e4;
494
+ var ZERO_TWIST = {
495
+ linear: { x: 0, y: 0, z: 0 },
496
+ angular: { x: 0, y: 0, z: 0 }
497
+ };
498
+ var CMD_VEL_SCHEMA = "geometry_msgs/msg/Twist";
499
+ var _FoxgloveClient = class _FoxgloveClient {
500
+ constructor(options) {
501
+ __publicField(this, "onLatency");
502
+ __publicField(this, "logger");
503
+ __publicField(this, "getThrottleMode");
504
+ __publicField(this, "presets");
505
+ __publicField(this, "ws", null);
506
+ __publicField(this, "url", "");
507
+ __publicField(this, "status", "disconnected");
508
+ __publicField(this, "statusListeners", /* @__PURE__ */ new Set());
509
+ __publicField(this, "logListeners", /* @__PURE__ */ new Set());
510
+ __publicField(this, "topicsListeners", /* @__PURE__ */ new Set());
511
+ __publicField(this, "servicesListeners", /* @__PURE__ */ new Set());
512
+ // Channel state
513
+ __publicField(this, "channels", /* @__PURE__ */ new Map());
514
+ __publicField(this, "topicToChannelId", /* @__PURE__ */ new Map());
515
+ // Per-subscription state. Each callback keeps its own throttle clock so
516
+ // multi-subscriber topics with different `maxFrequency` settings stay
517
+ // isolated. Bandwidth tracking is per-subscription and feeds the adaptive
518
+ // throttle layered on top of per-callback `maxFrequency`.
519
+ __publicField(this, "nextSubscriptionId", 1);
520
+ __publicField(this, "subscriptions", /* @__PURE__ */ new Map());
521
+ __publicField(this, "topicToSubscriptionId", /* @__PURE__ */ new Map());
522
+ __publicField(this, "breakerListeners", /* @__PURE__ */ new Map());
523
+ // CDR message readers — keyed by subscriptionId, created from channel schema.
524
+ __publicField(this, "messageReaders", /* @__PURE__ */ new Map());
525
+ // Publish state — maps topic → client-advertised channelId.
526
+ __publicField(this, "nextClientChannelId", 1);
527
+ __publicField(this, "advertisedTopics", /* @__PURE__ */ new Map());
528
+ __publicField(this, "hasPublishedTwist", false);
529
+ __publicField(this, "controlOutbox", []);
530
+ __publicField(this, "controlFlushScheduled", false);
531
+ // Service calls
532
+ __publicField(this, "nextServiceCallId", 1);
533
+ __publicField(this, "pendingServiceCalls", /* @__PURE__ */ new Map());
534
+ __publicField(this, "availableServices", /* @__PURE__ */ new Map());
535
+ // Keep-alive
536
+ __publicField(this, "pingTimer", null);
537
+ __publicField(this, "pongTimer", null);
538
+ __publicField(this, "lastPingSentTime", 0);
539
+ // Reconnection
540
+ __publicField(this, "reconnectAttempts", 0);
541
+ __publicField(this, "reconnectTimer", null);
542
+ __publicField(this, "connectionTimeoutTimer", null);
543
+ __publicField(this, "intentionalDisconnect", false);
544
+ // Connection handshake
545
+ __publicField(this, "connectResolve", null);
546
+ __publicField(this, "connectReject", null);
547
+ __publicField(this, "serverInfoReceived", false);
548
+ this.onLatency = options?.onLatency;
549
+ this.logger = options?.logger ?? NOOP_LOGGER;
550
+ this.getThrottleMode = options?.getThrottleMode ?? (() => "auto");
551
+ this.presets = buildEffectivePresets(options?.presetOverrides, this.logger);
552
+ setModeGetter(this.getThrottleMode);
553
+ }
554
+ get isConnected() {
555
+ return this.status === "connected";
556
+ }
557
+ get reconnectAttempt() {
558
+ return this.reconnectAttempts;
559
+ }
560
+ get maxReconnectAttempts() {
561
+ return MAX_RECONNECT_ATTEMPTS;
562
+ }
563
+ async connect(url) {
564
+ if (this.status === "connecting" || this.status === "connected") {
565
+ return;
566
+ }
567
+ this.url = url.trim();
568
+ this.intentionalDisconnect = false;
569
+ this.reconnectAttempts = 0;
570
+ this.log(`Opening WebSocket to ${this.url}...`);
571
+ return this.performConnect();
572
+ }
573
+ async disconnect() {
574
+ this.intentionalDisconnect = true;
575
+ this.safePublishZeroTwist();
576
+ this.flushControlOutbox();
577
+ this.cleanup();
578
+ this.setStatus("disconnected");
579
+ }
580
+ async getAvailableTopics() {
581
+ const appTopics = new Set(this.advertisedTopics.keys());
582
+ return Array.from(this.channels.values()).map((ch) => ({
583
+ topic: ch.topic,
584
+ schemaName: ch.schemaName,
585
+ encoding: ch.encoding,
586
+ source: appTopics.has(ch.topic) ? "app" : "robot"
587
+ }));
588
+ }
589
+ getSchemaTemplate(schemaName) {
590
+ for (const ch of this.channels.values()) {
591
+ if (ch.schemaName !== schemaName || !ch.schema) continue;
592
+ const encoding = (ch.schemaEncoding ?? "").toLowerCase();
593
+ const schemaStr = ch.schema;
594
+ const looksLikeJsonSchema = schemaStr.trimStart().startsWith("{");
595
+ const tryRos2idl = () => {
596
+ try {
597
+ return schemaToTemplate(ros2idlParser.parseRos2idl(schemaStr));
598
+ } catch {
599
+ return null;
600
+ }
601
+ };
602
+ const tryRos2msg = () => {
603
+ try {
604
+ return schemaToTemplate(rosmsg.parse(schemaStr, { ros2: true }));
605
+ } catch {
606
+ return null;
607
+ }
608
+ };
609
+ const tryJsonSchema = () => {
610
+ try {
611
+ const parsed = JSON.parse(schemaStr);
612
+ const t = jsonSchemaToTemplate(parsed);
613
+ return t && typeof t === "object" && !Array.isArray(t) ? t : null;
614
+ } catch {
615
+ return null;
616
+ }
617
+ };
618
+ let order;
619
+ if (encoding === "ros2idl") {
620
+ order = [tryRos2idl, tryJsonSchema, tryRos2msg];
621
+ } else if (encoding === "jsonschema" || looksLikeJsonSchema) {
622
+ order = [tryJsonSchema, tryRos2idl, tryRos2msg];
623
+ } else {
624
+ order = [tryRos2msg, tryRos2idl, tryJsonSchema];
625
+ }
626
+ for (const attempt of order) {
627
+ const t = attempt();
628
+ if (t) return t;
629
+ }
630
+ this.log(
631
+ `Schema template parse failed for "${schemaName}" \u2014 all parsers rejected the schema.`
632
+ );
633
+ break;
634
+ }
635
+ return null;
636
+ }
637
+ subscribe(topic, onMessage, options) {
638
+ const userMinIntervalMs = options?.maxFrequency && options.maxFrequency > 0 ? 1e3 / options.maxFrequency : void 0;
639
+ const disableAdaptive = options?.disableAdaptive ?? false;
640
+ const existingSubId = this.topicToSubscriptionId.get(topic);
641
+ if (existingSubId !== void 0) {
642
+ const sub = this.subscriptions.get(existingSubId);
643
+ if (sub) {
644
+ sub.callbacks.set(onMessage, {
645
+ userMinIntervalMs,
646
+ disableAdaptive,
647
+ lastDeliveredAt: 0
648
+ });
649
+ return () => {
650
+ sub.callbacks.delete(onMessage);
651
+ if (sub.callbacks.size === 0) {
652
+ this.unsubscribeTopic(topic, existingSubId);
653
+ }
654
+ };
655
+ }
656
+ }
657
+ const channelId = this.topicToChannelId.get(topic);
658
+ if (channelId === void 0) {
659
+ this.logger.warn(
660
+ `[FoxgloveClient] Topic "${topic}" not available. Available: ${Array.from(this.topicToChannelId.keys()).join(", ")}`
661
+ );
662
+ return () => {
663
+ };
664
+ }
665
+ const subscriptionId = this.nextSubscriptionId++;
666
+ const callbacks = /* @__PURE__ */ new Map();
667
+ callbacks.set(onMessage, {
668
+ userMinIntervalMs,
669
+ disableAdaptive,
670
+ lastDeliveredAt: 0
671
+ });
672
+ const breaker = new CircuitBreaker({
673
+ ...DEFAULT_BREAKER_CONFIG,
674
+ onStateChange: (newState) => {
675
+ const sub = this.subscriptions.get(subscriptionId);
676
+ if (!sub) return;
677
+ sub.isPaused = newState === "tripped_auto" || newState === "tripped_manual";
678
+ if (newState === "tripped_auto") {
679
+ if (this.ws && this.status === "connected") {
680
+ this.sendJson({ op: "unsubscribe", subscriptionIds: [subscriptionId] });
681
+ }
682
+ this.log(`[breaker] ${topic} \u2192 tripped_auto (sustained overload)`);
683
+ } else if (newState === "half_open") {
684
+ setTrackerToDeepest(sub.bandwidth, this.getThrottleMode());
685
+ if (this.ws && this.status === "connected") {
686
+ this.sendJson({
687
+ op: "subscribe",
688
+ subscriptions: [{ id: subscriptionId, channelId: sub.channelId }]
689
+ });
690
+ }
691
+ this.log(`[breaker] ${topic} \u2192 half_open (re-subscribed)`);
692
+ } else if (newState === "closed") {
693
+ this.log(`[breaker] ${topic} \u2192 closed (recovered)`);
694
+ } else if (newState === "tripped_manual") {
695
+ this.log(`[breaker] ${topic} \u2192 tripped_manual (user disabled auto-retry)`);
696
+ }
697
+ const listeners = this.breakerListeners.get(topic);
698
+ if (listeners) {
699
+ for (const cb of listeners) {
700
+ try {
701
+ cb(newState);
702
+ } catch (err) {
703
+ this.logger.error("[FoxgloveClient] Breaker listener error:", err);
704
+ }
705
+ }
706
+ }
707
+ }
708
+ });
709
+ this.subscriptions.set(subscriptionId, {
710
+ topic,
711
+ channelId,
712
+ callbacks,
713
+ bandwidth: createBandwidthTracker(this.getThrottleMode(), this.presets),
714
+ breaker,
715
+ isPaused: false
716
+ });
717
+ this.topicToSubscriptionId.set(topic, subscriptionId);
718
+ const channel = this.channels.get(channelId);
719
+ if (channel && channel.schema && channel.encoding !== "json") {
720
+ const schemaEncoding = channel.schemaEncoding ?? "";
721
+ this.log(
722
+ `Creating CDR reader for "${topic}" (encoding=${channel.encoding}, schemaEncoding=${schemaEncoding})`
723
+ );
724
+ try {
725
+ let msgDefs;
726
+ if (schemaEncoding === "ros2idl") {
727
+ msgDefs = ros2idlParser.parseRos2idl(channel.schema);
728
+ } else {
729
+ msgDefs = rosmsg.parse(channel.schema, { ros2: true });
730
+ }
731
+ this.messageReaders.set(subscriptionId, new rosmsg2Serialization.MessageReader(msgDefs));
732
+ this.log(` CDR reader created successfully for "${topic}"`);
733
+ } catch (err) {
734
+ this.log(` CDR reader FAILED for "${topic}": ${String(err)}`);
735
+ this.log(` Schema preview: ${channel.schema.substring(0, 200)}`);
736
+ }
737
+ }
738
+ this.sendJson({
739
+ op: "subscribe",
740
+ subscriptions: [{ id: subscriptionId, channelId }]
741
+ });
742
+ return () => {
743
+ callbacks.delete(onMessage);
744
+ if (callbacks.size === 0) {
745
+ this.unsubscribeTopic(topic, subscriptionId);
746
+ }
747
+ };
748
+ }
749
+ publish(topic, schemaName, data, options) {
750
+ if (!this.ws || this.status !== "connected") {
751
+ return;
752
+ }
753
+ if (schemaName === CMD_VEL_SCHEMA) {
754
+ this.hasPublishedTwist = true;
755
+ }
756
+ let clientChannelId = this.advertisedTopics.get(topic);
757
+ if (clientChannelId === void 0) {
758
+ clientChannelId = this.nextClientChannelId++;
759
+ this.advertisedTopics.set(topic, clientChannelId);
760
+ this.sendJson({
761
+ op: "advertise",
762
+ channels: [
763
+ {
764
+ id: clientChannelId,
765
+ topic,
766
+ encoding: "json",
767
+ schemaName
768
+ }
769
+ ]
770
+ });
771
+ const chId = clientChannelId;
772
+ setTimeout(() => {
773
+ this.sendBinaryMessage(chId, data);
774
+ }, 150);
775
+ return;
776
+ }
777
+ if (options?.priority === "control") {
778
+ this.controlOutbox.push({ channelId: clientChannelId, data });
779
+ this.scheduleControlFlush();
780
+ return;
781
+ }
782
+ this.sendBinaryMessage(clientChannelId, data);
783
+ }
784
+ scheduleControlFlush() {
785
+ if (this.controlFlushScheduled) return;
786
+ this.controlFlushScheduled = true;
787
+ setTimeout(() => {
788
+ this.controlFlushScheduled = false;
789
+ this.flushControlOutbox();
790
+ }, 0);
791
+ }
792
+ flushControlOutbox() {
793
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
794
+ this.controlOutbox.length = 0;
795
+ return;
796
+ }
797
+ let drained = 0;
798
+ while (this.controlOutbox.length > 0 && drained < _FoxgloveClient.CONTROL_FLUSH_BATCH) {
799
+ const entry = this.controlOutbox.shift();
800
+ if (!entry) break;
801
+ this.sendBinaryMessage(entry.channelId, entry.data);
802
+ drained++;
803
+ }
804
+ if (this.controlOutbox.length > 0) {
805
+ this.scheduleControlFlush();
806
+ }
807
+ }
808
+ ensureAdvertised(topic, schemaName) {
809
+ if (!this.ws || this.status !== "connected") return;
810
+ if (this.advertisedTopics.has(topic)) return;
811
+ const clientChannelId = this.nextClientChannelId++;
812
+ this.advertisedTopics.set(topic, clientChannelId);
813
+ this.sendJson({
814
+ op: "advertise",
815
+ channels: [
816
+ {
817
+ id: clientChannelId,
818
+ topic,
819
+ encoding: "json",
820
+ schemaName
821
+ }
822
+ ]
823
+ });
824
+ }
825
+ unadvertise(topic) {
826
+ const clientChannelId = this.advertisedTopics.get(topic);
827
+ if (clientChannelId === void 0) return;
828
+ this.advertisedTopics.delete(topic);
829
+ if (this.ws && this.status === "connected") {
830
+ this.sendJson({
831
+ op: "unadvertise",
832
+ channelIds: [clientChannelId]
833
+ });
834
+ }
835
+ }
836
+ sendBinaryMessage(channelId, data) {
837
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
838
+ const jsonPayload = JSON.stringify(data);
839
+ const payloadBytes = TEXT_ENCODER.encode(jsonPayload);
840
+ const buffer = new ArrayBuffer(1 + 4 + payloadBytes.byteLength);
841
+ const view = new DataView(buffer);
842
+ view.setUint8(0, 1 /* MESSAGE_DATA */);
843
+ view.setUint32(1, channelId, true);
844
+ new Uint8Array(buffer, 5).set(payloadBytes);
845
+ this.ws.send(buffer);
846
+ }
847
+ async callService(service, request) {
848
+ if (!this.ws || this.status !== "connected") {
849
+ throw new Error("Not connected");
850
+ }
851
+ const serviceInfo = this.availableServices.get(service);
852
+ if (!serviceInfo) {
853
+ throw new Error(`Service "${service}" not available`);
854
+ }
855
+ const callId = this.nextServiceCallId++;
856
+ return new Promise((resolve, reject) => {
857
+ const timer = setTimeout(() => {
858
+ this.pendingServiceCalls.delete(callId);
859
+ reject(new Error(`Service call "${service}" timed out after 30s`));
860
+ }, 3e4);
861
+ this.pendingServiceCalls.set(callId, { resolve, reject, timer });
862
+ const jsonData = JSON.stringify(request);
863
+ const encoded = btoa(jsonData);
864
+ this.sendJson({
865
+ op: "serviceCallRequest",
866
+ serviceId: serviceInfo.id,
867
+ callId,
868
+ encoding: "json",
869
+ data: encoded
870
+ });
871
+ });
872
+ }
873
+ onStatusChange(cb) {
874
+ this.statusListeners.add(cb);
875
+ cb(this.status);
876
+ return () => {
877
+ this.statusListeners.delete(cb);
878
+ };
879
+ }
880
+ onTopicsChange(cb) {
881
+ this.topicsListeners.add(cb);
882
+ return () => {
883
+ this.topicsListeners.delete(cb);
884
+ };
885
+ }
886
+ getAvailableServices() {
887
+ return Array.from(this.availableServices.values()).map((s) => ({
888
+ name: s.name,
889
+ type: s.type
890
+ }));
891
+ }
892
+ onServicesChange(cb) {
893
+ this.servicesListeners.add(cb);
894
+ cb(this.getAvailableServices());
895
+ return () => {
896
+ this.servicesListeners.delete(cb);
897
+ };
898
+ }
899
+ onLog(cb) {
900
+ this.logListeners.add(cb);
901
+ return () => {
902
+ this.logListeners.delete(cb);
903
+ };
904
+ }
905
+ publishZeroTwist() {
906
+ this.safePublishZeroTwist();
907
+ }
908
+ // ── Private: connection lifecycle ────────────────────────────────────────
909
+ performConnect() {
910
+ this.cleanup();
911
+ this.setStatus("connecting");
912
+ return new Promise((resolve, reject) => {
913
+ this.connectResolve = resolve;
914
+ this.connectReject = reject;
915
+ try {
916
+ this.ws = new WebSocket(this.url, SUBPROTOCOLS);
917
+ this.ws.binaryType = "arraybuffer";
918
+ this.ws.onopen = () => {
919
+ const negotiated = this.ws?.protocol ?? "unknown";
920
+ this.log(
921
+ `WebSocket handshake successful (protocol: ${negotiated}), waiting for serverInfo...`
922
+ );
923
+ this.clearConnectionTimeout();
924
+ };
925
+ this.ws.onmessage = (event) => {
926
+ this.handleWsMessage(event);
927
+ };
928
+ this.ws.onerror = (event) => {
929
+ const detail = event.message ?? "Handshake failed or connection rejected by server";
930
+ this.log(`WebSocket error: ${detail}`);
931
+ this.logger.error("[FoxgloveClient] WebSocket error:", event);
932
+ this.handleConnectionError(new Error(`WebSocket error: ${detail}`));
933
+ };
934
+ this.ws.onclose = (event) => {
935
+ this.log(`WebSocket closed: code=${event.code}, reason=${event.reason || "none"}`);
936
+ this.handleClose(event.code ?? 1e3, event.reason ?? "");
937
+ };
938
+ this.connectionTimeoutTimer = setTimeout(() => {
939
+ this.handleConnectionError(
940
+ new Error(`Connection timeout after ${CONNECTION_TIMEOUT_MS}ms`)
941
+ );
942
+ }, CONNECTION_TIMEOUT_MS);
943
+ } catch (err) {
944
+ this.handleConnectionError(err instanceof Error ? err : new Error(String(err)));
945
+ }
946
+ });
947
+ }
948
+ // ── Private: WebSocket message handling ──────────────────────────────────
949
+ handleWsMessage(event) {
950
+ if (this.controlOutbox.length > 0) {
951
+ this.flushControlOutbox();
952
+ }
953
+ if (typeof event.data === "string") {
954
+ this.handleJsonMessage(event.data);
955
+ } else if (event.data instanceof ArrayBuffer) {
956
+ this.handleBinaryMessage(event.data);
957
+ }
958
+ }
959
+ handleJsonMessage(raw) {
960
+ let msg;
961
+ try {
962
+ msg = JSON.parse(raw);
963
+ } catch {
964
+ this.logger.warn("[FoxgloveClient] Failed to parse JSON message");
965
+ return;
966
+ }
967
+ switch (msg.op) {
968
+ case "serverInfo":
969
+ this.handleServerInfo(msg);
970
+ break;
971
+ case "advertise":
972
+ this.handleAdvertise(msg);
973
+ break;
974
+ case "unadvertise":
975
+ this.handleUnadvertise(msg);
976
+ break;
977
+ case "advertiseServices":
978
+ this.handleAdvertiseServices(msg);
979
+ break;
980
+ case "serviceCallResponse":
981
+ this.handleServiceCallResponse(msg);
982
+ break;
983
+ case "pong":
984
+ this.handlePong();
985
+ break;
986
+ case "schemas":
987
+ break;
988
+ case "status":
989
+ if (msg.level === 2) {
990
+ this.logger.error(
991
+ "[FoxgloveClient] Server error:",
992
+ msg.message
993
+ );
994
+ }
995
+ break;
996
+ }
997
+ }
998
+ handleBinaryMessage(buffer) {
999
+ if (buffer.byteLength < 5) return;
1000
+ const view = new DataView(buffer);
1001
+ const opcode = view.getUint8(0);
1002
+ if (opcode !== 1 /* MESSAGE_DATA */) return;
1003
+ const subscriptionId = view.getUint32(1, true);
1004
+ const timestampLow = view.getUint32(5, true);
1005
+ const timestampHigh = view.getUint32(9, true);
1006
+ const timestampNs = timestampHigh * 4294967296 + timestampLow;
1007
+ const sec = Math.floor(timestampNs / 1e9);
1008
+ const nsec = timestampNs % 1e9;
1009
+ const payloadOffset = 13;
1010
+ const payload = buffer.slice(payloadOffset);
1011
+ const sub = this.subscriptions.get(subscriptionId);
1012
+ if (!sub) return;
1013
+ if (sub.isPaused) return;
1014
+ const now = Date.now();
1015
+ const mode = this.getThrottleMode();
1016
+ recordBytes(sub.bandwidth, now, buffer.byteLength, mode);
1017
+ sub.breaker.recordObservation(now, sub.bandwidth.bytesPerSec, getMaxLagMs());
1018
+ const deliverTo = [];
1019
+ for (const [cb, entry] of sub.callbacks) {
1020
+ const interval = effectiveMinInterval(
1021
+ entry.userMinIntervalMs,
1022
+ entry.disableAdaptive,
1023
+ sub.bandwidth
1024
+ );
1025
+ if (interval <= 0 || now - entry.lastDeliveredAt >= interval) {
1026
+ deliverTo.push([cb, entry]);
1027
+ }
1028
+ }
1029
+ if (deliverTo.length === 0) return;
1030
+ const channelInfo = this.channels.get(sub.channelId);
1031
+ const schemaName = channelInfo?.schemaName ?? "";
1032
+ const encoding = channelInfo?.encoding ?? "json";
1033
+ let data;
1034
+ if (encoding === "json") {
1035
+ try {
1036
+ const text = TEXT_DECODER.decode(payload);
1037
+ data = JSON.parse(text);
1038
+ } catch {
1039
+ data = new Uint8Array(payload);
1040
+ }
1041
+ } else {
1042
+ const reader = this.messageReaders.get(subscriptionId);
1043
+ if (reader) {
1044
+ try {
1045
+ data = reader.readMessage(new Uint8Array(payload));
1046
+ } catch {
1047
+ data = new Uint8Array(payload);
1048
+ }
1049
+ } else {
1050
+ data = new Uint8Array(payload);
1051
+ }
1052
+ }
1053
+ const rosMsg = {
1054
+ topic: sub.topic,
1055
+ schemaName,
1056
+ encoding: encoding === "json" ? "json" : "cdr",
1057
+ data,
1058
+ receiveTime: { sec, nsec },
1059
+ byteSize: payload.byteLength
1060
+ };
1061
+ for (const [cb, entry] of deliverTo) {
1062
+ entry.lastDeliveredAt = now;
1063
+ try {
1064
+ cb(rosMsg);
1065
+ } catch (err) {
1066
+ this.logger.error("[FoxgloveClient] Subscriber callback error:", err);
1067
+ }
1068
+ }
1069
+ }
1070
+ handleServerInfo(info) {
1071
+ this.log(`Received serverInfo: ${info.name} (sessionId: ${info.sessionId ?? "none"})`);
1072
+ this.serverInfoReceived = true;
1073
+ }
1074
+ handleAdvertise(msg) {
1075
+ for (const ch of msg.channels) {
1076
+ this.channels.set(ch.id, ch);
1077
+ this.topicToChannelId.set(ch.topic, ch.id);
1078
+ this.log(
1079
+ ` Channel: ${ch.topic} [${ch.schemaName}] encoding=${ch.encoding} schemaEncoding=${ch.schemaEncoding ?? "none"}`
1080
+ );
1081
+ }
1082
+ if (this.connectResolve && this.serverInfoReceived) {
1083
+ this.log(`Connection established with ${msg.channels.length} initial topics.`);
1084
+ this.clearConnectionTimeout();
1085
+ this.reconnectAttempts = 0;
1086
+ this.setStatus("connected");
1087
+ this.startPingLoop();
1088
+ this.connectResolve();
1089
+ this.connectResolve = null;
1090
+ this.connectReject = null;
1091
+ } else {
1092
+ this.notifyTopicsChanged();
1093
+ }
1094
+ }
1095
+ handleUnadvertise(msg) {
1096
+ for (const id of msg.channelIds) {
1097
+ const ch = this.channels.get(id);
1098
+ if (ch) {
1099
+ this.topicToChannelId.delete(ch.topic);
1100
+ }
1101
+ this.channels.delete(id);
1102
+ }
1103
+ this.notifyTopicsChanged();
1104
+ }
1105
+ handleAdvertiseServices(msg) {
1106
+ for (const svc of msg.services) {
1107
+ this.availableServices.set(svc.name, svc);
1108
+ }
1109
+ this.notifyServicesChanged();
1110
+ }
1111
+ notifyServicesChanged() {
1112
+ const snapshot = this.getAvailableServices();
1113
+ for (const cb of this.servicesListeners) {
1114
+ try {
1115
+ cb(snapshot);
1116
+ } catch (err) {
1117
+ this.logger.error("[FoxgloveClient] Services listener error:", err);
1118
+ }
1119
+ }
1120
+ }
1121
+ handleServiceCallResponse(msg) {
1122
+ const pending = this.pendingServiceCalls.get(msg.callId);
1123
+ if (!pending) return;
1124
+ clearTimeout(pending.timer);
1125
+ this.pendingServiceCalls.delete(msg.callId);
1126
+ try {
1127
+ if (msg.encoding === "json" && msg.data) {
1128
+ const decoded = atob(msg.data);
1129
+ const parsed = JSON.parse(decoded);
1130
+ pending.resolve(parsed);
1131
+ } else {
1132
+ pending.resolve({ success: true });
1133
+ }
1134
+ } catch (err) {
1135
+ pending.reject(
1136
+ new Error(
1137
+ `Failed to parse service response: ${err instanceof Error ? err.message : String(err)}`
1138
+ )
1139
+ );
1140
+ }
1141
+ }
1142
+ // ── Private: keep-alive ──────────────────────────────────────────────────
1143
+ startPingLoop() {
1144
+ this.stopPingLoop();
1145
+ this.pingTimer = setInterval(() => {
1146
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
1147
+ if (this.pongTimer) {
1148
+ clearTimeout(this.pongTimer);
1149
+ this.pongTimer = null;
1150
+ }
1151
+ this.lastPingSentTime = Date.now();
1152
+ this.sendJson({ op: "ping" });
1153
+ this.pongTimer = setTimeout(() => {
1154
+ this.logger.warn("[FoxgloveClient] Pong timeout \u2014 reconnecting");
1155
+ this.handleClose(4e3, "Pong timeout");
1156
+ }, PONG_TIMEOUT_MS);
1157
+ }
1158
+ }, PING_INTERVAL_MS);
1159
+ }
1160
+ stopPingLoop() {
1161
+ if (this.pingTimer) {
1162
+ clearInterval(this.pingTimer);
1163
+ this.pingTimer = null;
1164
+ }
1165
+ if (this.pongTimer) {
1166
+ clearTimeout(this.pongTimer);
1167
+ this.pongTimer = null;
1168
+ }
1169
+ }
1170
+ handlePong() {
1171
+ if (this.pongTimer) {
1172
+ clearTimeout(this.pongTimer);
1173
+ this.pongTimer = null;
1174
+ }
1175
+ if (this.onLatency && this.lastPingSentTime > 0) {
1176
+ try {
1177
+ this.onLatency(Date.now() - this.lastPingSentTime);
1178
+ } catch {
1179
+ }
1180
+ }
1181
+ }
1182
+ // ── Private: reconnection ────────────────────────────────────────────────
1183
+ handleConnectionError(error) {
1184
+ this.clearConnectionTimeout();
1185
+ if (this.connectReject) {
1186
+ this.connectReject(error);
1187
+ this.connectResolve = null;
1188
+ this.connectReject = null;
1189
+ }
1190
+ this.setStatus("error");
1191
+ this.cleanup();
1192
+ this.scheduleReconnect();
1193
+ }
1194
+ handleClose(_code, _reason) {
1195
+ const wasConnected = this.status === "connected";
1196
+ if (wasConnected && this.hasPublishedTwist && !this.intentionalDisconnect) {
1197
+ this.safePublishZeroTwist();
1198
+ }
1199
+ this.cleanup();
1200
+ this.setStatus("disconnected");
1201
+ if (wasConnected && !this.intentionalDisconnect) {
1202
+ this.scheduleReconnect();
1203
+ }
1204
+ }
1205
+ scheduleReconnect() {
1206
+ if (this.intentionalDisconnect || this.reconnectTimer !== null || this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
1207
+ if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
1208
+ this.logger.warn(
1209
+ `[FoxgloveClient] Max reconnect attempts (${MAX_RECONNECT_ATTEMPTS}) reached`
1210
+ );
1211
+ this.setStatus("error");
1212
+ }
1213
+ return;
1214
+ }
1215
+ const delay = Math.min(
1216
+ BASE_RECONNECT_DELAY_MS * Math.pow(2, this.reconnectAttempts),
1217
+ 16e3
1218
+ );
1219
+ this.log(
1220
+ `Scheduling reconnect attempt ${this.reconnectAttempts + 1}/${MAX_RECONNECT_ATTEMPTS} in ${delay}ms...`
1221
+ );
1222
+ this.reconnectAttempts++;
1223
+ this.reconnectTimer = setTimeout(() => {
1224
+ this.reconnectTimer = null;
1225
+ this.performConnect().catch((err) => {
1226
+ this.logger.error("[FoxgloveClient] Reconnect failed:", err);
1227
+ });
1228
+ }, delay);
1229
+ }
1230
+ // ── Private: unsubscribe ─────────────────────────────────────────────────
1231
+ unsubscribeTopic(topic, subscriptionId) {
1232
+ const sub = this.subscriptions.get(subscriptionId);
1233
+ sub?.breaker.destroy();
1234
+ this.subscriptions.delete(subscriptionId);
1235
+ this.topicToSubscriptionId.delete(topic);
1236
+ this.messageReaders.delete(subscriptionId);
1237
+ if (this.ws && this.status === "connected" && !sub?.isPaused) {
1238
+ this.sendJson({
1239
+ op: "unsubscribe",
1240
+ subscriptionIds: [subscriptionId]
1241
+ });
1242
+ }
1243
+ }
1244
+ // ── IProtocolClient: circuit breaker controls ─────────────────────────────
1245
+ getBreakerState(topic) {
1246
+ const subId = this.topicToSubscriptionId.get(topic);
1247
+ if (subId === void 0) return "closed";
1248
+ const sub = this.subscriptions.get(subId);
1249
+ return sub?.breaker.getState() ?? "closed";
1250
+ }
1251
+ getBreakerNextRetryAt(topic) {
1252
+ const subId = this.topicToSubscriptionId.get(topic);
1253
+ if (subId === void 0) return null;
1254
+ return this.subscriptions.get(subId)?.breaker.getNextRetryAt() ?? null;
1255
+ }
1256
+ getSubscriptionStats(topic) {
1257
+ const subId = this.topicToSubscriptionId.get(topic);
1258
+ if (subId === void 0) return null;
1259
+ const sub = this.subscriptions.get(subId);
1260
+ if (!sub) return null;
1261
+ const mode = this.getThrottleMode();
1262
+ return {
1263
+ adaptiveMinIntervalMs: sub.bandwidth.adaptiveMinIntervalMs,
1264
+ bucketLabel: getTrackerBucketLabel(sub.bandwidth, mode),
1265
+ bytesPerSec: sub.bandwidth.bytesPerSec
1266
+ };
1267
+ }
1268
+ breakerRetry(topic) {
1269
+ const subId = this.topicToSubscriptionId.get(topic);
1270
+ if (subId === void 0) return;
1271
+ this.subscriptions.get(subId)?.breaker.retry();
1272
+ }
1273
+ breakerDisable(topic) {
1274
+ const subId = this.topicToSubscriptionId.get(topic);
1275
+ if (subId === void 0) return;
1276
+ this.subscriptions.get(subId)?.breaker.disable();
1277
+ }
1278
+ onBreakerStateChange(topic, cb) {
1279
+ let listeners = this.breakerListeners.get(topic);
1280
+ if (!listeners) {
1281
+ listeners = /* @__PURE__ */ new Set();
1282
+ this.breakerListeners.set(topic, listeners);
1283
+ }
1284
+ listeners.add(cb);
1285
+ return () => {
1286
+ const set = this.breakerListeners.get(topic);
1287
+ if (set) {
1288
+ set.delete(cb);
1289
+ if (set.size === 0) this.breakerListeners.delete(topic);
1290
+ }
1291
+ };
1292
+ }
1293
+ // ── Private: dead-man's switch ───────────────────────────────────────────
1294
+ safePublishZeroTwist() {
1295
+ if (!this.hasPublishedTwist) return;
1296
+ try {
1297
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
1298
+ this.publish("/cmd_vel", CMD_VEL_SCHEMA, ZERO_TWIST, { priority: "control" });
1299
+ }
1300
+ } catch {
1301
+ }
1302
+ this.hasPublishedTwist = false;
1303
+ }
1304
+ // ── Private: cleanup ─────────────────────────────────────────────────────
1305
+ cleanup() {
1306
+ this.clearConnectionTimeout();
1307
+ this.stopPingLoop();
1308
+ if (this.reconnectTimer) {
1309
+ clearTimeout(this.reconnectTimer);
1310
+ this.reconnectTimer = null;
1311
+ }
1312
+ for (const [, pending] of this.pendingServiceCalls) {
1313
+ clearTimeout(pending.timer);
1314
+ pending.reject(new Error("Connection closed"));
1315
+ }
1316
+ this.pendingServiceCalls.clear();
1317
+ if (this.ws) {
1318
+ if (this.ws.readyState === WebSocket.OPEN && this.advertisedTopics.size > 0) {
1319
+ const channelIds = Array.from(this.advertisedTopics.values());
1320
+ try {
1321
+ this.sendJson({ op: "unadvertise", channelIds });
1322
+ } catch {
1323
+ }
1324
+ }
1325
+ this.ws.onopen = null;
1326
+ this.ws.onmessage = null;
1327
+ this.ws.onerror = null;
1328
+ this.ws.onclose = null;
1329
+ if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
1330
+ this.ws.close();
1331
+ }
1332
+ this.ws = null;
1333
+ }
1334
+ this.channels.clear();
1335
+ this.topicToChannelId.clear();
1336
+ this.subscriptions.clear();
1337
+ this.topicToSubscriptionId.clear();
1338
+ this.messageReaders.clear();
1339
+ this.advertisedTopics.clear();
1340
+ this.availableServices.clear();
1341
+ this.notifyServicesChanged();
1342
+ this.serverInfoReceived = false;
1343
+ this.nextSubscriptionId = 1;
1344
+ this.nextClientChannelId = 1;
1345
+ this.nextServiceCallId = 1;
1346
+ }
1347
+ clearConnectionTimeout() {
1348
+ if (this.connectionTimeoutTimer) {
1349
+ clearTimeout(this.connectionTimeoutTimer);
1350
+ this.connectionTimeoutTimer = null;
1351
+ }
1352
+ }
1353
+ // ── Private: helpers ─────────────────────────────────────────────────────
1354
+ setStatus(status) {
1355
+ if (this.status === status) return;
1356
+ this.status = status;
1357
+ for (const cb of this.statusListeners) {
1358
+ try {
1359
+ cb(status);
1360
+ } catch (err) {
1361
+ this.logger.error("[FoxgloveClient] Status listener error:", err);
1362
+ }
1363
+ }
1364
+ }
1365
+ sendJson(msg) {
1366
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
1367
+ this.ws.send(JSON.stringify(msg));
1368
+ }
1369
+ }
1370
+ notifyTopicsChanged() {
1371
+ const appTopics = new Set(this.advertisedTopics.keys());
1372
+ const topics = Array.from(this.channels.values()).map((ch) => ({
1373
+ topic: ch.topic,
1374
+ schemaName: ch.schemaName,
1375
+ encoding: ch.encoding,
1376
+ source: appTopics.has(ch.topic) ? "app" : "robot"
1377
+ }));
1378
+ for (const cb of this.topicsListeners) {
1379
+ try {
1380
+ cb(topics);
1381
+ } catch (err) {
1382
+ this.logger.error("[FoxgloveClient] Topics listener error:", err);
1383
+ }
1384
+ }
1385
+ }
1386
+ log(message) {
1387
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().substring(11, 23);
1388
+ const formatted = `[${timestamp}] ${message}`;
1389
+ this.logger.log(`[FoxgloveClient] ${formatted}`);
1390
+ for (const cb of this.logListeners) {
1391
+ try {
1392
+ cb(formatted);
1393
+ } catch (err) {
1394
+ this.logger.error("[FoxgloveClient] Log listener error:", err);
1395
+ }
1396
+ }
1397
+ }
1398
+ };
1399
+ // Control-priority outbox. Twist / E-Stop publishes route through here
1400
+ // and get flushed at the top of every incoming WS message handler.
1401
+ __publicField(_FoxgloveClient, "CONTROL_FLUSH_BATCH", 3);
1402
+ var FoxgloveClient = _FoxgloveClient;
1403
+
1404
+ // src/RosbridgeClient.ts
1405
+ var NOOP_LOGGER2 = { log() {
1406
+ }, warn() {
1407
+ }, error() {
1408
+ } };
1409
+ var TEXT_DECODER2 = new TextDecoder();
1410
+ var MAX_RECONNECT_ATTEMPTS2 = 5;
1411
+ var BASE_RECONNECT_DELAY_MS2 = 1e3;
1412
+ var CONNECTION_TIMEOUT_MS2 = 1e4;
1413
+ var DEFAULT_THROTTLE_RATE_MS = 100;
1414
+ var SERVICE_CALL_TIMEOUT_MS = 3e4;
1415
+ var ZERO_TWIST2 = {
1416
+ linear: { x: 0, y: 0, z: 0 },
1417
+ angular: { x: 0, y: 0, z: 0 }
1418
+ };
1419
+ var CMD_VEL_SCHEMA2 = "geometry_msgs/msg/Twist";
1420
+ var _RosbridgeClient = class _RosbridgeClient {
1421
+ constructor(options) {
1422
+ __publicField(this, "onLatency");
1423
+ __publicField(this, "logger");
1424
+ __publicField(this, "getThrottleMode");
1425
+ __publicField(this, "presets");
1426
+ __publicField(this, "ws", null);
1427
+ __publicField(this, "url", "");
1428
+ __publicField(this, "status", "disconnected");
1429
+ __publicField(this, "statusListeners", /* @__PURE__ */ new Set());
1430
+ __publicField(this, "logListeners", /* @__PURE__ */ new Set());
1431
+ __publicField(this, "topicsListeners", /* @__PURE__ */ new Set());
1432
+ __publicField(this, "servicesListeners", /* @__PURE__ */ new Set());
1433
+ __publicField(this, "availableServices", []);
1434
+ __publicField(this, "servicesPollTimer", null);
1435
+ __publicField(this, "activeSubscriptions", /* @__PURE__ */ new Map());
1436
+ __publicField(this, "breakerListeners", /* @__PURE__ */ new Map());
1437
+ __publicField(this, "advertisedTopics", /* @__PURE__ */ new Set());
1438
+ __publicField(this, "hasPublishedTwist", false);
1439
+ __publicField(this, "controlOutbox", []);
1440
+ __publicField(this, "controlFlushScheduled", false);
1441
+ __publicField(this, "discoveredTopics", []);
1442
+ __publicField(this, "pendingServiceCalls", /* @__PURE__ */ new Map());
1443
+ __publicField(this, "serviceCallCounter", 0);
1444
+ __publicField(this, "latencyProbeTimer", null);
1445
+ __publicField(this, "reconnectAttempts", 0);
1446
+ __publicField(this, "reconnectTimer", null);
1447
+ __publicField(this, "connectionTimeoutTimer", null);
1448
+ __publicField(this, "intentionalDisconnect", false);
1449
+ this.onLatency = options?.onLatency;
1450
+ this.logger = options?.logger ?? NOOP_LOGGER2;
1451
+ this.getThrottleMode = options?.getThrottleMode ?? (() => "auto");
1452
+ this.presets = buildEffectivePresets(options?.presetOverrides, this.logger);
1453
+ setModeGetter(this.getThrottleMode);
1454
+ }
1455
+ get isConnected() {
1456
+ return this.status === "connected";
1457
+ }
1458
+ get reconnectAttempt() {
1459
+ return this.reconnectAttempts;
1460
+ }
1461
+ get maxReconnectAttempts() {
1462
+ return MAX_RECONNECT_ATTEMPTS2;
1463
+ }
1464
+ async connect(url) {
1465
+ if (this.status === "connecting" || this.status === "connected") {
1466
+ return;
1467
+ }
1468
+ this.url = url.trim();
1469
+ this.intentionalDisconnect = false;
1470
+ this.reconnectAttempts = 0;
1471
+ this.log(`Connecting to rosbridge at ${this.url}...`);
1472
+ return this.performConnect();
1473
+ }
1474
+ async disconnect() {
1475
+ this.intentionalDisconnect = true;
1476
+ this.safePublishZeroTwist();
1477
+ this.flushControlOutbox();
1478
+ this.cleanup();
1479
+ this.setStatus("disconnected");
1480
+ }
1481
+ async getAvailableTopics() {
1482
+ if (!this.ws || !this.isConnected) {
1483
+ return this.discoveredTopics;
1484
+ }
1485
+ try {
1486
+ const result = await this.callService("/rosapi/topics", {});
1487
+ const names = result.topics ?? [];
1488
+ const types = result.types ?? [];
1489
+ const topics = [];
1490
+ for (let i = 0; i < names.length; i++) {
1491
+ const name = names[i];
1492
+ if (!name) continue;
1493
+ topics.push({
1494
+ topic: name,
1495
+ schemaName: types[i] ?? "",
1496
+ encoding: "json",
1497
+ source: this.advertisedTopics.has(name) ? "app" : "robot"
1498
+ });
1499
+ }
1500
+ this.discoveredTopics = topics;
1501
+ this.log(`Discovered ${topics.length} topics.`);
1502
+ this.notifyTopicsChanged();
1503
+ return topics;
1504
+ } catch (err) {
1505
+ const msg = err instanceof Error ? err.message : String(err);
1506
+ this.log(`Failed to get topics via rosapi: ${msg}`);
1507
+ return this.discoveredTopics;
1508
+ }
1509
+ }
1510
+ getSchemaTemplate(_schemaName) {
1511
+ return null;
1512
+ }
1513
+ subscribe(topic, onMessage, options) {
1514
+ if (!this.ws || this.status !== "connected") {
1515
+ return () => {
1516
+ };
1517
+ }
1518
+ const userMinIntervalMs = options?.maxFrequency && options.maxFrequency > 0 ? 1e3 / options.maxFrequency : void 0;
1519
+ const disableAdaptive = options?.disableAdaptive ?? false;
1520
+ const existing = this.activeSubscriptions.get(topic);
1521
+ if (existing) {
1522
+ existing.callbacks.set(onMessage, {
1523
+ userMinIntervalMs,
1524
+ disableAdaptive,
1525
+ lastDeliveredAt: 0
1526
+ });
1527
+ return () => {
1528
+ existing.callbacks.delete(onMessage);
1529
+ if (existing.callbacks.size === 0) {
1530
+ this.unsubscribeTopic(topic);
1531
+ }
1532
+ };
1533
+ }
1534
+ const topicInfo = this.discoveredTopics.find((t) => t.topic === topic);
1535
+ const messageType = topicInfo?.schemaName ?? "";
1536
+ if (!messageType) {
1537
+ this.log(`Warning: subscribing to "${topic}" without known message type`);
1538
+ }
1539
+ const callbacks = /* @__PURE__ */ new Map();
1540
+ callbacks.set(onMessage, {
1541
+ userMinIntervalMs,
1542
+ disableAdaptive,
1543
+ lastDeliveredAt: 0
1544
+ });
1545
+ const breaker = new CircuitBreaker({
1546
+ ...DEFAULT_BREAKER_CONFIG,
1547
+ onStateChange: (newState) => {
1548
+ const sub = this.activeSubscriptions.get(topic);
1549
+ if (!sub) return;
1550
+ sub.isPaused = newState === "tripped_auto" || newState === "tripped_manual";
1551
+ if (newState === "tripped_auto") {
1552
+ if (this.ws && this.status === "connected") {
1553
+ this.send({ op: "unsubscribe", topic });
1554
+ }
1555
+ this.log(`[breaker] ${topic} \u2192 tripped_auto (sustained overload)`);
1556
+ } else if (newState === "half_open") {
1557
+ setTrackerToDeepest(sub.bandwidth, this.getThrottleMode());
1558
+ if (this.ws && this.status === "connected") {
1559
+ this.send({
1560
+ op: "subscribe",
1561
+ topic,
1562
+ type: sub.schemaName,
1563
+ throttle_rate: DEFAULT_THROTTLE_RATE_MS,
1564
+ queue_length: 1
1565
+ });
1566
+ }
1567
+ this.log(`[breaker] ${topic} \u2192 half_open (re-subscribed)`);
1568
+ } else if (newState === "closed") {
1569
+ this.log(`[breaker] ${topic} \u2192 closed (recovered)`);
1570
+ } else if (newState === "tripped_manual") {
1571
+ this.log(`[breaker] ${topic} \u2192 tripped_manual (user disabled auto-retry)`);
1572
+ }
1573
+ const listeners = this.breakerListeners.get(topic);
1574
+ if (listeners) {
1575
+ for (const cb of listeners) {
1576
+ try {
1577
+ cb(newState);
1578
+ } catch (err) {
1579
+ this.logger.error("[RosbridgeClient] Breaker listener error:", err);
1580
+ }
1581
+ }
1582
+ }
1583
+ }
1584
+ });
1585
+ this.activeSubscriptions.set(topic, {
1586
+ schemaName: messageType,
1587
+ callbacks,
1588
+ bandwidth: createBandwidthTracker(this.getThrottleMode(), this.presets),
1589
+ breaker,
1590
+ isPaused: false
1591
+ });
1592
+ this.send({
1593
+ op: "subscribe",
1594
+ topic,
1595
+ type: messageType,
1596
+ throttle_rate: DEFAULT_THROTTLE_RATE_MS,
1597
+ queue_length: 1
1598
+ });
1599
+ return () => {
1600
+ callbacks.delete(onMessage);
1601
+ if (callbacks.size === 0) {
1602
+ this.unsubscribeTopic(topic);
1603
+ }
1604
+ };
1605
+ }
1606
+ publish(topic, schemaName, data, options) {
1607
+ if (!this.ws || this.status !== "connected") {
1608
+ return;
1609
+ }
1610
+ if (schemaName === CMD_VEL_SCHEMA2) {
1611
+ this.hasPublishedTwist = true;
1612
+ }
1613
+ if (!this.advertisedTopics.has(topic)) {
1614
+ this.send({
1615
+ op: "advertise",
1616
+ topic,
1617
+ type: schemaName
1618
+ });
1619
+ this.advertisedTopics.add(topic);
1620
+ }
1621
+ const payload = { op: "publish", topic, msg: data };
1622
+ if (options?.priority === "control") {
1623
+ this.controlOutbox.push(payload);
1624
+ this.scheduleControlFlush();
1625
+ return;
1626
+ }
1627
+ this.send(payload);
1628
+ }
1629
+ scheduleControlFlush() {
1630
+ if (this.controlFlushScheduled) return;
1631
+ this.controlFlushScheduled = true;
1632
+ setTimeout(() => {
1633
+ this.controlFlushScheduled = false;
1634
+ this.flushControlOutbox();
1635
+ }, 0);
1636
+ }
1637
+ flushControlOutbox() {
1638
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
1639
+ this.controlOutbox.length = 0;
1640
+ return;
1641
+ }
1642
+ let drained = 0;
1643
+ while (this.controlOutbox.length > 0 && drained < _RosbridgeClient.CONTROL_FLUSH_BATCH) {
1644
+ const entry = this.controlOutbox.shift();
1645
+ if (!entry) break;
1646
+ this.send(entry);
1647
+ drained++;
1648
+ }
1649
+ if (this.controlOutbox.length > 0) {
1650
+ this.scheduleControlFlush();
1651
+ }
1652
+ }
1653
+ ensureAdvertised(topic, schemaName) {
1654
+ if (!this.ws || this.status !== "connected") return;
1655
+ if (this.advertisedTopics.has(topic)) return;
1656
+ this.send({
1657
+ op: "advertise",
1658
+ topic,
1659
+ type: schemaName
1660
+ });
1661
+ this.advertisedTopics.add(topic);
1662
+ }
1663
+ unadvertise(topic) {
1664
+ if (!this.advertisedTopics.has(topic)) return;
1665
+ this.advertisedTopics.delete(topic);
1666
+ if (this.ws && this.status === "connected") {
1667
+ this.send({
1668
+ op: "unadvertise",
1669
+ topic
1670
+ });
1671
+ }
1672
+ }
1673
+ async callService(service, request) {
1674
+ if (!this.ws || this.status !== "connected") {
1675
+ throw new Error("Not connected");
1676
+ }
1677
+ const id = `service_call:${service}:${++this.serviceCallCounter}`;
1678
+ return new Promise((resolve, reject) => {
1679
+ const timer = setTimeout(() => {
1680
+ this.pendingServiceCalls.delete(id);
1681
+ reject(
1682
+ new Error(`Service call "${service}" timed out after ${SERVICE_CALL_TIMEOUT_MS}ms`)
1683
+ );
1684
+ }, SERVICE_CALL_TIMEOUT_MS);
1685
+ this.pendingServiceCalls.set(id, { resolve, reject, timer });
1686
+ this.send({
1687
+ op: "call_service",
1688
+ id,
1689
+ service,
1690
+ args: request
1691
+ });
1692
+ });
1693
+ }
1694
+ onStatusChange(cb) {
1695
+ this.statusListeners.add(cb);
1696
+ cb(this.status);
1697
+ return () => {
1698
+ this.statusListeners.delete(cb);
1699
+ };
1700
+ }
1701
+ onTopicsChange(cb) {
1702
+ this.topicsListeners.add(cb);
1703
+ return () => {
1704
+ this.topicsListeners.delete(cb);
1705
+ };
1706
+ }
1707
+ getAvailableServices() {
1708
+ return [...this.availableServices];
1709
+ }
1710
+ onServicesChange(cb) {
1711
+ this.servicesListeners.add(cb);
1712
+ cb(this.getAvailableServices());
1713
+ return () => {
1714
+ this.servicesListeners.delete(cb);
1715
+ };
1716
+ }
1717
+ async discoverServices() {
1718
+ if (!this.isConnected) return;
1719
+ try {
1720
+ const result = await this.callService("/rosapi/services", {});
1721
+ const names = result.services ?? [];
1722
+ const next = names.map((n) => ({ name: n, type: "" }));
1723
+ const prevKey = this.availableServices.map((s) => s.name).sort().join("|");
1724
+ const nextKey = next.map((s) => s.name).sort().join("|");
1725
+ if (prevKey === nextKey) return;
1726
+ this.availableServices = next;
1727
+ this.notifyServicesChanged();
1728
+ } catch (err) {
1729
+ const msg = err instanceof Error ? err.message : String(err);
1730
+ this.log(`Service discovery via /rosapi/services failed: ${msg}`);
1731
+ this.stopServicesPoll();
1732
+ }
1733
+ }
1734
+ startServicesPoll() {
1735
+ if (this.servicesPollTimer) return;
1736
+ void this.discoverServices();
1737
+ this.servicesPollTimer = setInterval(() => {
1738
+ void this.discoverServices();
1739
+ }, 3e4);
1740
+ }
1741
+ stopServicesPoll() {
1742
+ if (this.servicesPollTimer) {
1743
+ clearInterval(this.servicesPollTimer);
1744
+ this.servicesPollTimer = null;
1745
+ }
1746
+ }
1747
+ notifyServicesChanged() {
1748
+ const snapshot = this.getAvailableServices();
1749
+ for (const cb of this.servicesListeners) {
1750
+ try {
1751
+ cb(snapshot);
1752
+ } catch (err) {
1753
+ this.logger.error("[RosbridgeClient] Services listener error:", err);
1754
+ }
1755
+ }
1756
+ }
1757
+ onLog(cb) {
1758
+ this.logListeners.add(cb);
1759
+ return () => {
1760
+ this.logListeners.delete(cb);
1761
+ };
1762
+ }
1763
+ publishZeroTwist() {
1764
+ this.safePublishZeroTwist();
1765
+ }
1766
+ // ── Private: connection lifecycle ──────────────────────────────────────
1767
+ performConnect() {
1768
+ this.cleanupConnection();
1769
+ this.setStatus("connecting");
1770
+ return new Promise((resolve, reject) => {
1771
+ try {
1772
+ this.ws = new WebSocket(this.url);
1773
+ this.connectionTimeoutTimer = setTimeout(() => {
1774
+ this.log(`Connection timeout after ${CONNECTION_TIMEOUT_MS2}ms`);
1775
+ reject(new Error(`Connection timeout after ${CONNECTION_TIMEOUT_MS2}ms`));
1776
+ this.cleanup();
1777
+ this.setStatus("error");
1778
+ this.scheduleReconnect();
1779
+ }, CONNECTION_TIMEOUT_MS2);
1780
+ this.ws.onopen = () => {
1781
+ this.clearConnectionTimeout();
1782
+ this.reconnectAttempts = 0;
1783
+ this.log("Connected to rosbridge server.");
1784
+ this.setStatus("connected");
1785
+ this.startLatencyProbe();
1786
+ this.startServicesPoll();
1787
+ resolve();
1788
+ };
1789
+ this.ws.onerror = (event) => {
1790
+ const detail = event.message ?? "Connection error";
1791
+ this.log(`Rosbridge error: ${detail}`);
1792
+ this.logger.error("[RosbridgeClient] Error:", event);
1793
+ if (this.status === "connecting") {
1794
+ this.clearConnectionTimeout();
1795
+ reject(new Error(`Rosbridge error: ${detail}`));
1796
+ this.cleanup();
1797
+ this.setStatus("error");
1798
+ this.scheduleReconnect();
1799
+ }
1800
+ };
1801
+ this.ws.onclose = () => {
1802
+ const wasConnected = this.status === "connected";
1803
+ this.log("Rosbridge connection closed.");
1804
+ if (wasConnected && this.hasPublishedTwist && !this.intentionalDisconnect) {
1805
+ this.safePublishZeroTwist();
1806
+ }
1807
+ this.cleanupConnection();
1808
+ this.setStatus("disconnected");
1809
+ if (wasConnected && !this.intentionalDisconnect) {
1810
+ this.scheduleReconnect();
1811
+ }
1812
+ };
1813
+ this.ws.onmessage = (event) => {
1814
+ this.handleMessage(event.data);
1815
+ };
1816
+ } catch (err) {
1817
+ this.clearConnectionTimeout();
1818
+ const error = err instanceof Error ? err : new Error(String(err));
1819
+ reject(error);
1820
+ this.cleanup();
1821
+ this.setStatus("error");
1822
+ }
1823
+ });
1824
+ }
1825
+ // ── Private: message handling ──────────────────────────────────────────
1826
+ handleMessage(raw) {
1827
+ if (this.controlOutbox.length > 0) {
1828
+ this.flushControlOutbox();
1829
+ }
1830
+ try {
1831
+ const data = typeof raw === "string" ? raw : TEXT_DECODER2.decode(raw);
1832
+ const byteSize = typeof raw === "string" ? data.length : raw.byteLength;
1833
+ const mode = this.getThrottleMode();
1834
+ if (this.tryDropPublishBeforeParse(data, byteSize, mode)) {
1835
+ return;
1836
+ }
1837
+ const msg = JSON.parse(data);
1838
+ const op = msg.op;
1839
+ switch (op) {
1840
+ case "publish":
1841
+ this.handlePublish(msg, byteSize, mode);
1842
+ break;
1843
+ case "service_response":
1844
+ this.handleServiceResponse(msg);
1845
+ break;
1846
+ case "status": {
1847
+ const level = msg.level;
1848
+ const statusMsg = msg.msg;
1849
+ if (level === "error" || level === "warning") {
1850
+ this.log(`rosbridge ${level}: ${statusMsg ?? "unknown"}`);
1851
+ }
1852
+ break;
1853
+ }
1854
+ }
1855
+ } catch (err) {
1856
+ this.logger.error("[RosbridgeClient] Failed to parse message:", err);
1857
+ }
1858
+ }
1859
+ /**
1860
+ * Drop a publish message before paying full `JSON.parse` cost.
1861
+ *
1862
+ * Rosbridge frames look like `{"op":"publish","topic":"…","msg":{…}}`.
1863
+ * For fat payloads (base64-encoded camera frames, large arrays)
1864
+ * `JSON.parse` on the whole envelope dominates per-message cost. We do a
1865
+ * cheap substring search to extract `op` and `topic`, run the same
1866
+ * throttle/breaker accounting `handlePublish` would do, and return `true`
1867
+ * to skip the parse when no callback wants the message right now.
1868
+ *
1869
+ * Handles both compact (`{"op":"publish"`) and pretty (`{"op": "publish"`)
1870
+ * JSON forms.
1871
+ */
1872
+ tryDropPublishBeforeParse(data, byteSize, mode) {
1873
+ if (!data.startsWith('{"op":"publish"') && !data.startsWith('{"op": "publish"')) {
1874
+ return false;
1875
+ }
1876
+ const topicKeyIdx = data.indexOf('"topic"');
1877
+ if (topicKeyIdx < 0 || topicKeyIdx > 200) return false;
1878
+ let i = topicKeyIdx + 7;
1879
+ if (data[i] !== ":") return false;
1880
+ i++;
1881
+ if (data[i] === " ") i++;
1882
+ if (data[i] !== '"') return false;
1883
+ i++;
1884
+ const topicEnd = data.indexOf('"', i);
1885
+ if (topicEnd < 0) return false;
1886
+ const topic = data.slice(i, topicEnd);
1887
+ const sub = this.activeSubscriptions.get(topic);
1888
+ if (!sub) return true;
1889
+ const now = Date.now();
1890
+ if (sub.isPaused) {
1891
+ recordBytes(sub.bandwidth, now, byteSize, mode);
1892
+ return true;
1893
+ }
1894
+ let anyCallbackWantsThis = false;
1895
+ for (const entry of sub.callbacks.values()) {
1896
+ const interval = effectiveMinInterval(
1897
+ entry.userMinIntervalMs,
1898
+ entry.disableAdaptive,
1899
+ sub.bandwidth
1900
+ );
1901
+ if (interval <= 0 || now - entry.lastDeliveredAt >= interval) {
1902
+ anyCallbackWantsThis = true;
1903
+ break;
1904
+ }
1905
+ }
1906
+ if (anyCallbackWantsThis) return false;
1907
+ recordBytes(sub.bandwidth, now, byteSize, mode);
1908
+ sub.breaker.recordObservation(now, sub.bandwidth.bytesPerSec, getMaxLagMs());
1909
+ return true;
1910
+ }
1911
+ handlePublish(msg, byteSize, mode) {
1912
+ const topic = msg.topic;
1913
+ const msgData = msg.msg ?? {};
1914
+ const sub = this.activeSubscriptions.get(topic);
1915
+ if (!sub) return;
1916
+ if (sub.isPaused) return;
1917
+ const now = Date.now();
1918
+ recordBytes(sub.bandwidth, now, byteSize, mode);
1919
+ sub.breaker.recordObservation(now, sub.bandwidth.bytesPerSec, getMaxLagMs());
1920
+ const deliverTo = [];
1921
+ for (const [cb, entry] of sub.callbacks) {
1922
+ const interval = effectiveMinInterval(
1923
+ entry.userMinIntervalMs,
1924
+ entry.disableAdaptive,
1925
+ sub.bandwidth
1926
+ );
1927
+ if (interval <= 0 || now - entry.lastDeliveredAt >= interval) {
1928
+ deliverTo.push([cb, entry]);
1929
+ }
1930
+ }
1931
+ if (deliverTo.length === 0) return;
1932
+ const rosMsg = {
1933
+ topic,
1934
+ schemaName: sub.schemaName,
1935
+ encoding: "json",
1936
+ data: msgData,
1937
+ receiveTime: {
1938
+ sec: Math.floor(now / 1e3),
1939
+ nsec: now % 1e3 * 1e6
1940
+ },
1941
+ byteSize
1942
+ };
1943
+ for (const [cb, entry] of deliverTo) {
1944
+ entry.lastDeliveredAt = now;
1945
+ try {
1946
+ cb(rosMsg);
1947
+ } catch (err) {
1948
+ this.logger.error("[RosbridgeClient] Subscriber callback error:", err);
1949
+ }
1950
+ }
1951
+ }
1952
+ handleServiceResponse(msg) {
1953
+ const id = msg.id;
1954
+ const pending = this.pendingServiceCalls.get(id);
1955
+ if (!pending) return;
1956
+ clearTimeout(pending.timer);
1957
+ this.pendingServiceCalls.delete(id);
1958
+ const success = msg.result === true || msg.result === "true";
1959
+ if (success) {
1960
+ const values = msg.values ?? {};
1961
+ pending.resolve(values);
1962
+ } else {
1963
+ const errorMsg = typeof msg.values === "string" ? msg.values : "Service call failed";
1964
+ pending.reject(new Error(errorMsg));
1965
+ }
1966
+ }
1967
+ // ── Private: topic management ──────────────────────────────────────────
1968
+ unsubscribeTopic(topic) {
1969
+ const sub = this.activeSubscriptions.get(topic);
1970
+ sub?.breaker.destroy();
1971
+ this.activeSubscriptions.delete(topic);
1972
+ if (this.ws && this.status === "connected" && !sub?.isPaused) {
1973
+ this.send({ op: "unsubscribe", topic });
1974
+ }
1975
+ }
1976
+ // ── IProtocolClient: circuit breaker controls ─────────────────────────────
1977
+ getBreakerState(topic) {
1978
+ return this.activeSubscriptions.get(topic)?.breaker.getState() ?? "closed";
1979
+ }
1980
+ getBreakerNextRetryAt(topic) {
1981
+ return this.activeSubscriptions.get(topic)?.breaker.getNextRetryAt() ?? null;
1982
+ }
1983
+ getSubscriptionStats(topic) {
1984
+ const sub = this.activeSubscriptions.get(topic);
1985
+ if (!sub) return null;
1986
+ const mode = this.getThrottleMode();
1987
+ return {
1988
+ adaptiveMinIntervalMs: sub.bandwidth.adaptiveMinIntervalMs,
1989
+ bucketLabel: getTrackerBucketLabel(sub.bandwidth, mode),
1990
+ bytesPerSec: sub.bandwidth.bytesPerSec
1991
+ };
1992
+ }
1993
+ breakerRetry(topic) {
1994
+ this.activeSubscriptions.get(topic)?.breaker.retry();
1995
+ }
1996
+ breakerDisable(topic) {
1997
+ this.activeSubscriptions.get(topic)?.breaker.disable();
1998
+ }
1999
+ onBreakerStateChange(topic, cb) {
2000
+ let listeners = this.breakerListeners.get(topic);
2001
+ if (!listeners) {
2002
+ listeners = /* @__PURE__ */ new Set();
2003
+ this.breakerListeners.set(topic, listeners);
2004
+ }
2005
+ listeners.add(cb);
2006
+ return () => {
2007
+ const set = this.breakerListeners.get(topic);
2008
+ if (set) {
2009
+ set.delete(cb);
2010
+ if (set.size === 0) this.breakerListeners.delete(topic);
2011
+ }
2012
+ };
2013
+ }
2014
+ // ── Private: dead-man's switch ─────────────────────────────────────────
2015
+ safePublishZeroTwist() {
2016
+ if (!this.hasPublishedTwist) return;
2017
+ try {
2018
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
2019
+ this.publish("/cmd_vel", CMD_VEL_SCHEMA2, ZERO_TWIST2, { priority: "control" });
2020
+ }
2021
+ } catch {
2022
+ }
2023
+ this.hasPublishedTwist = false;
2024
+ }
2025
+ // ── Private: latency probe ──────────────────────────────────────────────
2026
+ startLatencyProbe() {
2027
+ this.stopLatencyProbe();
2028
+ this.latencyProbeTimer = setInterval(() => {
2029
+ if (!this.ws || this.status !== "connected") return;
2030
+ const start = Date.now();
2031
+ const id = `latency_probe:${++this.serviceCallCounter}`;
2032
+ const timer = setTimeout(() => {
2033
+ this.pendingServiceCalls.delete(id);
2034
+ }, 5e3);
2035
+ this.pendingServiceCalls.set(id, {
2036
+ resolve: () => {
2037
+ clearTimeout(timer);
2038
+ if (this.onLatency) {
2039
+ try {
2040
+ this.onLatency(Date.now() - start);
2041
+ } catch {
2042
+ }
2043
+ }
2044
+ },
2045
+ reject: () => {
2046
+ clearTimeout(timer);
2047
+ },
2048
+ timer
2049
+ });
2050
+ this.send({
2051
+ op: "call_service",
2052
+ id,
2053
+ service: "/rosapi/topics",
2054
+ args: {}
2055
+ });
2056
+ }, 5e3);
2057
+ }
2058
+ stopLatencyProbe() {
2059
+ if (this.latencyProbeTimer) {
2060
+ clearInterval(this.latencyProbeTimer);
2061
+ this.latencyProbeTimer = null;
2062
+ }
2063
+ }
2064
+ // ── Private: reconnection ──────────────────────────────────────────────
2065
+ scheduleReconnect() {
2066
+ if (this.intentionalDisconnect || this.reconnectTimer !== null || this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS2) {
2067
+ if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS2) {
2068
+ this.log(`Max reconnect attempts (${MAX_RECONNECT_ATTEMPTS2}) reached.`);
2069
+ this.setStatus("error");
2070
+ }
2071
+ return;
2072
+ }
2073
+ const delay = Math.min(
2074
+ BASE_RECONNECT_DELAY_MS2 * Math.pow(2, this.reconnectAttempts),
2075
+ 16e3
2076
+ );
2077
+ this.log(
2078
+ `Scheduling reconnect attempt ${this.reconnectAttempts + 1}/${MAX_RECONNECT_ATTEMPTS2} in ${delay}ms...`
2079
+ );
2080
+ this.reconnectAttempts++;
2081
+ this.reconnectTimer = setTimeout(() => {
2082
+ this.reconnectTimer = null;
2083
+ this.performConnect().catch((err) => {
2084
+ this.logger.error("[RosbridgeClient] Reconnect failed:", err);
2085
+ });
2086
+ }, delay);
2087
+ }
2088
+ // ── Private: cleanup ───────────────────────────────────────────────────
2089
+ cleanupConnection() {
2090
+ this.clearConnectionTimeout();
2091
+ this.stopServicesPoll();
2092
+ this.activeSubscriptions.clear();
2093
+ for (const [, pending] of this.pendingServiceCalls) {
2094
+ clearTimeout(pending.timer);
2095
+ pending.reject(new Error("Connection closed"));
2096
+ }
2097
+ this.pendingServiceCalls.clear();
2098
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
2099
+ for (const topic of this.advertisedTopics) {
2100
+ try {
2101
+ this.send({ op: "unadvertise", topic });
2102
+ } catch {
2103
+ }
2104
+ }
2105
+ }
2106
+ this.advertisedTopics.clear();
2107
+ }
2108
+ cleanup() {
2109
+ this.cleanupConnection();
2110
+ this.stopLatencyProbe();
2111
+ this.stopServicesPoll();
2112
+ if (this.reconnectTimer) {
2113
+ clearTimeout(this.reconnectTimer);
2114
+ this.reconnectTimer = null;
2115
+ }
2116
+ if (this.ws) {
2117
+ try {
2118
+ this.ws.close();
2119
+ } catch {
2120
+ }
2121
+ this.ws = null;
2122
+ }
2123
+ this.discoveredTopics = [];
2124
+ if (this.availableServices.length > 0) {
2125
+ this.availableServices = [];
2126
+ this.notifyServicesChanged();
2127
+ }
2128
+ }
2129
+ clearConnectionTimeout() {
2130
+ if (this.connectionTimeoutTimer) {
2131
+ clearTimeout(this.connectionTimeoutTimer);
2132
+ this.connectionTimeoutTimer = null;
2133
+ }
2134
+ }
2135
+ // ── Private: helpers ───────────────────────────────────────────────────
2136
+ send(msg) {
2137
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
2138
+ this.ws.send(JSON.stringify(msg));
2139
+ }
2140
+ }
2141
+ setStatus(status) {
2142
+ if (this.status === status) return;
2143
+ this.status = status;
2144
+ for (const cb of this.statusListeners) {
2145
+ try {
2146
+ cb(status);
2147
+ } catch (err) {
2148
+ this.logger.error("[RosbridgeClient] Status listener error:", err);
2149
+ }
2150
+ }
2151
+ }
2152
+ notifyTopicsChanged() {
2153
+ for (const cb of this.topicsListeners) {
2154
+ try {
2155
+ cb(this.discoveredTopics);
2156
+ } catch (err) {
2157
+ this.logger.error("[RosbridgeClient] Topics listener error:", err);
2158
+ }
2159
+ }
2160
+ }
2161
+ log(message) {
2162
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().substring(11, 23);
2163
+ const formatted = `[${timestamp}] ${message}`;
2164
+ this.logger.log(`[RosbridgeClient] ${formatted}`);
2165
+ for (const cb of this.logListeners) {
2166
+ try {
2167
+ cb(formatted);
2168
+ } catch (err) {
2169
+ this.logger.error("[RosbridgeClient] Log listener error:", err);
2170
+ }
2171
+ }
2172
+ }
2173
+ };
2174
+ __publicField(_RosbridgeClient, "CONTROL_FLUSH_BATCH", 3);
2175
+ var RosbridgeClient = _RosbridgeClient;
2176
+
2177
+ // src/constants.ts
2178
+ var DEFAULT_PORTS = {
2179
+ "foxglove-ws": 8765,
2180
+ rosbridge: 9090,
2181
+ zenoh: 7447
2182
+ };
2183
+
2184
+ // src/ProtocolManager.ts
2185
+ function sanitizeHost(raw) {
2186
+ let host = (raw ?? "").trim();
2187
+ host = host.replace(/^(wss?|https?):\/\//i, "");
2188
+ host = host.split("/")[0] ?? host;
2189
+ host = host.split("?")[0] ?? host;
2190
+ host = host.split("#")[0] ?? host;
2191
+ if (!host.startsWith("[")) {
2192
+ const colonIx = host.indexOf(":");
2193
+ if (colonIx !== -1) host = host.slice(0, colonIx);
2194
+ }
2195
+ return host;
2196
+ }
2197
+ var ProtocolManager = class {
2198
+ constructor() {
2199
+ __publicField(this, "activeClient", null);
2200
+ __publicField(this, "activeOptions", null);
2201
+ __publicField(this, "clientOptions");
2202
+ }
2203
+ /**
2204
+ * Set options forwarded to every client constructed by this manager.
2205
+ * Host applications typically call this once at startup.
2206
+ */
2207
+ setClientOptions(options) {
2208
+ this.clientOptions = options;
2209
+ }
2210
+ /**
2211
+ * Create and connect a protocol client for the given options.
2212
+ *
2213
+ * For `protocol: 'zenoh'`, throws a clear "planned for v0.2.0" error —
2214
+ * the v0.1.0 release does not ship a Zenoh implementation.
2215
+ */
2216
+ async connect(options) {
2217
+ if (this.activeClient) {
2218
+ await this.activeClient.disconnect();
2219
+ }
2220
+ const client = this.createClient(options);
2221
+ const scheme = options.secure ? "wss" : "ws";
2222
+ const port = options.port || DEFAULT_PORTS[options.protocol];
2223
+ const host = sanitizeHost(options.host);
2224
+ const url = `${scheme}://${host}:${port}`;
2225
+ try {
2226
+ new URL(url);
2227
+ } catch {
2228
+ throw new Error(
2229
+ `Invalid connection URL "${url}" \u2014 check host and port. Hosts should not include "ws://" or ":port"; use the port field instead.`
2230
+ );
2231
+ }
2232
+ await client.connect(url);
2233
+ this.activeClient = client;
2234
+ this.activeOptions = options;
2235
+ return client;
2236
+ }
2237
+ /**
2238
+ * Disconnect the active client and forget it.
2239
+ */
2240
+ async disconnect() {
2241
+ if (this.activeClient) {
2242
+ await this.activeClient.disconnect();
2243
+ this.activeClient = null;
2244
+ this.activeOptions = null;
2245
+ }
2246
+ }
2247
+ /**
2248
+ * Currently active client, or `null` if not connected.
2249
+ */
2250
+ getClient() {
2251
+ return this.activeClient;
2252
+ }
2253
+ /**
2254
+ * Currently active connection options, or `null` if not connected.
2255
+ */
2256
+ getOptions() {
2257
+ return this.activeOptions;
2258
+ }
2259
+ createClient(options) {
2260
+ switch (options.protocol) {
2261
+ case "foxglove-ws":
2262
+ return new FoxgloveClient(this.clientOptions);
2263
+ case "rosbridge":
2264
+ return new RosbridgeClient(this.clientOptions);
2265
+ case "zenoh":
2266
+ throw new Error("Zenoh support is planned for v0.2.0");
2267
+ default: {
2268
+ const exhaustive = options.protocol;
2269
+ throw new Error(`Unknown protocol: ${String(exhaustive)}`);
2270
+ }
2271
+ }
2272
+ }
2273
+ };
2274
+
2275
+ exports.DEFAULT_PORTS = DEFAULT_PORTS;
2276
+ exports.DEFAULT_PRESETS = DEFAULT_PRESETS;
2277
+ exports.FoxgloveClient = FoxgloveClient;
2278
+ exports.ProtocolManager = ProtocolManager;
2279
+ exports.RosbridgeClient = RosbridgeClient;
2280
+ exports.bucketLabelForLag = bucketLabelForLag;
2281
+ exports.clearLagHistory = clearLagHistory;
2282
+ exports.getLagHistoryCsv = getLagHistoryCsv;
2283
+ exports.getLagStats = getLagStats;
2284
+ exports.getMaxLagMs = getMaxLagMs;
2285
+ exports.jsonSchemaToTemplate = jsonSchemaToTemplate;
2286
+ exports.schemaToTemplate = schemaToTemplate;
2287
+ //# sourceMappingURL=index.cjs.map
2288
+ //# sourceMappingURL=index.cjs.map