ros-mobile-bridge 0.1.0 → 0.1.1
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 +19 -0
- package/ROADMAP.md +6 -1
- package/dist/index.cjs +393 -76
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +91 -7
- package/dist/index.d.ts +91 -7
- package/dist/index.mjs +394 -77
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,24 @@ 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.1] - 2026-05-27
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **`FoxgloveClient.callService` against `foxglove_bridge >= 3.2.6` (foxglove-sdk-cpp v0.18.0+) hung until timeout because the bridge rejects JSON-encoded service requests** even when it advertises `supportedEncodings: ["cdr", "json"]` — that capability applies to topic messages, not service calls. Service-call requests are now CDR-encoded using the per-service `requestSchema` shipped by the bridge in `advertiseServices`; the response is decoded with the corresponding `responseSchema`. The fix is backward-compatible with older bridges (CDR is the canonical ROS 2 service encoding and has always been accepted). Verified at the wire level against ROS Jazzy + `foxglove_bridge` 3.2.6.
|
|
12
|
+
- **`FoxgloveClient` now handles the `serviceCallFailure` op** alongside `serviceCallResponse`. Failures from the bridge (unknown service, malformed request, schema mismatch, unsupported encoding) reject the in-flight call promise immediately with the bridge's message instead of being silently dropped until the 30 s timeout.
|
|
13
|
+
- **`FoxgloveClient.callService` against schemaless service advertisements now works for empty requests.** `foxglove_bridge` 3.2.6+ commonly advertises services with their type name but without inline request-schema text — the normal shape for services discovered via ROS 2 graph introspection rather than explicit `.srv` files. The client now detects this case: if the caller's request is empty (`{}`, `null`, or `undefined`), it sends only the 4-byte CDR encapsulation header and the bridge default-constructs the request server-side from the known type. Non-empty requests against schemaless services still surface a clear error explaining the limitation (the encoder genuinely cannot serialize without field-layout information).
|
|
14
|
+
- **`FoxgloveClient.callService` now resolves request and response schemas through a layered fallback.** Order of resolution: (1) the bridge-advertised `requestSchema` / `responseSchema` (authoritative when present), (2) a bundled IDL for six well-known ROS 2 system services — `rcl_interfaces/srv/{ListParameters,GetParameters,SetParameters,DescribeParameters,GetParameterTypes}` and `action_msgs/srv/CancelGoal` — which `foxglove_bridge` 3.2.6+ commonly discovers via introspection without shipping schemas inline, (3) the 4-byte encapsulation-header fallback for empty requests when neither source has a schema. Parameter operations and action cancellation now work against any compliant bridge configuration, not only ones that ship `.srv`-derived schemas. When defs are available, an empty caller request `{}` is now filled with zero values via `schemaToTemplate` rather than rejected on missing fields, making `{}` a stable "default request" sentinel across sim and real-bridge setups.
|
|
15
|
+
- **`FoxgloveClient` now dispatches inbound binary opcode `0x03` SERVICE_CALL_RESPONSE frames.** Previous revisions consumed only the JSON-op response shape; `foxglove-sdk-cpp` 0.18.0+ defaults to the binary 0x03 frame for every service-call response, so pending callIds never resolved and surfaced as 30-second timeouts. The binary path and the legacy JSON-op path now route through a shared decoder; both CDR and JSON response payloads are handled.
|
|
16
|
+
- **`FoxgloveClient` now sends SERVICE_CALL_REQUEST as the spec-defined binary opcode `0x02` frame** instead of the JSON op of the same name. The JSON form is not in the Foxglove WS v1 spec and is rejected by current bridges with a level-2 status message, leaving callIds hanging until the 30-second timeout.
|
|
17
|
+
- **`FoxgloveClient` no longer emits the JSON `ping` keep-alive every 5 seconds.** The op is not in the Foxglove WS v1 spec; modern bridges emit a level-2 status in response to each one. WebSocket-level RFC 6455 ping/pong handles connection liveness at the transport layer and does not need application-level emulation. Existing `pong` responses from older bridges are ignored without error.
|
|
18
|
+
- **`FoxgloveClient` now fast-fails in-flight service calls when the bridge sends a level-2 status message naming `serviceCallRequest`.** These undirected status messages do not carry a callId, so prior versions had no way to associate them with the failing call and all in-flight callIds hung until their 30-second timeout. The substring match keeps the rejection scoped to service-call surfaces; unrelated level-2 messages do not tear down healthy calls.
|
|
19
|
+
- **`FoxgloveClient` outbound binary frames are now sent as `Uint8Array`, not raw `ArrayBuffer`.** React Native's WebSocket native bridge silently drops `send(ArrayBuffer)` payloads above roughly 400 bytes; this manifested as 16-name `get_parameters` requests never leaving the device (confirmed via tcpdump). Sending via `Uint8Array` uses a different RN native serializer that handles every payload size we send. Same bytes on the wire for browsers and Node; this is load-bearing for React Native consumers and is covered by a regression test.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- **`ProtocolClientOptions.onLatency` is no longer driven by `FoxgloveClient`** as a consequence of removing the JSON `ping`/`pong` keep-alive (see Fixed). The option remains in the public type and is still driven by `RosbridgeClient` via its dedicated latency-probe path. Foxglove WS RTT measurement will return when a portable spec-compliant signal is available; browsers do not expose `ws.ping()` from JavaScript.
|
|
24
|
+
|
|
7
25
|
## [0.1.0] - 2026-05-20
|
|
8
26
|
|
|
9
27
|
### Added
|
|
@@ -25,4 +43,5 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
25
43
|
|
|
26
44
|
- `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.
|
|
27
45
|
|
|
46
|
+
[0.1.1]: https://github.com/AuriLabsTech/ros-mobile-bridge/releases/tag/v0.1.1
|
|
28
47
|
[0.1.0]: https://github.com/AuriLabsTech/ros-mobile-bridge/releases/tag/v0.1.0
|
package/ROADMAP.md
CHANGED
|
@@ -30,7 +30,12 @@ The `ZenohClient` skeleton already exists in `src/ZenohClient.ts` as roadmap-as-
|
|
|
30
30
|
- Integration tests against a real `rmw_zenoh` setup (`zenohd` + `zenoh-plugin-remote-api` + a ROS 2 node) in CI.
|
|
31
31
|
- Documentation: a dedicated section in the README describing when Zenoh is the right transport choice versus the existing two, and a runnable example under `examples/zenoh/`.
|
|
32
32
|
|
|
33
|
-
**
|
|
33
|
+
**Open questions resolved first (a spike opens this milestone):**
|
|
34
|
+
|
|
35
|
+
- **React Native support is unverified.** `@eclipse-zenoh/zenoh-ts` ships a WebAssembly module for key-expression handling. WASM runs cleanly in browsers, Node, and Electron, but React Native (Hermes) WASM support is limited. If the dependency does not run under React Native, the Zenoh transport may initially target browser / Node / Electron with React Native to follow.
|
|
36
|
+
- **Schema acquisition differs from Foxglove WS.** `rmw_zenoh` does not distribute message definitions inline, so CDR decoding needs definitions sourced another way (bundled common interfaces, consumer-provided, or a registry). The spike scopes this.
|
|
37
|
+
|
|
38
|
+
**Why this matters:** Zenoh is the recommended middleware for the next generation of ROS 2 deployments (it underlies `rmw_zenoh`, which the OSRF has positioned as a serious alternative to the default DDS-based middleware). No existing JavaScript or TypeScript library provides a ROS 2 client over Zenoh for these runtimes; `v0.2.0` aims to close that gap, with mobile-runtime support contingent on the verification above.
|
|
34
39
|
|
|
35
40
|
## v0.3.0 — Hardening and community
|
|
36
41
|
|
package/dist/index.cjs
CHANGED
|
@@ -478,6 +478,112 @@ function jsonSchemaToTemplate(schema) {
|
|
|
478
478
|
return null;
|
|
479
479
|
}
|
|
480
480
|
|
|
481
|
+
// src/builtinSchemas.ts
|
|
482
|
+
var SEP = "\n================================================================================\n";
|
|
483
|
+
var MSG_PARAMETER_VALUE = `MSG: rcl_interfaces/ParameterValue
|
|
484
|
+
uint8 type
|
|
485
|
+
bool bool_value
|
|
486
|
+
int64 integer_value
|
|
487
|
+
float64 double_value
|
|
488
|
+
string string_value
|
|
489
|
+
byte[] byte_array_value
|
|
490
|
+
bool[] bool_array_value
|
|
491
|
+
int64[] integer_array_value
|
|
492
|
+
float64[] double_array_value
|
|
493
|
+
string[] string_array_value`;
|
|
494
|
+
var MSG_PARAMETER = `MSG: rcl_interfaces/Parameter
|
|
495
|
+
string name
|
|
496
|
+
ParameterValue value`;
|
|
497
|
+
var MSG_SET_PARAMETERS_RESULT = `MSG: rcl_interfaces/SetParametersResult
|
|
498
|
+
bool successful
|
|
499
|
+
string reason`;
|
|
500
|
+
var MSG_LIST_PARAMETERS_RESULT = `MSG: rcl_interfaces/ListParametersResult
|
|
501
|
+
string[] names
|
|
502
|
+
string[] prefixes`;
|
|
503
|
+
var MSG_FLOATING_POINT_RANGE = `MSG: rcl_interfaces/FloatingPointRange
|
|
504
|
+
float64 from_value
|
|
505
|
+
float64 to_value
|
|
506
|
+
float64 step`;
|
|
507
|
+
var MSG_INTEGER_RANGE = `MSG: rcl_interfaces/IntegerRange
|
|
508
|
+
int64 from_value
|
|
509
|
+
int64 to_value
|
|
510
|
+
uint64 step`;
|
|
511
|
+
var MSG_PARAMETER_DESCRIPTOR = `MSG: rcl_interfaces/ParameterDescriptor
|
|
512
|
+
string name
|
|
513
|
+
uint8 type
|
|
514
|
+
string description
|
|
515
|
+
string additional_constraints
|
|
516
|
+
bool read_only
|
|
517
|
+
bool dynamic_typing
|
|
518
|
+
FloatingPointRange[] floating_point_range
|
|
519
|
+
IntegerRange[] integer_range`;
|
|
520
|
+
var MSG_TIME = `MSG: builtin_interfaces/Time
|
|
521
|
+
int32 sec
|
|
522
|
+
uint32 nanosec`;
|
|
523
|
+
var MSG_UUID = `MSG: unique_identifier_msgs/UUID
|
|
524
|
+
uint8[16] uuid`;
|
|
525
|
+
var MSG_GOAL_INFO = `MSG: action_msgs/GoalInfo
|
|
526
|
+
unique_identifier_msgs/UUID goal_id
|
|
527
|
+
builtin_interfaces/Time stamp`;
|
|
528
|
+
var LIST_PARAMETERS_REQUEST = `uint64 DEPTH_RECURSIVE=0
|
|
529
|
+
string[] prefixes
|
|
530
|
+
uint64 depth`;
|
|
531
|
+
var LIST_PARAMETERS_RESPONSE = ["ListParametersResult result", MSG_LIST_PARAMETERS_RESULT].join(SEP);
|
|
532
|
+
var GET_PARAMETERS_REQUEST = "string[] names";
|
|
533
|
+
var GET_PARAMETERS_RESPONSE = ["ParameterValue[] values", MSG_PARAMETER_VALUE].join(SEP);
|
|
534
|
+
var SET_PARAMETERS_REQUEST = ["Parameter[] parameters", MSG_PARAMETER, MSG_PARAMETER_VALUE].join(SEP);
|
|
535
|
+
var SET_PARAMETERS_RESPONSE = ["SetParametersResult[] results", MSG_SET_PARAMETERS_RESULT].join(SEP);
|
|
536
|
+
var DESCRIBE_PARAMETERS_REQUEST = "string[] names";
|
|
537
|
+
var DESCRIBE_PARAMETERS_RESPONSE = [
|
|
538
|
+
"ParameterDescriptor[] descriptors",
|
|
539
|
+
MSG_PARAMETER_DESCRIPTOR,
|
|
540
|
+
MSG_FLOATING_POINT_RANGE,
|
|
541
|
+
MSG_INTEGER_RANGE
|
|
542
|
+
].join(SEP);
|
|
543
|
+
var GET_PARAMETER_TYPES_REQUEST = "string[] names";
|
|
544
|
+
var GET_PARAMETER_TYPES_RESPONSE = "uint8[] types";
|
|
545
|
+
var CANCEL_GOAL_REQUEST = ["GoalInfo goal_info", MSG_GOAL_INFO, MSG_UUID, MSG_TIME].join(SEP);
|
|
546
|
+
var CANCEL_GOAL_RESPONSE = [
|
|
547
|
+
`int8 ERROR_NONE = 0
|
|
548
|
+
int8 ERROR_REJECTED = 1
|
|
549
|
+
int8 ERROR_UNKNOWN_GOAL_ID = 2
|
|
550
|
+
int8 ERROR_GOAL_TERMINATED = 3
|
|
551
|
+
int8 return_code
|
|
552
|
+
GoalInfo[] goals_canceling`,
|
|
553
|
+
MSG_GOAL_INFO,
|
|
554
|
+
MSG_UUID,
|
|
555
|
+
MSG_TIME
|
|
556
|
+
].join(SEP);
|
|
557
|
+
var BUNDLED = {
|
|
558
|
+
"rcl_interfaces/srv/ListParameters": {
|
|
559
|
+
request: LIST_PARAMETERS_REQUEST,
|
|
560
|
+
response: LIST_PARAMETERS_RESPONSE
|
|
561
|
+
},
|
|
562
|
+
"rcl_interfaces/srv/GetParameters": {
|
|
563
|
+
request: GET_PARAMETERS_REQUEST,
|
|
564
|
+
response: GET_PARAMETERS_RESPONSE
|
|
565
|
+
},
|
|
566
|
+
"rcl_interfaces/srv/SetParameters": {
|
|
567
|
+
request: SET_PARAMETERS_REQUEST,
|
|
568
|
+
response: SET_PARAMETERS_RESPONSE
|
|
569
|
+
},
|
|
570
|
+
"rcl_interfaces/srv/DescribeParameters": {
|
|
571
|
+
request: DESCRIBE_PARAMETERS_REQUEST,
|
|
572
|
+
response: DESCRIBE_PARAMETERS_RESPONSE
|
|
573
|
+
},
|
|
574
|
+
"rcl_interfaces/srv/GetParameterTypes": {
|
|
575
|
+
request: GET_PARAMETER_TYPES_REQUEST,
|
|
576
|
+
response: GET_PARAMETER_TYPES_RESPONSE
|
|
577
|
+
},
|
|
578
|
+
"action_msgs/srv/CancelGoal": {
|
|
579
|
+
request: CANCEL_GOAL_REQUEST,
|
|
580
|
+
response: CANCEL_GOAL_RESPONSE
|
|
581
|
+
}
|
|
582
|
+
};
|
|
583
|
+
function getBundledServiceSchema(serviceType) {
|
|
584
|
+
return BUNDLED[serviceType] ?? null;
|
|
585
|
+
}
|
|
586
|
+
|
|
481
587
|
// src/FoxgloveClient.ts
|
|
482
588
|
var NOOP_LOGGER = { log() {
|
|
483
589
|
}, warn() {
|
|
@@ -485,9 +591,46 @@ var NOOP_LOGGER = { log() {
|
|
|
485
591
|
} };
|
|
486
592
|
var TEXT_ENCODER = new TextEncoder();
|
|
487
593
|
var TEXT_DECODER = new TextDecoder();
|
|
594
|
+
function base64ToUint8(b64) {
|
|
595
|
+
const binary = atob(b64);
|
|
596
|
+
const out = new Uint8Array(binary.length);
|
|
597
|
+
for (let i = 0; i < binary.length; i++) {
|
|
598
|
+
out[i] = binary.charCodeAt(i);
|
|
599
|
+
}
|
|
600
|
+
return out;
|
|
601
|
+
}
|
|
602
|
+
var CDR_LE_HEADER = new Uint8Array([0, 1, 0, 0]);
|
|
603
|
+
function isEmptyRequest(request) {
|
|
604
|
+
if (request === null || request === void 0) return true;
|
|
605
|
+
if (typeof request !== "object" || Array.isArray(request)) return false;
|
|
606
|
+
return Object.keys(request).length === 0;
|
|
607
|
+
}
|
|
608
|
+
function parseFoxgloveSchema(schemaStr, encodingHint) {
|
|
609
|
+
const declared = (encodingHint ?? "").toLowerCase();
|
|
610
|
+
const tryRos2idl = () => {
|
|
611
|
+
try {
|
|
612
|
+
return ros2idlParser.parseRos2idl(schemaStr);
|
|
613
|
+
} catch {
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
};
|
|
617
|
+
const tryRos2msg = () => {
|
|
618
|
+
try {
|
|
619
|
+
return rosmsg.parse(schemaStr, { ros2: true });
|
|
620
|
+
} catch {
|
|
621
|
+
return null;
|
|
622
|
+
}
|
|
623
|
+
};
|
|
624
|
+
const order = declared === "ros2idl" ? [tryRos2idl, tryRos2msg] : [tryRos2msg, tryRos2idl];
|
|
625
|
+
for (const attempt of order) {
|
|
626
|
+
const defs = attempt();
|
|
627
|
+
if (defs && defs.length > 0) return defs;
|
|
628
|
+
}
|
|
629
|
+
throw new Error(
|
|
630
|
+
`Could not parse schema (encodingHint=${encodingHint ?? "none"}, preview="${schemaStr.substring(0, 80)}")`
|
|
631
|
+
);
|
|
632
|
+
}
|
|
488
633
|
var SUBPROTOCOLS = ["foxglove.sdk.v1", "foxglove.websocket.v1"];
|
|
489
|
-
var PING_INTERVAL_MS = 5e3;
|
|
490
|
-
var PONG_TIMEOUT_MS = 1e4;
|
|
491
634
|
var MAX_RECONNECT_ATTEMPTS = 5;
|
|
492
635
|
var BASE_RECONNECT_DELAY_MS = 1e3;
|
|
493
636
|
var CONNECTION_TIMEOUT_MS = 1e4;
|
|
@@ -498,7 +641,6 @@ var ZERO_TWIST = {
|
|
|
498
641
|
var CMD_VEL_SCHEMA = "geometry_msgs/msg/Twist";
|
|
499
642
|
var _FoxgloveClient = class _FoxgloveClient {
|
|
500
643
|
constructor(options) {
|
|
501
|
-
__publicField(this, "onLatency");
|
|
502
644
|
__publicField(this, "logger");
|
|
503
645
|
__publicField(this, "getThrottleMode");
|
|
504
646
|
__publicField(this, "presets");
|
|
@@ -532,10 +674,21 @@ var _FoxgloveClient = class _FoxgloveClient {
|
|
|
532
674
|
__publicField(this, "nextServiceCallId", 1);
|
|
533
675
|
__publicField(this, "pendingServiceCalls", /* @__PURE__ */ new Map());
|
|
534
676
|
__publicField(this, "availableServices", /* @__PURE__ */ new Map());
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
677
|
+
/**
|
|
678
|
+
* Per-service CDR codecs. Compiled lazily on first call from the schema
|
|
679
|
+
* the bridge shipped in `advertiseServices`. Foxglove WS service requests
|
|
680
|
+
* and responses are CDR-encoded for ROS 2; the codec parses the bare
|
|
681
|
+
* Request/Response struct (the bridge handles `rmw_request_id_t` wrapping).
|
|
682
|
+
*/
|
|
683
|
+
// The defs caches hold the parsed MessageDefinition[] keyed by service id.
|
|
684
|
+
// Keeping them alongside the writer/reader caches buys two things: cheap
|
|
685
|
+
// lookup of zero-value defaults (via schemaToTemplate) when the caller
|
|
686
|
+
// passes an empty request, and a single source of truth for "where did
|
|
687
|
+
// this schema come from" (bridge-advertised, bundled fallback, or neither).
|
|
688
|
+
__publicField(this, "serviceRequestDefs", /* @__PURE__ */ new Map());
|
|
689
|
+
__publicField(this, "serviceResponseDefs", /* @__PURE__ */ new Map());
|
|
690
|
+
__publicField(this, "serviceRequestWriters", /* @__PURE__ */ new Map());
|
|
691
|
+
__publicField(this, "serviceResponseReaders", /* @__PURE__ */ new Map());
|
|
539
692
|
// Reconnection
|
|
540
693
|
__publicField(this, "reconnectAttempts", 0);
|
|
541
694
|
__publicField(this, "reconnectTimer", null);
|
|
@@ -545,7 +698,6 @@ var _FoxgloveClient = class _FoxgloveClient {
|
|
|
545
698
|
__publicField(this, "connectResolve", null);
|
|
546
699
|
__publicField(this, "connectReject", null);
|
|
547
700
|
__publicField(this, "serverInfoReceived", false);
|
|
548
|
-
this.onLatency = options?.onLatency;
|
|
549
701
|
this.logger = options?.logger ?? NOOP_LOGGER;
|
|
550
702
|
this.getThrottleMode = options?.getThrottleMode ?? (() => "auto");
|
|
551
703
|
this.presets = buildEffectivePresets(options?.presetOverrides, this.logger);
|
|
@@ -842,7 +994,33 @@ var _FoxgloveClient = class _FoxgloveClient {
|
|
|
842
994
|
view.setUint8(0, 1 /* MESSAGE_DATA */);
|
|
843
995
|
view.setUint32(1, channelId, true);
|
|
844
996
|
new Uint8Array(buffer, 5).set(payloadBytes);
|
|
845
|
-
this.ws.send(buffer);
|
|
997
|
+
this.ws.send(new Uint8Array(buffer));
|
|
998
|
+
}
|
|
999
|
+
/**
|
|
1000
|
+
* Send a service-call request as a binary opcode-0x02 frame. Per
|
|
1001
|
+
* Foxglove WS v1 spec, SERVICE_CALL_REQUEST is binary only; the JSON op
|
|
1002
|
+
* `serviceCallRequest` that earlier revisions used is not in the spec
|
|
1003
|
+
* and is rejected by current bridges with a `status` level-2 message,
|
|
1004
|
+
* which leaves the in-flight callId hanging until the 30 s timeout.
|
|
1005
|
+
*
|
|
1006
|
+
* Frame layout mirrors the inbound 0x03 SERVICE_CALL_RESPONSE parser:
|
|
1007
|
+
* [uint8 op=0x02][uint32 serviceId LE][uint32 callId LE]
|
|
1008
|
+
* [uint32 encLen LE][utf8 encoding][bytes payload]
|
|
1009
|
+
*/
|
|
1010
|
+
sendBinaryServiceCallRequest(serviceId, callId, encoding, payload) {
|
|
1011
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
|
1012
|
+
const encodingBytes = TEXT_ENCODER.encode(encoding);
|
|
1013
|
+
const buffer = new ArrayBuffer(
|
|
1014
|
+
1 + 4 + 4 + 4 + encodingBytes.byteLength + payload.byteLength
|
|
1015
|
+
);
|
|
1016
|
+
const view = new DataView(buffer);
|
|
1017
|
+
view.setUint8(0, 2 /* SERVICE_CALL_REQUEST */);
|
|
1018
|
+
view.setUint32(1, serviceId, true);
|
|
1019
|
+
view.setUint32(5, callId, true);
|
|
1020
|
+
view.setUint32(9, encodingBytes.byteLength, true);
|
|
1021
|
+
new Uint8Array(buffer, 13, encodingBytes.byteLength).set(encodingBytes);
|
|
1022
|
+
new Uint8Array(buffer, 13 + encodingBytes.byteLength).set(payload);
|
|
1023
|
+
this.ws.send(new Uint8Array(buffer));
|
|
846
1024
|
}
|
|
847
1025
|
async callService(service, request) {
|
|
848
1026
|
if (!this.ws || this.status !== "connected") {
|
|
@@ -859,17 +1037,98 @@ var _FoxgloveClient = class _FoxgloveClient {
|
|
|
859
1037
|
reject(new Error(`Service call "${service}" timed out after 30s`));
|
|
860
1038
|
}, 3e4);
|
|
861
1039
|
this.pendingServiceCalls.set(callId, { resolve, reject, timer });
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
1040
|
+
let payloadBytes;
|
|
1041
|
+
try {
|
|
1042
|
+
const reqDefs = this.getRequestDefs(serviceInfo);
|
|
1043
|
+
if (reqDefs) {
|
|
1044
|
+
const writer = this.getOrCompileRequestWriter(serviceInfo.id, reqDefs);
|
|
1045
|
+
const payload = isEmptyRequest(request) ? schemaToTemplate(reqDefs) : request;
|
|
1046
|
+
payloadBytes = writer.writeMessage(payload);
|
|
1047
|
+
} else if (isEmptyRequest(request)) {
|
|
1048
|
+
payloadBytes = CDR_LE_HEADER;
|
|
1049
|
+
} else {
|
|
1050
|
+
throw new Error(
|
|
1051
|
+
`Service "${service}" (type "${serviceInfo.type}") has no request schema advertised and is not in the built-in fallback bundle; cannot encode a non-empty CDR request. The bridge omits inline schemas for services discovered via introspection; empty requests still work via the encapsulation-header fallback.`
|
|
1052
|
+
);
|
|
1053
|
+
}
|
|
1054
|
+
} catch (err) {
|
|
1055
|
+
clearTimeout(timer);
|
|
1056
|
+
this.pendingServiceCalls.delete(callId);
|
|
1057
|
+
reject(
|
|
1058
|
+
new Error(
|
|
1059
|
+
`Failed to encode request for "${service}": ${err instanceof Error ? err.message : String(err)}`
|
|
1060
|
+
)
|
|
1061
|
+
);
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
this.sendBinaryServiceCallRequest(serviceInfo.id, callId, "cdr", payloadBytes);
|
|
871
1065
|
});
|
|
872
1066
|
}
|
|
1067
|
+
/**
|
|
1068
|
+
* Resolve the parsed request-side {@link MessageDefinition}[] for a
|
|
1069
|
+
* service, preferring the bridge-advertised schema and falling back to
|
|
1070
|
+
* the built-in bundle (`src/builtinSchemas.ts`) when the bridge omitted
|
|
1071
|
+
* one. Returns `null` if neither source has anything for this service —
|
|
1072
|
+
* callers then choose between the encapsulation-header fallback (for
|
|
1073
|
+
* empty requests) and an explicit error (for non-empty ones). Cached
|
|
1074
|
+
* per service id; parse + bundle lookup runs at most once per service
|
|
1075
|
+
* advertisement.
|
|
1076
|
+
*/
|
|
1077
|
+
getRequestDefs(svc) {
|
|
1078
|
+
const cached = this.serviceRequestDefs.get(svc.id);
|
|
1079
|
+
if (cached) return cached;
|
|
1080
|
+
if (svc.requestSchema) {
|
|
1081
|
+
const defs = parseFoxgloveSchema(svc.requestSchema, svc.requestSchemaEncoding);
|
|
1082
|
+
this.serviceRequestDefs.set(svc.id, defs);
|
|
1083
|
+
return defs;
|
|
1084
|
+
}
|
|
1085
|
+
const bundled = getBundledServiceSchema(svc.type);
|
|
1086
|
+
if (bundled) {
|
|
1087
|
+
const defs = rosmsg.parse(bundled.request, { ros2: true });
|
|
1088
|
+
this.serviceRequestDefs.set(svc.id, defs);
|
|
1089
|
+
return defs;
|
|
1090
|
+
}
|
|
1091
|
+
return null;
|
|
1092
|
+
}
|
|
1093
|
+
/** Response-side counterpart to {@link getRequestDefs}; same precedence. */
|
|
1094
|
+
getResponseDefs(svc) {
|
|
1095
|
+
const cached = this.serviceResponseDefs.get(svc.id);
|
|
1096
|
+
if (cached) return cached;
|
|
1097
|
+
if (svc.responseSchema) {
|
|
1098
|
+
const defs = parseFoxgloveSchema(svc.responseSchema, svc.responseSchemaEncoding);
|
|
1099
|
+
this.serviceResponseDefs.set(svc.id, defs);
|
|
1100
|
+
return defs;
|
|
1101
|
+
}
|
|
1102
|
+
const bundled = getBundledServiceSchema(svc.type);
|
|
1103
|
+
if (bundled) {
|
|
1104
|
+
const defs = rosmsg.parse(bundled.response, { ros2: true });
|
|
1105
|
+
this.serviceResponseDefs.set(svc.id, defs);
|
|
1106
|
+
return defs;
|
|
1107
|
+
}
|
|
1108
|
+
return null;
|
|
1109
|
+
}
|
|
1110
|
+
/** Lazily compile and cache the CDR writer for a service's request type. */
|
|
1111
|
+
getOrCompileRequestWriter(serviceId, defs) {
|
|
1112
|
+
const cached = this.serviceRequestWriters.get(serviceId);
|
|
1113
|
+
if (cached) return cached;
|
|
1114
|
+
const writer = new rosmsg2Serialization.MessageWriter(defs);
|
|
1115
|
+
this.serviceRequestWriters.set(serviceId, writer);
|
|
1116
|
+
return writer;
|
|
1117
|
+
}
|
|
1118
|
+
/** Lazily compile and cache the CDR reader for a service's response type. */
|
|
1119
|
+
getOrCompileResponseReader(serviceId, defs) {
|
|
1120
|
+
const cached = this.serviceResponseReaders.get(serviceId);
|
|
1121
|
+
if (cached) return cached;
|
|
1122
|
+
const reader = new rosmsg2Serialization.MessageReader(defs);
|
|
1123
|
+
this.serviceResponseReaders.set(serviceId, reader);
|
|
1124
|
+
return reader;
|
|
1125
|
+
}
|
|
1126
|
+
findServiceById(serviceId) {
|
|
1127
|
+
for (const svc of this.availableServices.values()) {
|
|
1128
|
+
if (svc.id === serviceId) return svc;
|
|
1129
|
+
}
|
|
1130
|
+
return void 0;
|
|
1131
|
+
}
|
|
873
1132
|
onStatusChange(cb) {
|
|
874
1133
|
this.statusListeners.add(cb);
|
|
875
1134
|
cb(this.status);
|
|
@@ -980,26 +1239,41 @@ var _FoxgloveClient = class _FoxgloveClient {
|
|
|
980
1239
|
case "serviceCallResponse":
|
|
981
1240
|
this.handleServiceCallResponse(msg);
|
|
982
1241
|
break;
|
|
983
|
-
case "
|
|
984
|
-
this.
|
|
1242
|
+
case "serviceCallFailure":
|
|
1243
|
+
this.handleServiceCallFailure(msg);
|
|
985
1244
|
break;
|
|
986
1245
|
case "schemas":
|
|
987
1246
|
break;
|
|
988
|
-
case "status":
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
)
|
|
1247
|
+
case "status": {
|
|
1248
|
+
const status = msg;
|
|
1249
|
+
if (status.level === 2) {
|
|
1250
|
+
const text = status.message ?? "";
|
|
1251
|
+
this.logger.error("[FoxgloveClient] Server error:", text);
|
|
1252
|
+
if (/serviceCallRequest/i.test(text) && this.pendingServiceCalls.size > 0) {
|
|
1253
|
+
this.rejectAllPendingServiceCalls(`Bridge rejected service call: ${text}`);
|
|
1254
|
+
}
|
|
994
1255
|
}
|
|
995
1256
|
break;
|
|
1257
|
+
}
|
|
996
1258
|
}
|
|
997
1259
|
}
|
|
998
1260
|
handleBinaryMessage(buffer) {
|
|
999
|
-
if (buffer.byteLength <
|
|
1261
|
+
if (buffer.byteLength < 1) return;
|
|
1000
1262
|
const view = new DataView(buffer);
|
|
1001
1263
|
const opcode = view.getUint8(0);
|
|
1002
|
-
|
|
1264
|
+
switch (opcode) {
|
|
1265
|
+
case 1 /* MESSAGE_DATA */:
|
|
1266
|
+
this.handleBinaryMessageData(buffer, view);
|
|
1267
|
+
return;
|
|
1268
|
+
case 3 /* SERVICE_CALL_RESPONSE */:
|
|
1269
|
+
this.handleBinaryServiceCallResponse(buffer, view);
|
|
1270
|
+
return;
|
|
1271
|
+
default:
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
handleBinaryMessageData(buffer, view) {
|
|
1276
|
+
if (buffer.byteLength < 13) return;
|
|
1003
1277
|
const subscriptionId = view.getUint32(1, true);
|
|
1004
1278
|
const timestampLow = view.getUint32(5, true);
|
|
1005
1279
|
const timestampHigh = view.getUint32(9, true);
|
|
@@ -1084,7 +1358,6 @@ var _FoxgloveClient = class _FoxgloveClient {
|
|
|
1084
1358
|
this.clearConnectionTimeout();
|
|
1085
1359
|
this.reconnectAttempts = 0;
|
|
1086
1360
|
this.setStatus("connected");
|
|
1087
|
-
this.startPingLoop();
|
|
1088
1361
|
this.connectResolve();
|
|
1089
1362
|
this.connectResolve = null;
|
|
1090
1363
|
this.connectReject = null;
|
|
@@ -1118,15 +1391,51 @@ var _FoxgloveClient = class _FoxgloveClient {
|
|
|
1118
1391
|
}
|
|
1119
1392
|
}
|
|
1120
1393
|
}
|
|
1394
|
+
/**
|
|
1395
|
+
* Service-call responses arrive on two distinct wire paths and both
|
|
1396
|
+
* funnel into {@link dispatchServiceCallResponse}:
|
|
1397
|
+
*
|
|
1398
|
+
* 1. JSON op `serviceCallResponse` (handled here) — older bridges
|
|
1399
|
+
* and any deployment with binary responses disabled. `data` is
|
|
1400
|
+
* base64, decoded once into a Uint8Array before dispatch.
|
|
1401
|
+
* 2. Binary opcode 0x03 (handled in
|
|
1402
|
+
* {@link handleBinaryServiceCallResponse}) — the default for
|
|
1403
|
+
* foxglove-sdk-cpp ≥ 0.18.0 / foxglove_bridge 3.2.6+. Payload
|
|
1404
|
+
* bytes are passed through directly with no base64 round-trip.
|
|
1405
|
+
*
|
|
1406
|
+
* Before this split, only the JSON path existed and the binary
|
|
1407
|
+
* frames were dropped by {@link handleBinaryMessage} — pending callIds
|
|
1408
|
+
* never resolved and surfaced as 30 s timeouts on every callService.
|
|
1409
|
+
*/
|
|
1121
1410
|
handleServiceCallResponse(msg) {
|
|
1122
|
-
const
|
|
1411
|
+
const payload = msg.data ? base64ToUint8(msg.data) : new Uint8Array();
|
|
1412
|
+
this.dispatchServiceCallResponse(msg.callId, msg.serviceId, msg.encoding ?? "", payload);
|
|
1413
|
+
}
|
|
1414
|
+
/**
|
|
1415
|
+
* Inner decode + dispatch shared by the JSON-op and binary-frame
|
|
1416
|
+
* paths. Owns the lookup of the pending call, the timer cleanup, and
|
|
1417
|
+
* the CDR / JSON branch — splitting it out keeps the two surface
|
|
1418
|
+
* paths thin and prevents drift between them.
|
|
1419
|
+
*/
|
|
1420
|
+
dispatchServiceCallResponse(callId, serviceId, encoding, payload) {
|
|
1421
|
+
const pending = this.pendingServiceCalls.get(callId);
|
|
1123
1422
|
if (!pending) return;
|
|
1124
1423
|
clearTimeout(pending.timer);
|
|
1125
|
-
this.pendingServiceCalls.delete(
|
|
1424
|
+
this.pendingServiceCalls.delete(callId);
|
|
1126
1425
|
try {
|
|
1127
|
-
if (
|
|
1128
|
-
const
|
|
1129
|
-
const
|
|
1426
|
+
if (encoding === "cdr" && payload.byteLength > 0) {
|
|
1427
|
+
const svc = this.findServiceById(serviceId);
|
|
1428
|
+
const respDefs = svc ? this.getResponseDefs(svc) : null;
|
|
1429
|
+
if (!svc || !respDefs) {
|
|
1430
|
+
pending.resolve({ rawBytes: payload });
|
|
1431
|
+
return;
|
|
1432
|
+
}
|
|
1433
|
+
const reader = this.getOrCompileResponseReader(svc.id, respDefs);
|
|
1434
|
+
const decoded = reader.readMessage(payload);
|
|
1435
|
+
pending.resolve(decoded);
|
|
1436
|
+
} else if (encoding === "json" && payload.byteLength > 0) {
|
|
1437
|
+
const text = TEXT_DECODER.decode(payload);
|
|
1438
|
+
const parsed = JSON.parse(text);
|
|
1130
1439
|
pending.resolve(parsed);
|
|
1131
1440
|
} else {
|
|
1132
1441
|
pending.resolve({ success: true });
|
|
@@ -1139,45 +1448,54 @@ var _FoxgloveClient = class _FoxgloveClient {
|
|
|
1139
1448
|
);
|
|
1140
1449
|
}
|
|
1141
1450
|
}
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1451
|
+
/**
|
|
1452
|
+
* Parse a binary opcode-0x03 SERVICE_CALL_RESPONSE frame and dispatch
|
|
1453
|
+
* to {@link dispatchServiceCallResponse}. Frame layout (LE throughout):
|
|
1454
|
+
*
|
|
1455
|
+
* [uint8 op=0x03][uint32 serviceId][uint32 callId]
|
|
1456
|
+
* [uint32 encodingLength][utf8 encoding][bytes payload]
|
|
1457
|
+
*
|
|
1458
|
+
* Malformed frames (too short for the header, or an `encodingLength`
|
|
1459
|
+
* that runs past the buffer) are dropped silently — the bridge will
|
|
1460
|
+
* either resend or fail the call via the JSON `serviceCallFailure`
|
|
1461
|
+
* op, and either way logging on the hot binary path would be noisy.
|
|
1462
|
+
*/
|
|
1463
|
+
handleBinaryServiceCallResponse(buffer, view) {
|
|
1464
|
+
if (buffer.byteLength < 13) return;
|
|
1465
|
+
const serviceId = view.getUint32(1, true);
|
|
1466
|
+
const callId = view.getUint32(5, true);
|
|
1467
|
+
const encodingLength = view.getUint32(9, true);
|
|
1468
|
+
const payloadOffset = 13 + encodingLength;
|
|
1469
|
+
if (buffer.byteLength < payloadOffset) return;
|
|
1470
|
+
const encoding = TEXT_DECODER.decode(new Uint8Array(buffer, 13, encodingLength));
|
|
1471
|
+
const payload = new Uint8Array(buffer, payloadOffset);
|
|
1472
|
+
this.dispatchServiceCallResponse(callId, serviceId, encoding, payload);
|
|
1159
1473
|
}
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1474
|
+
/**
|
|
1475
|
+
* Handle a `serviceCallFailure` op (callId-targeted rejection from the
|
|
1476
|
+
* bridge). Without this, failures sat unhandled and the in-flight promise
|
|
1477
|
+
* timed out at 30 s — turning every misencoded request, unknown service,
|
|
1478
|
+
* or schema mismatch into a long, opaque hang.
|
|
1479
|
+
*/
|
|
1480
|
+
handleServiceCallFailure(msg) {
|
|
1481
|
+
const pending = this.pendingServiceCalls.get(msg.callId);
|
|
1482
|
+
if (!pending) return;
|
|
1483
|
+
clearTimeout(pending.timer);
|
|
1484
|
+
this.pendingServiceCalls.delete(msg.callId);
|
|
1485
|
+
pending.reject(new Error(msg.message ?? "Service call failed (no message from bridge)"));
|
|
1169
1486
|
}
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1487
|
+
/**
|
|
1488
|
+
* Reject every in-flight service call with `reason`, clear their timers,
|
|
1489
|
+
* and empty the pending map. Used by {@link cleanup} on disconnect and
|
|
1490
|
+
* by the status-level-2 fast-fail path where the bridge has signalled a
|
|
1491
|
+
* service-call rejection without naming a callId.
|
|
1492
|
+
*/
|
|
1493
|
+
rejectAllPendingServiceCalls(reason) {
|
|
1494
|
+
for (const pending of this.pendingServiceCalls.values()) {
|
|
1495
|
+
clearTimeout(pending.timer);
|
|
1496
|
+
pending.reject(new Error(reason));
|
|
1180
1497
|
}
|
|
1498
|
+
this.pendingServiceCalls.clear();
|
|
1181
1499
|
}
|
|
1182
1500
|
// ── Private: reconnection ────────────────────────────────────────────────
|
|
1183
1501
|
handleConnectionError(error) {
|
|
@@ -1304,16 +1622,11 @@ var _FoxgloveClient = class _FoxgloveClient {
|
|
|
1304
1622
|
// ── Private: cleanup ─────────────────────────────────────────────────────
|
|
1305
1623
|
cleanup() {
|
|
1306
1624
|
this.clearConnectionTimeout();
|
|
1307
|
-
this.stopPingLoop();
|
|
1308
1625
|
if (this.reconnectTimer) {
|
|
1309
1626
|
clearTimeout(this.reconnectTimer);
|
|
1310
1627
|
this.reconnectTimer = null;
|
|
1311
1628
|
}
|
|
1312
|
-
|
|
1313
|
-
clearTimeout(pending.timer);
|
|
1314
|
-
pending.reject(new Error("Connection closed"));
|
|
1315
|
-
}
|
|
1316
|
-
this.pendingServiceCalls.clear();
|
|
1629
|
+
this.rejectAllPendingServiceCalls("Connection closed");
|
|
1317
1630
|
if (this.ws) {
|
|
1318
1631
|
if (this.ws.readyState === WebSocket.OPEN && this.advertisedTopics.size > 0) {
|
|
1319
1632
|
const channelIds = Array.from(this.advertisedTopics.values());
|
|
@@ -1338,6 +1651,10 @@ var _FoxgloveClient = class _FoxgloveClient {
|
|
|
1338
1651
|
this.messageReaders.clear();
|
|
1339
1652
|
this.advertisedTopics.clear();
|
|
1340
1653
|
this.availableServices.clear();
|
|
1654
|
+
this.serviceRequestDefs.clear();
|
|
1655
|
+
this.serviceResponseDefs.clear();
|
|
1656
|
+
this.serviceRequestWriters.clear();
|
|
1657
|
+
this.serviceResponseReaders.clear();
|
|
1341
1658
|
this.notifyServicesChanged();
|
|
1342
1659
|
this.serverInfoReceived = false;
|
|
1343
1660
|
this.nextSubscriptionId = 1;
|