ros-mobile-bridge 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/README.md +21 -0
- package/dist/index.cjs +220 -45
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +198 -1
- package/dist/index.d.ts +198 -1
- package/dist/index.mjs +219 -46
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,33 @@ All notable changes to `ros-mobile-bridge` will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.1.3] - 2026-06-02
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`SubscribeOptions.dispatchMode`** controls how throttle-surviving messages reach the callback. `'immediate'` (the default, and the only prior behavior) parses and delivers every surviving message synchronously on the message-handler tick. `'latest-only'` delivers only the newest message under back-pressure: superseded messages are dropped *before* being parsed and delivery is deferred off the message-handler tick, so a high-bandwidth topic like a raw camera stream decodes only the frame you will actually render. The conflation happens upstream of the CDR/JSON decode, which an external wrapper cannot do because it only ever receives already-parsed messages. Available on both Foxglove WS and rosbridge. On a binary (CDR) topic the surviving payload is copied to outlive the deferral, so `'latest-only'` is parse-cheap but not allocation-free; on rosbridge the stashed frame is an immutable string and the stash is copy-free. It composes below the throttle (`maxFrequency` and the adaptive cap decide eligibility, then `'latest-only'` keeps the newest of those). A callback that throws is logged and never wedges the subscription; on unsubscribe, disconnect, or a circuit-breaker trip any pending message is dropped rather than delivered.
|
|
12
|
+
- **`materializeBytes(view: Uint8Array): Uint8Array`** returns an owned, offset-0 copy of a `Uint8Array`. `RosMessage.data`, when it is a `Uint8Array`, is a zero-copy view into the inbound WebSocket frame (v0.1.2); call this before retaining the bytes past the callback or handing them to a native binding that ignores `byteOffset` (some Skia paths, `node-canvas`, `sharp`, FFI). It always copies and never returns the input view, so the result is always safe to retain. This supersedes the v0.1.2 note that anticipated a conditional (skip-when-already-owned) copy: a full-span view can still alias the shared frame buffer, so the conditional form would have skipped the copy in exactly the unsafe case.
|
|
13
|
+
- **`matchesSchema(a: string, b: string): boolean`** compares two ROS schema names tolerant of the ROS 1 / ROS 2 `msg/` (and `srv/`, `action/`) asymmetry, so `sensor_msgs/msg/Image` matches `sensor_msgs/Image`. It is kind-agnostic: it strips the interface-kind segment and compares `pkg` + `Type`. A `normalizeSchema` counterpart is deliberately not exported, because a 2-part name cannot be safely expanded to canonical 3-part form (the interface kind cannot be inferred from the string).
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- **Bundled service-schema lookup now tolerates the 2-part / 3-part name asymmetry.** A bridge advertising a well-known system service as `rcl_interfaces/ListParameters` (without the `srv/` segment) now resolves to the bundled schema, so parameter operations and goal cancellation work regardless of which form the bridge reports. Bridge-advertised schemas remain authoritative.
|
|
18
|
+
|
|
19
|
+
## [0.1.2] - 2026-06-01
|
|
20
|
+
|
|
21
|
+
### Performance
|
|
22
|
+
|
|
23
|
+
- **Zero-copy payload view on Foxglove WS binary topic ingest.** Previously every inbound `messageData` frame allocated a fresh `ArrayBuffer` (`ArrayBuffer.prototype.slice`) for the payload, costing roughly 10 MB/frame on raw 1080p image streams even on frames that the throttle subsequently dropped before parse. The library now uses a zero-copy `Uint8Array` view; downstream consumers (CDR reader, JSON decoder, byte-size accounting) see no behavior change. Verified end-to-end against a sustained 138 MB/s harness: `max` lag dropped from 96 ms to 29 ms (3.4× tail reduction), `mean` and `p50` unchanged.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- **Control-priority publishes now coalesce by destination.** When multiple `priority: 'control'` publishes for the same topic queue in the outbox during JS-thread saturation, only the latest drains rather than every queued tick in FIFO order. The release-the-joystick zero-Twist that follows N stale-value Twists on `/cmd_vel` now sends in one WebSocket frame, so the robot stops within one round-trip of release regardless of how deep the queue grew during the block. Insertion order across **distinct** topics is preserved (intra-topic conflation only). Reduced the worst-case stop latency by orders of magnitude under sustained-overload scenarios.
|
|
28
|
+
- **Tightened CircuitBreaker defaults for CDR-realistic workloads.** The breaker's `lagThresholdMs` (`250 → 150`), `tripDwellMs` (`5000 → 2000`), and the internal `WARMUP_MS` grace period (`2000 → 500`) were originally tuned against JSON-sim spike patterns (transient 100–333 ms spikes interleaved with healthy stretches). CDR sensor streams on real hardware fail in a different regime — sustained multi-second freezes during the cold-start window — which warrants faster detection. Total subscribe-to-unsubscribe floor on a genuinely saturating topic drops from approximately 7 s to approximately 2.5 s, comfortably below the threshold at which a user perceives the app as frozen.
|
|
29
|
+
|
|
30
|
+
### Documented
|
|
31
|
+
|
|
32
|
+
- **Zero-copy contract on `RosMessage.data` when it is a `Uint8Array`.** The byte values delivered to a subscriber callback as `Uint8Array` are now views into the inbound WebSocket frame's `ArrayBuffer`, not copies. The view's `byteOffset` is significant. Consumers handing `data` directly to native bindings that ignore `byteOffset` (some Skia binding paths, some FFI calls) must first materialize an owned copy via `new Uint8Array(data)`. A `materializeBytes(view)` helper that performs this copy conditionally is planned for the next release; until then the explicit copy is the recommended idiom. TSDoc on `RosMessage` carries the full contract.
|
|
33
|
+
|
|
7
34
|
## [0.1.1] - 2026-05-27
|
|
8
35
|
|
|
9
36
|
### Fixed
|
|
@@ -43,5 +70,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
43
70
|
|
|
44
71
|
- `ZenohClient` ships as an unimplemented skeleton (every method throws). `ProtocolManager.connect` throws a clear "Zenoh support is planned for v0.2.0" error for `protocol: 'zenoh'`. The class is not exported from `index.ts` in v0.1.0.
|
|
45
72
|
|
|
73
|
+
[0.1.3]: https://github.com/AuriLabsTech/ros-mobile-bridge/releases/tag/v0.1.3
|
|
74
|
+
[0.1.2]: https://github.com/AuriLabsTech/ros-mobile-bridge/releases/tag/v0.1.2
|
|
46
75
|
[0.1.1]: https://github.com/AuriLabsTech/ros-mobile-bridge/releases/tag/v0.1.1
|
|
47
76
|
[0.1.0]: https://github.com/AuriLabsTech/ros-mobile-bridge/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -98,6 +98,27 @@ const unwatch = client.onBreakerStateChange('/camera/compressed', (state) => {
|
|
|
98
98
|
|
|
99
99
|
Manual breaker controls (`breakerRetry`, `breakerDisable`) let consumers expose user-driven recovery in their UI.
|
|
100
100
|
|
|
101
|
+
### High-throughput subscriptions: `dispatchMode`
|
|
102
|
+
|
|
103
|
+
`SubscribeOptions.dispatchMode` controls how messages reach your callback when they arrive faster than you can use them. The default, `'immediate'`, parses and delivers every message that survives the throttle, synchronously on the message-handler tick. For a high-bandwidth topic where only the freshest message matters (a raw camera stream feeding a viewport), `'latest-only'` delivers just the newest message and drops the rest *before* they are parsed, so you decode only the frame you actually render:
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
import { materializeBytes } from 'ros-mobile-bridge';
|
|
107
|
+
|
|
108
|
+
client.subscribe(
|
|
109
|
+
'/camera/raw',
|
|
110
|
+
(msg) => {
|
|
111
|
+
if (msg.data instanceof Uint8Array) {
|
|
112
|
+
// msg.data is a zero-copy view; copy it before retaining past the callback.
|
|
113
|
+
render(materializeBytes(msg.data));
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
{ dispatchMode: 'latest-only' },
|
|
117
|
+
);
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Delivery is deferred off the message-handler tick, and the conflation happens upstream of the decode, which a wrapper around `subscribe` cannot do because it only ever sees already-parsed messages. `'latest-only'` composes below the throttle: `maxFrequency` and the adaptive cap still decide which messages are eligible, then the newest of those is delivered. For lossless-but-deferred delivery, keep your own bounded queue in the callback (the `dispatchMode` TSDoc shows the pattern); the bound and drop policy are yours to set because they depend on the device.
|
|
121
|
+
|
|
101
122
|
### Host-app injection
|
|
102
123
|
|
|
103
124
|
Construct clients with `ProtocolClientOptions` to receive latency callbacks, route logs, and tell the throttle which mode the user picked:
|
package/dist/index.cjs
CHANGED
|
@@ -10,7 +10,7 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
|
|
|
10
10
|
|
|
11
11
|
// src/CircuitBreaker.ts
|
|
12
12
|
var HEALTHY_DEBOUNCE_MS = 4e3;
|
|
13
|
-
var WARMUP_MS =
|
|
13
|
+
var WARMUP_MS = 500;
|
|
14
14
|
var CircuitBreaker = class {
|
|
15
15
|
constructor(config) {
|
|
16
16
|
__publicField(this, "config", config);
|
|
@@ -151,8 +151,8 @@ var CircuitBreaker = class {
|
|
|
151
151
|
}
|
|
152
152
|
};
|
|
153
153
|
var DEFAULT_BREAKER_CONFIG = {
|
|
154
|
-
lagThresholdMs:
|
|
155
|
-
tripDwellMs:
|
|
154
|
+
lagThresholdMs: 150,
|
|
155
|
+
tripDwellMs: 2e3,
|
|
156
156
|
recoveryDwellMs: 1e4,
|
|
157
157
|
cooldownsMs: [3e4, 6e4, 12e4, 3e5]
|
|
158
158
|
};
|
|
@@ -478,6 +478,16 @@ function jsonSchemaToTemplate(schema) {
|
|
|
478
478
|
return null;
|
|
479
479
|
}
|
|
480
480
|
|
|
481
|
+
// src/schemaName.ts
|
|
482
|
+
var INTERFACE_KIND = /^([^/]+)\/(?:msg|srv|action)\/([^/]+)$/;
|
|
483
|
+
function stripInterfaceKind(name) {
|
|
484
|
+
const m = INTERFACE_KIND.exec(name);
|
|
485
|
+
return m ? `${m[1]}/${m[2]}` : name;
|
|
486
|
+
}
|
|
487
|
+
function matchesSchema(a, b) {
|
|
488
|
+
return stripInterfaceKind(a) === stripInterfaceKind(b);
|
|
489
|
+
}
|
|
490
|
+
|
|
481
491
|
// src/builtinSchemas.ts
|
|
482
492
|
var SEP = "\n================================================================================\n";
|
|
483
493
|
var MSG_PARAMETER_VALUE = `MSG: rcl_interfaces/ParameterValue
|
|
@@ -581,7 +591,13 @@ var BUNDLED = {
|
|
|
581
591
|
}
|
|
582
592
|
};
|
|
583
593
|
function getBundledServiceSchema(serviceType) {
|
|
584
|
-
|
|
594
|
+
const exact = BUNDLED[serviceType];
|
|
595
|
+
if (exact) return exact;
|
|
596
|
+
const target = stripInterfaceKind(serviceType);
|
|
597
|
+
for (const key of Object.keys(BUNDLED)) {
|
|
598
|
+
if (stripInterfaceKind(key) === target) return BUNDLED[key] ?? null;
|
|
599
|
+
}
|
|
600
|
+
return null;
|
|
585
601
|
}
|
|
586
602
|
|
|
587
603
|
// src/FoxgloveClient.ts
|
|
@@ -789,6 +805,7 @@ var _FoxgloveClient = class _FoxgloveClient {
|
|
|
789
805
|
subscribe(topic, onMessage, options) {
|
|
790
806
|
const userMinIntervalMs = options?.maxFrequency && options.maxFrequency > 0 ? 1e3 / options.maxFrequency : void 0;
|
|
791
807
|
const disableAdaptive = options?.disableAdaptive ?? false;
|
|
808
|
+
const dispatchMode = options?.dispatchMode ?? "immediate";
|
|
792
809
|
const existingSubId = this.topicToSubscriptionId.get(topic);
|
|
793
810
|
if (existingSubId !== void 0) {
|
|
794
811
|
const sub = this.subscriptions.get(existingSubId);
|
|
@@ -796,9 +813,14 @@ var _FoxgloveClient = class _FoxgloveClient {
|
|
|
796
813
|
sub.callbacks.set(onMessage, {
|
|
797
814
|
userMinIntervalMs,
|
|
798
815
|
disableAdaptive,
|
|
799
|
-
lastDeliveredAt: 0
|
|
816
|
+
lastDeliveredAt: 0,
|
|
817
|
+
dispatchMode,
|
|
818
|
+
pending: null,
|
|
819
|
+
drainTimer: null
|
|
800
820
|
});
|
|
801
821
|
return () => {
|
|
822
|
+
const entry = sub.callbacks.get(onMessage);
|
|
823
|
+
if (entry) this.cancelDrain(entry);
|
|
802
824
|
sub.callbacks.delete(onMessage);
|
|
803
825
|
if (sub.callbacks.size === 0) {
|
|
804
826
|
this.unsubscribeTopic(topic, existingSubId);
|
|
@@ -819,7 +841,10 @@ var _FoxgloveClient = class _FoxgloveClient {
|
|
|
819
841
|
callbacks.set(onMessage, {
|
|
820
842
|
userMinIntervalMs,
|
|
821
843
|
disableAdaptive,
|
|
822
|
-
lastDeliveredAt: 0
|
|
844
|
+
lastDeliveredAt: 0,
|
|
845
|
+
dispatchMode,
|
|
846
|
+
pending: null,
|
|
847
|
+
drainTimer: null
|
|
823
848
|
});
|
|
824
849
|
const breaker = new CircuitBreaker({
|
|
825
850
|
...DEFAULT_BREAKER_CONFIG,
|
|
@@ -827,6 +852,7 @@ var _FoxgloveClient = class _FoxgloveClient {
|
|
|
827
852
|
const sub = this.subscriptions.get(subscriptionId);
|
|
828
853
|
if (!sub) return;
|
|
829
854
|
sub.isPaused = newState === "tripped_auto" || newState === "tripped_manual";
|
|
855
|
+
if (sub.isPaused) this.cancelAllDrains(sub);
|
|
830
856
|
if (newState === "tripped_auto") {
|
|
831
857
|
if (this.ws && this.status === "connected") {
|
|
832
858
|
this.sendJson({ op: "unsubscribe", subscriptionIds: [subscriptionId] });
|
|
@@ -892,6 +918,8 @@ var _FoxgloveClient = class _FoxgloveClient {
|
|
|
892
918
|
subscriptions: [{ id: subscriptionId, channelId }]
|
|
893
919
|
});
|
|
894
920
|
return () => {
|
|
921
|
+
const entry = callbacks.get(onMessage);
|
|
922
|
+
if (entry) this.cancelDrain(entry);
|
|
895
923
|
callbacks.delete(onMessage);
|
|
896
924
|
if (callbacks.size === 0) {
|
|
897
925
|
this.unsubscribeTopic(topic, subscriptionId);
|
|
@@ -927,7 +955,12 @@ var _FoxgloveClient = class _FoxgloveClient {
|
|
|
927
955
|
return;
|
|
928
956
|
}
|
|
929
957
|
if (options?.priority === "control") {
|
|
930
|
-
this.controlOutbox.
|
|
958
|
+
const existing = this.controlOutbox.findIndex((e) => e.channelId === clientChannelId);
|
|
959
|
+
if (existing >= 0) {
|
|
960
|
+
this.controlOutbox[existing] = { channelId: clientChannelId, data };
|
|
961
|
+
} else {
|
|
962
|
+
this.controlOutbox.push({ channelId: clientChannelId, data });
|
|
963
|
+
}
|
|
931
964
|
this.scheduleControlFlush();
|
|
932
965
|
return;
|
|
933
966
|
}
|
|
@@ -1281,7 +1314,7 @@ var _FoxgloveClient = class _FoxgloveClient {
|
|
|
1281
1314
|
const sec = Math.floor(timestampNs / 1e9);
|
|
1282
1315
|
const nsec = timestampNs % 1e9;
|
|
1283
1316
|
const payloadOffset = 13;
|
|
1284
|
-
const payload = buffer
|
|
1317
|
+
const payload = new Uint8Array(buffer, payloadOffset);
|
|
1285
1318
|
const sub = this.subscriptions.get(subscriptionId);
|
|
1286
1319
|
if (!sub) return;
|
|
1287
1320
|
if (sub.isPaused) return;
|
|
@@ -1301,46 +1334,103 @@ var _FoxgloveClient = class _FoxgloveClient {
|
|
|
1301
1334
|
}
|
|
1302
1335
|
}
|
|
1303
1336
|
if (deliverTo.length === 0) return;
|
|
1304
|
-
|
|
1305
|
-
const
|
|
1306
|
-
|
|
1307
|
-
|
|
1337
|
+
let parsed = null;
|
|
1338
|
+
for (const [cb, entry] of deliverTo) {
|
|
1339
|
+
entry.lastDeliveredAt = now;
|
|
1340
|
+
if (entry.dispatchMode === "latest-only") {
|
|
1341
|
+
entry.pending = { payload: new Uint8Array(payload), sec, nsec };
|
|
1342
|
+
if (entry.drainTimer === null) {
|
|
1343
|
+
entry.drainTimer = setTimeout(
|
|
1344
|
+
() => this.drainLatestOnly(subscriptionId, cb, entry),
|
|
1345
|
+
0
|
|
1346
|
+
);
|
|
1347
|
+
}
|
|
1348
|
+
continue;
|
|
1349
|
+
}
|
|
1350
|
+
if (parsed === null) {
|
|
1351
|
+
const channelInfo = this.channels.get(sub.channelId);
|
|
1352
|
+
const encoding = channelInfo?.encoding ?? "json";
|
|
1353
|
+
parsed = {
|
|
1354
|
+
topic: sub.topic,
|
|
1355
|
+
schemaName: channelInfo?.schemaName ?? "",
|
|
1356
|
+
encoding: encoding === "json" ? "json" : "cdr",
|
|
1357
|
+
data: this.decodePayload(payload, subscriptionId, encoding),
|
|
1358
|
+
receiveTime: { sec, nsec },
|
|
1359
|
+
byteSize: payload.byteLength
|
|
1360
|
+
};
|
|
1361
|
+
}
|
|
1362
|
+
try {
|
|
1363
|
+
cb(parsed);
|
|
1364
|
+
} catch (err) {
|
|
1365
|
+
this.logger.error("[FoxgloveClient] Subscriber callback error:", err);
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
/**
|
|
1370
|
+
* Decode a `messageData` payload to its delivered shape: a parsed object when
|
|
1371
|
+
* a CDR reader or JSON decode succeeds, the raw bytes otherwise. Shared by
|
|
1372
|
+
* the synchronous `immediate` path and the deferred `latest-only` drain.
|
|
1373
|
+
*/
|
|
1374
|
+
decodePayload(payload, subscriptionId, encoding) {
|
|
1308
1375
|
if (encoding === "json") {
|
|
1309
1376
|
try {
|
|
1310
|
-
|
|
1311
|
-
data = JSON.parse(text);
|
|
1377
|
+
return JSON.parse(TEXT_DECODER.decode(payload));
|
|
1312
1378
|
} catch {
|
|
1313
|
-
|
|
1379
|
+
return payload;
|
|
1314
1380
|
}
|
|
1315
|
-
}
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
}
|
|
1323
|
-
} else {
|
|
1324
|
-
data = new Uint8Array(payload);
|
|
1381
|
+
}
|
|
1382
|
+
const reader = this.messageReaders.get(subscriptionId);
|
|
1383
|
+
if (reader) {
|
|
1384
|
+
try {
|
|
1385
|
+
return reader.readMessage(payload);
|
|
1386
|
+
} catch {
|
|
1387
|
+
return payload;
|
|
1325
1388
|
}
|
|
1326
1389
|
}
|
|
1390
|
+
return payload;
|
|
1391
|
+
}
|
|
1392
|
+
/**
|
|
1393
|
+
* Drain one `latest-only` callback's pending payload: parse the survivor and
|
|
1394
|
+
* deliver it. Cleared state (`pending`, `drainTimer`) is reset *before* the
|
|
1395
|
+
* callback runs, so a throwing callback never wedges future delivery — the
|
|
1396
|
+
* next arrival re-arms normally. Bails if the subscription was torn down or
|
|
1397
|
+
* paused while the drain was armed (no post-teardown delivery).
|
|
1398
|
+
*/
|
|
1399
|
+
drainLatestOnly(subscriptionId, cb, entry) {
|
|
1400
|
+
entry.drainTimer = null;
|
|
1401
|
+
const pending = entry.pending;
|
|
1402
|
+
entry.pending = null;
|
|
1403
|
+
if (!pending) return;
|
|
1404
|
+
const sub = this.subscriptions.get(subscriptionId);
|
|
1405
|
+
if (!sub || sub.isPaused) return;
|
|
1406
|
+
const channelInfo = this.channels.get(sub.channelId);
|
|
1407
|
+
const encoding = channelInfo?.encoding ?? "json";
|
|
1327
1408
|
const rosMsg = {
|
|
1328
1409
|
topic: sub.topic,
|
|
1329
|
-
schemaName,
|
|
1410
|
+
schemaName: channelInfo?.schemaName ?? "",
|
|
1330
1411
|
encoding: encoding === "json" ? "json" : "cdr",
|
|
1331
|
-
data,
|
|
1332
|
-
receiveTime: { sec, nsec },
|
|
1333
|
-
byteSize: payload.byteLength
|
|
1412
|
+
data: this.decodePayload(pending.payload, subscriptionId, encoding),
|
|
1413
|
+
receiveTime: { sec: pending.sec, nsec: pending.nsec },
|
|
1414
|
+
byteSize: pending.payload.byteLength
|
|
1334
1415
|
};
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
} catch (err) {
|
|
1340
|
-
this.logger.error("[FoxgloveClient] Subscriber callback error:", err);
|
|
1341
|
-
}
|
|
1416
|
+
try {
|
|
1417
|
+
cb(rosMsg);
|
|
1418
|
+
} catch (err) {
|
|
1419
|
+
this.logger.error("[FoxgloveClient] Subscriber callback error:", err);
|
|
1342
1420
|
}
|
|
1343
1421
|
}
|
|
1422
|
+
/** Cancel one callback's armed drain and drop its pending payload. */
|
|
1423
|
+
cancelDrain(entry) {
|
|
1424
|
+
if (entry.drainTimer !== null) {
|
|
1425
|
+
clearTimeout(entry.drainTimer);
|
|
1426
|
+
entry.drainTimer = null;
|
|
1427
|
+
}
|
|
1428
|
+
entry.pending = null;
|
|
1429
|
+
}
|
|
1430
|
+
/** Cancel every armed drain on a subscription (teardown / pause). */
|
|
1431
|
+
cancelAllDrains(sub) {
|
|
1432
|
+
for (const entry of sub.callbacks.values()) this.cancelDrain(entry);
|
|
1433
|
+
}
|
|
1344
1434
|
handleServerInfo(info) {
|
|
1345
1435
|
this.log(`Received serverInfo: ${info.name} (sessionId: ${info.sessionId ?? "none"})`);
|
|
1346
1436
|
this.serverInfoReceived = true;
|
|
@@ -1548,6 +1638,7 @@ var _FoxgloveClient = class _FoxgloveClient {
|
|
|
1548
1638
|
// ── Private: unsubscribe ─────────────────────────────────────────────────
|
|
1549
1639
|
unsubscribeTopic(topic, subscriptionId) {
|
|
1550
1640
|
const sub = this.subscriptions.get(subscriptionId);
|
|
1641
|
+
if (sub) this.cancelAllDrains(sub);
|
|
1551
1642
|
sub?.breaker.destroy();
|
|
1552
1643
|
this.subscriptions.delete(subscriptionId);
|
|
1553
1644
|
this.topicToSubscriptionId.delete(topic);
|
|
@@ -1644,6 +1735,7 @@ var _FoxgloveClient = class _FoxgloveClient {
|
|
|
1644
1735
|
}
|
|
1645
1736
|
this.ws = null;
|
|
1646
1737
|
}
|
|
1738
|
+
for (const sub of this.subscriptions.values()) this.cancelAllDrains(sub);
|
|
1647
1739
|
this.channels.clear();
|
|
1648
1740
|
this.topicToChannelId.clear();
|
|
1649
1741
|
this.subscriptions.clear();
|
|
@@ -1834,14 +1926,20 @@ var _RosbridgeClient = class _RosbridgeClient {
|
|
|
1834
1926
|
}
|
|
1835
1927
|
const userMinIntervalMs = options?.maxFrequency && options.maxFrequency > 0 ? 1e3 / options.maxFrequency : void 0;
|
|
1836
1928
|
const disableAdaptive = options?.disableAdaptive ?? false;
|
|
1929
|
+
const dispatchMode = options?.dispatchMode ?? "immediate";
|
|
1837
1930
|
const existing = this.activeSubscriptions.get(topic);
|
|
1838
1931
|
if (existing) {
|
|
1839
1932
|
existing.callbacks.set(onMessage, {
|
|
1840
1933
|
userMinIntervalMs,
|
|
1841
1934
|
disableAdaptive,
|
|
1842
|
-
lastDeliveredAt: 0
|
|
1935
|
+
lastDeliveredAt: 0,
|
|
1936
|
+
dispatchMode,
|
|
1937
|
+
pending: null,
|
|
1938
|
+
drainTimer: null
|
|
1843
1939
|
});
|
|
1844
1940
|
return () => {
|
|
1941
|
+
const entry = existing.callbacks.get(onMessage);
|
|
1942
|
+
if (entry) this.cancelDrain(entry);
|
|
1845
1943
|
existing.callbacks.delete(onMessage);
|
|
1846
1944
|
if (existing.callbacks.size === 0) {
|
|
1847
1945
|
this.unsubscribeTopic(topic);
|
|
@@ -1857,7 +1955,10 @@ var _RosbridgeClient = class _RosbridgeClient {
|
|
|
1857
1955
|
callbacks.set(onMessage, {
|
|
1858
1956
|
userMinIntervalMs,
|
|
1859
1957
|
disableAdaptive,
|
|
1860
|
-
lastDeliveredAt: 0
|
|
1958
|
+
lastDeliveredAt: 0,
|
|
1959
|
+
dispatchMode,
|
|
1960
|
+
pending: null,
|
|
1961
|
+
drainTimer: null
|
|
1861
1962
|
});
|
|
1862
1963
|
const breaker = new CircuitBreaker({
|
|
1863
1964
|
...DEFAULT_BREAKER_CONFIG,
|
|
@@ -1865,6 +1966,7 @@ var _RosbridgeClient = class _RosbridgeClient {
|
|
|
1865
1966
|
const sub = this.activeSubscriptions.get(topic);
|
|
1866
1967
|
if (!sub) return;
|
|
1867
1968
|
sub.isPaused = newState === "tripped_auto" || newState === "tripped_manual";
|
|
1969
|
+
if (sub.isPaused) this.cancelAllDrains(sub);
|
|
1868
1970
|
if (newState === "tripped_auto") {
|
|
1869
1971
|
if (this.ws && this.status === "connected") {
|
|
1870
1972
|
this.send({ op: "unsubscribe", topic });
|
|
@@ -1914,6 +2016,8 @@ var _RosbridgeClient = class _RosbridgeClient {
|
|
|
1914
2016
|
queue_length: 1
|
|
1915
2017
|
});
|
|
1916
2018
|
return () => {
|
|
2019
|
+
const entry = callbacks.get(onMessage);
|
|
2020
|
+
if (entry) this.cancelDrain(entry);
|
|
1917
2021
|
callbacks.delete(onMessage);
|
|
1918
2022
|
if (callbacks.size === 0) {
|
|
1919
2023
|
this.unsubscribeTopic(topic);
|
|
@@ -1937,7 +2041,12 @@ var _RosbridgeClient = class _RosbridgeClient {
|
|
|
1937
2041
|
}
|
|
1938
2042
|
const payload = { op: "publish", topic, msg: data };
|
|
1939
2043
|
if (options?.priority === "control") {
|
|
1940
|
-
this.controlOutbox.
|
|
2044
|
+
const existing = this.controlOutbox.findIndex((e) => e.topic === topic);
|
|
2045
|
+
if (existing >= 0) {
|
|
2046
|
+
this.controlOutbox[existing] = payload;
|
|
2047
|
+
} else {
|
|
2048
|
+
this.controlOutbox.push(payload);
|
|
2049
|
+
}
|
|
1941
2050
|
this.scheduleControlFlush();
|
|
1942
2051
|
return;
|
|
1943
2052
|
}
|
|
@@ -2208,19 +2317,26 @@ var _RosbridgeClient = class _RosbridgeClient {
|
|
|
2208
2317
|
recordBytes(sub.bandwidth, now, byteSize, mode);
|
|
2209
2318
|
return true;
|
|
2210
2319
|
}
|
|
2211
|
-
let
|
|
2212
|
-
for (const entry of sub.callbacks
|
|
2320
|
+
let anyImmediateWantsThis = false;
|
|
2321
|
+
for (const [cb, entry] of sub.callbacks) {
|
|
2213
2322
|
const interval = effectiveMinInterval(
|
|
2214
2323
|
entry.userMinIntervalMs,
|
|
2215
2324
|
entry.disableAdaptive,
|
|
2216
2325
|
sub.bandwidth
|
|
2217
2326
|
);
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2327
|
+
const eligible = interval <= 0 || now - entry.lastDeliveredAt >= interval;
|
|
2328
|
+
if (!eligible) continue;
|
|
2329
|
+
if (entry.dispatchMode === "latest-only") {
|
|
2330
|
+
entry.lastDeliveredAt = now;
|
|
2331
|
+
entry.pending = { raw: data, receivedAt: now };
|
|
2332
|
+
if (entry.drainTimer === null) {
|
|
2333
|
+
entry.drainTimer = setTimeout(() => this.drainLatestOnly(topic, cb, entry), 0);
|
|
2334
|
+
}
|
|
2335
|
+
} else {
|
|
2336
|
+
anyImmediateWantsThis = true;
|
|
2221
2337
|
}
|
|
2222
2338
|
}
|
|
2223
|
-
if (
|
|
2339
|
+
if (anyImmediateWantsThis) return false;
|
|
2224
2340
|
recordBytes(sub.bandwidth, now, byteSize, mode);
|
|
2225
2341
|
sub.breaker.recordObservation(now, sub.bandwidth.bytesPerSec, getMaxLagMs());
|
|
2226
2342
|
return true;
|
|
@@ -2236,6 +2352,7 @@ var _RosbridgeClient = class _RosbridgeClient {
|
|
|
2236
2352
|
sub.breaker.recordObservation(now, sub.bandwidth.bytesPerSec, getMaxLagMs());
|
|
2237
2353
|
const deliverTo = [];
|
|
2238
2354
|
for (const [cb, entry] of sub.callbacks) {
|
|
2355
|
+
if (entry.dispatchMode === "latest-only") continue;
|
|
2239
2356
|
const interval = effectiveMinInterval(
|
|
2240
2357
|
entry.userMinIntervalMs,
|
|
2241
2358
|
entry.disableAdaptive,
|
|
@@ -2266,6 +2383,55 @@ var _RosbridgeClient = class _RosbridgeClient {
|
|
|
2266
2383
|
}
|
|
2267
2384
|
}
|
|
2268
2385
|
}
|
|
2386
|
+
/**
|
|
2387
|
+
* Drain one `latest-only` callback's pending frame: parse the survivor and
|
|
2388
|
+
* deliver it. State (`pending`, `drainTimer`) is cleared *before* the
|
|
2389
|
+
* callback runs, so a throwing callback never wedges future delivery. Bails
|
|
2390
|
+
* if the subscription was torn down or paused while the drain was armed (no
|
|
2391
|
+
* post-teardown delivery).
|
|
2392
|
+
*/
|
|
2393
|
+
drainLatestOnly(topic, cb, entry) {
|
|
2394
|
+
entry.drainTimer = null;
|
|
2395
|
+
const pending = entry.pending;
|
|
2396
|
+
entry.pending = null;
|
|
2397
|
+
if (!pending) return;
|
|
2398
|
+
const sub = this.activeSubscriptions.get(topic);
|
|
2399
|
+
if (!sub || sub.isPaused) return;
|
|
2400
|
+
let parsed;
|
|
2401
|
+
try {
|
|
2402
|
+
parsed = JSON.parse(pending.raw);
|
|
2403
|
+
} catch {
|
|
2404
|
+
return;
|
|
2405
|
+
}
|
|
2406
|
+
const rosMsg = {
|
|
2407
|
+
topic,
|
|
2408
|
+
schemaName: sub.schemaName,
|
|
2409
|
+
encoding: "json",
|
|
2410
|
+
data: parsed.msg ?? {},
|
|
2411
|
+
receiveTime: {
|
|
2412
|
+
sec: Math.floor(pending.receivedAt / 1e3),
|
|
2413
|
+
nsec: pending.receivedAt % 1e3 * 1e6
|
|
2414
|
+
},
|
|
2415
|
+
byteSize: pending.raw.length
|
|
2416
|
+
};
|
|
2417
|
+
try {
|
|
2418
|
+
cb(rosMsg);
|
|
2419
|
+
} catch (err) {
|
|
2420
|
+
this.logger.error("[RosbridgeClient] Subscriber callback error:", err);
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2423
|
+
/** Cancel one callback's armed drain and drop its pending frame. */
|
|
2424
|
+
cancelDrain(entry) {
|
|
2425
|
+
if (entry.drainTimer !== null) {
|
|
2426
|
+
clearTimeout(entry.drainTimer);
|
|
2427
|
+
entry.drainTimer = null;
|
|
2428
|
+
}
|
|
2429
|
+
entry.pending = null;
|
|
2430
|
+
}
|
|
2431
|
+
/** Cancel every armed drain on a subscription (teardown / pause). */
|
|
2432
|
+
cancelAllDrains(sub) {
|
|
2433
|
+
for (const entry of sub.callbacks.values()) this.cancelDrain(entry);
|
|
2434
|
+
}
|
|
2269
2435
|
handleServiceResponse(msg) {
|
|
2270
2436
|
const id = msg.id;
|
|
2271
2437
|
const pending = this.pendingServiceCalls.get(id);
|
|
@@ -2284,6 +2450,7 @@ var _RosbridgeClient = class _RosbridgeClient {
|
|
|
2284
2450
|
// ── Private: topic management ──────────────────────────────────────────
|
|
2285
2451
|
unsubscribeTopic(topic) {
|
|
2286
2452
|
const sub = this.activeSubscriptions.get(topic);
|
|
2453
|
+
if (sub) this.cancelAllDrains(sub);
|
|
2287
2454
|
sub?.breaker.destroy();
|
|
2288
2455
|
this.activeSubscriptions.delete(topic);
|
|
2289
2456
|
if (this.ws && this.status === "connected" && !sub?.isPaused) {
|
|
@@ -2406,6 +2573,7 @@ var _RosbridgeClient = class _RosbridgeClient {
|
|
|
2406
2573
|
cleanupConnection() {
|
|
2407
2574
|
this.clearConnectionTimeout();
|
|
2408
2575
|
this.stopServicesPoll();
|
|
2576
|
+
for (const sub of this.activeSubscriptions.values()) this.cancelAllDrains(sub);
|
|
2409
2577
|
this.activeSubscriptions.clear();
|
|
2410
2578
|
for (const [, pending] of this.pendingServiceCalls) {
|
|
2411
2579
|
clearTimeout(pending.timer);
|
|
@@ -2589,6 +2757,11 @@ var ProtocolManager = class {
|
|
|
2589
2757
|
}
|
|
2590
2758
|
};
|
|
2591
2759
|
|
|
2760
|
+
// src/materializeBytes.ts
|
|
2761
|
+
function materializeBytes(view) {
|
|
2762
|
+
return new Uint8Array(view);
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2592
2765
|
exports.DEFAULT_PORTS = DEFAULT_PORTS;
|
|
2593
2766
|
exports.DEFAULT_PRESETS = DEFAULT_PRESETS;
|
|
2594
2767
|
exports.FoxgloveClient = FoxgloveClient;
|
|
@@ -2600,6 +2773,8 @@ exports.getLagHistoryCsv = getLagHistoryCsv;
|
|
|
2600
2773
|
exports.getLagStats = getLagStats;
|
|
2601
2774
|
exports.getMaxLagMs = getMaxLagMs;
|
|
2602
2775
|
exports.jsonSchemaToTemplate = jsonSchemaToTemplate;
|
|
2776
|
+
exports.matchesSchema = matchesSchema;
|
|
2777
|
+
exports.materializeBytes = materializeBytes;
|
|
2603
2778
|
exports.schemaToTemplate = schemaToTemplate;
|
|
2604
2779
|
//# sourceMappingURL=index.cjs.map
|
|
2605
2780
|
//# sourceMappingURL=index.cjs.map
|