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 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
- **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 that works in mobile and web runtimes. `v0.2.0` closes that gap.
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
- // Keep-alive
536
- __publicField(this, "pingTimer", null);
537
- __publicField(this, "pongTimer", null);
538
- __publicField(this, "lastPingSentTime", 0);
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
- 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
- });
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 "pong":
984
- this.handlePong();
1242
+ case "serviceCallFailure":
1243
+ this.handleServiceCallFailure(msg);
985
1244
  break;
986
1245
  case "schemas":
987
1246
  break;
988
- case "status":
989
- if (msg.level === 2) {
990
- this.logger.error(
991
- "[FoxgloveClient] Server error:",
992
- msg.message
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 < 5) return;
1261
+ if (buffer.byteLength < 1) return;
1000
1262
  const view = new DataView(buffer);
1001
1263
  const opcode = view.getUint8(0);
1002
- if (opcode !== 1 /* MESSAGE_DATA */) return;
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 pending = this.pendingServiceCalls.get(msg.callId);
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(msg.callId);
1424
+ this.pendingServiceCalls.delete(callId);
1126
1425
  try {
1127
- if (msg.encoding === "json" && msg.data) {
1128
- const decoded = atob(msg.data);
1129
- const parsed = JSON.parse(decoded);
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
- // ── 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);
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
- 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
- }
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
- 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
- }
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
- for (const [, pending] of this.pendingServiceCalls) {
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;