ros-mobile-bridge 0.1.2 → 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 +14 -0
- package/README.md +21 -0
- package/dist/index.cjs +204 -39
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +177 -5
- package/dist/index.d.ts +177 -5
- package/dist/index.mjs +203 -40
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,18 @@ 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
|
+
|
|
7
19
|
## [0.1.2] - 2026-06-01
|
|
8
20
|
|
|
9
21
|
### Performance
|
|
@@ -58,5 +70,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
58
70
|
|
|
59
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.
|
|
60
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
|
|
61
75
|
[0.1.1]: https://github.com/AuriLabsTech/ros-mobile-bridge/releases/tag/v0.1.1
|
|
62
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
|
@@ -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);
|
|
@@ -1306,46 +1334,103 @@ var _FoxgloveClient = class _FoxgloveClient {
|
|
|
1306
1334
|
}
|
|
1307
1335
|
}
|
|
1308
1336
|
if (deliverTo.length === 0) return;
|
|
1309
|
-
|
|
1310
|
-
const
|
|
1311
|
-
|
|
1312
|
-
|
|
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) {
|
|
1313
1375
|
if (encoding === "json") {
|
|
1314
1376
|
try {
|
|
1315
|
-
|
|
1316
|
-
data = JSON.parse(text);
|
|
1377
|
+
return JSON.parse(TEXT_DECODER.decode(payload));
|
|
1317
1378
|
} catch {
|
|
1318
|
-
|
|
1379
|
+
return payload;
|
|
1319
1380
|
}
|
|
1320
|
-
}
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
}
|
|
1328
|
-
} else {
|
|
1329
|
-
data = payload;
|
|
1381
|
+
}
|
|
1382
|
+
const reader = this.messageReaders.get(subscriptionId);
|
|
1383
|
+
if (reader) {
|
|
1384
|
+
try {
|
|
1385
|
+
return reader.readMessage(payload);
|
|
1386
|
+
} catch {
|
|
1387
|
+
return payload;
|
|
1330
1388
|
}
|
|
1331
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";
|
|
1332
1408
|
const rosMsg = {
|
|
1333
1409
|
topic: sub.topic,
|
|
1334
|
-
schemaName,
|
|
1410
|
+
schemaName: channelInfo?.schemaName ?? "",
|
|
1335
1411
|
encoding: encoding === "json" ? "json" : "cdr",
|
|
1336
|
-
data,
|
|
1337
|
-
receiveTime: { sec, nsec },
|
|
1338
|
-
byteSize: payload.byteLength
|
|
1412
|
+
data: this.decodePayload(pending.payload, subscriptionId, encoding),
|
|
1413
|
+
receiveTime: { sec: pending.sec, nsec: pending.nsec },
|
|
1414
|
+
byteSize: pending.payload.byteLength
|
|
1339
1415
|
};
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
} catch (err) {
|
|
1345
|
-
this.logger.error("[FoxgloveClient] Subscriber callback error:", err);
|
|
1346
|
-
}
|
|
1416
|
+
try {
|
|
1417
|
+
cb(rosMsg);
|
|
1418
|
+
} catch (err) {
|
|
1419
|
+
this.logger.error("[FoxgloveClient] Subscriber callback error:", err);
|
|
1347
1420
|
}
|
|
1348
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
|
+
}
|
|
1349
1434
|
handleServerInfo(info) {
|
|
1350
1435
|
this.log(`Received serverInfo: ${info.name} (sessionId: ${info.sessionId ?? "none"})`);
|
|
1351
1436
|
this.serverInfoReceived = true;
|
|
@@ -1553,6 +1638,7 @@ var _FoxgloveClient = class _FoxgloveClient {
|
|
|
1553
1638
|
// ── Private: unsubscribe ─────────────────────────────────────────────────
|
|
1554
1639
|
unsubscribeTopic(topic, subscriptionId) {
|
|
1555
1640
|
const sub = this.subscriptions.get(subscriptionId);
|
|
1641
|
+
if (sub) this.cancelAllDrains(sub);
|
|
1556
1642
|
sub?.breaker.destroy();
|
|
1557
1643
|
this.subscriptions.delete(subscriptionId);
|
|
1558
1644
|
this.topicToSubscriptionId.delete(topic);
|
|
@@ -1649,6 +1735,7 @@ var _FoxgloveClient = class _FoxgloveClient {
|
|
|
1649
1735
|
}
|
|
1650
1736
|
this.ws = null;
|
|
1651
1737
|
}
|
|
1738
|
+
for (const sub of this.subscriptions.values()) this.cancelAllDrains(sub);
|
|
1652
1739
|
this.channels.clear();
|
|
1653
1740
|
this.topicToChannelId.clear();
|
|
1654
1741
|
this.subscriptions.clear();
|
|
@@ -1839,14 +1926,20 @@ var _RosbridgeClient = class _RosbridgeClient {
|
|
|
1839
1926
|
}
|
|
1840
1927
|
const userMinIntervalMs = options?.maxFrequency && options.maxFrequency > 0 ? 1e3 / options.maxFrequency : void 0;
|
|
1841
1928
|
const disableAdaptive = options?.disableAdaptive ?? false;
|
|
1929
|
+
const dispatchMode = options?.dispatchMode ?? "immediate";
|
|
1842
1930
|
const existing = this.activeSubscriptions.get(topic);
|
|
1843
1931
|
if (existing) {
|
|
1844
1932
|
existing.callbacks.set(onMessage, {
|
|
1845
1933
|
userMinIntervalMs,
|
|
1846
1934
|
disableAdaptive,
|
|
1847
|
-
lastDeliveredAt: 0
|
|
1935
|
+
lastDeliveredAt: 0,
|
|
1936
|
+
dispatchMode,
|
|
1937
|
+
pending: null,
|
|
1938
|
+
drainTimer: null
|
|
1848
1939
|
});
|
|
1849
1940
|
return () => {
|
|
1941
|
+
const entry = existing.callbacks.get(onMessage);
|
|
1942
|
+
if (entry) this.cancelDrain(entry);
|
|
1850
1943
|
existing.callbacks.delete(onMessage);
|
|
1851
1944
|
if (existing.callbacks.size === 0) {
|
|
1852
1945
|
this.unsubscribeTopic(topic);
|
|
@@ -1862,7 +1955,10 @@ var _RosbridgeClient = class _RosbridgeClient {
|
|
|
1862
1955
|
callbacks.set(onMessage, {
|
|
1863
1956
|
userMinIntervalMs,
|
|
1864
1957
|
disableAdaptive,
|
|
1865
|
-
lastDeliveredAt: 0
|
|
1958
|
+
lastDeliveredAt: 0,
|
|
1959
|
+
dispatchMode,
|
|
1960
|
+
pending: null,
|
|
1961
|
+
drainTimer: null
|
|
1866
1962
|
});
|
|
1867
1963
|
const breaker = new CircuitBreaker({
|
|
1868
1964
|
...DEFAULT_BREAKER_CONFIG,
|
|
@@ -1870,6 +1966,7 @@ var _RosbridgeClient = class _RosbridgeClient {
|
|
|
1870
1966
|
const sub = this.activeSubscriptions.get(topic);
|
|
1871
1967
|
if (!sub) return;
|
|
1872
1968
|
sub.isPaused = newState === "tripped_auto" || newState === "tripped_manual";
|
|
1969
|
+
if (sub.isPaused) this.cancelAllDrains(sub);
|
|
1873
1970
|
if (newState === "tripped_auto") {
|
|
1874
1971
|
if (this.ws && this.status === "connected") {
|
|
1875
1972
|
this.send({ op: "unsubscribe", topic });
|
|
@@ -1919,6 +2016,8 @@ var _RosbridgeClient = class _RosbridgeClient {
|
|
|
1919
2016
|
queue_length: 1
|
|
1920
2017
|
});
|
|
1921
2018
|
return () => {
|
|
2019
|
+
const entry = callbacks.get(onMessage);
|
|
2020
|
+
if (entry) this.cancelDrain(entry);
|
|
1922
2021
|
callbacks.delete(onMessage);
|
|
1923
2022
|
if (callbacks.size === 0) {
|
|
1924
2023
|
this.unsubscribeTopic(topic);
|
|
@@ -2218,19 +2317,26 @@ var _RosbridgeClient = class _RosbridgeClient {
|
|
|
2218
2317
|
recordBytes(sub.bandwidth, now, byteSize, mode);
|
|
2219
2318
|
return true;
|
|
2220
2319
|
}
|
|
2221
|
-
let
|
|
2222
|
-
for (const entry of sub.callbacks
|
|
2320
|
+
let anyImmediateWantsThis = false;
|
|
2321
|
+
for (const [cb, entry] of sub.callbacks) {
|
|
2223
2322
|
const interval = effectiveMinInterval(
|
|
2224
2323
|
entry.userMinIntervalMs,
|
|
2225
2324
|
entry.disableAdaptive,
|
|
2226
2325
|
sub.bandwidth
|
|
2227
2326
|
);
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
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;
|
|
2231
2337
|
}
|
|
2232
2338
|
}
|
|
2233
|
-
if (
|
|
2339
|
+
if (anyImmediateWantsThis) return false;
|
|
2234
2340
|
recordBytes(sub.bandwidth, now, byteSize, mode);
|
|
2235
2341
|
sub.breaker.recordObservation(now, sub.bandwidth.bytesPerSec, getMaxLagMs());
|
|
2236
2342
|
return true;
|
|
@@ -2246,6 +2352,7 @@ var _RosbridgeClient = class _RosbridgeClient {
|
|
|
2246
2352
|
sub.breaker.recordObservation(now, sub.bandwidth.bytesPerSec, getMaxLagMs());
|
|
2247
2353
|
const deliverTo = [];
|
|
2248
2354
|
for (const [cb, entry] of sub.callbacks) {
|
|
2355
|
+
if (entry.dispatchMode === "latest-only") continue;
|
|
2249
2356
|
const interval = effectiveMinInterval(
|
|
2250
2357
|
entry.userMinIntervalMs,
|
|
2251
2358
|
entry.disableAdaptive,
|
|
@@ -2276,6 +2383,55 @@ var _RosbridgeClient = class _RosbridgeClient {
|
|
|
2276
2383
|
}
|
|
2277
2384
|
}
|
|
2278
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
|
+
}
|
|
2279
2435
|
handleServiceResponse(msg) {
|
|
2280
2436
|
const id = msg.id;
|
|
2281
2437
|
const pending = this.pendingServiceCalls.get(id);
|
|
@@ -2294,6 +2450,7 @@ var _RosbridgeClient = class _RosbridgeClient {
|
|
|
2294
2450
|
// ── Private: topic management ──────────────────────────────────────────
|
|
2295
2451
|
unsubscribeTopic(topic) {
|
|
2296
2452
|
const sub = this.activeSubscriptions.get(topic);
|
|
2453
|
+
if (sub) this.cancelAllDrains(sub);
|
|
2297
2454
|
sub?.breaker.destroy();
|
|
2298
2455
|
this.activeSubscriptions.delete(topic);
|
|
2299
2456
|
if (this.ws && this.status === "connected" && !sub?.isPaused) {
|
|
@@ -2416,6 +2573,7 @@ var _RosbridgeClient = class _RosbridgeClient {
|
|
|
2416
2573
|
cleanupConnection() {
|
|
2417
2574
|
this.clearConnectionTimeout();
|
|
2418
2575
|
this.stopServicesPoll();
|
|
2576
|
+
for (const sub of this.activeSubscriptions.values()) this.cancelAllDrains(sub);
|
|
2419
2577
|
this.activeSubscriptions.clear();
|
|
2420
2578
|
for (const [, pending] of this.pendingServiceCalls) {
|
|
2421
2579
|
clearTimeout(pending.timer);
|
|
@@ -2599,6 +2757,11 @@ var ProtocolManager = class {
|
|
|
2599
2757
|
}
|
|
2600
2758
|
};
|
|
2601
2759
|
|
|
2760
|
+
// src/materializeBytes.ts
|
|
2761
|
+
function materializeBytes(view) {
|
|
2762
|
+
return new Uint8Array(view);
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2602
2765
|
exports.DEFAULT_PORTS = DEFAULT_PORTS;
|
|
2603
2766
|
exports.DEFAULT_PRESETS = DEFAULT_PRESETS;
|
|
2604
2767
|
exports.FoxgloveClient = FoxgloveClient;
|
|
@@ -2610,6 +2773,8 @@ exports.getLagHistoryCsv = getLagHistoryCsv;
|
|
|
2610
2773
|
exports.getLagStats = getLagStats;
|
|
2611
2774
|
exports.getMaxLagMs = getMaxLagMs;
|
|
2612
2775
|
exports.jsonSchemaToTemplate = jsonSchemaToTemplate;
|
|
2776
|
+
exports.matchesSchema = matchesSchema;
|
|
2777
|
+
exports.materializeBytes = materializeBytes;
|
|
2613
2778
|
exports.schemaToTemplate = schemaToTemplate;
|
|
2614
2779
|
//# sourceMappingURL=index.cjs.map
|
|
2615
2780
|
//# sourceMappingURL=index.cjs.map
|