@blueyerobotics/blueye-ts 4.0.3 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -56,12 +56,49 @@ The derived `client.state` reflects the aggregate of the core sockets (`sub`, `r
56
56
  - `connecting`: one or more required sockets are not yet ready.
57
57
  - `connected`: all required sockets are ready — safe to call `sendRequest()`, `getTelemetry()`, and `sendControl()`.
58
58
 
59
- `client.sonarState` reflects the sonar socket independently. If the client loses one or more sockets after being connected, the derived state moves back to `connecting`. `sendRequest()` and `sendControl()` reject unless the client is in the `connected` state.
59
+ All state events global and per-socket — are edge-triggered: they fire exactly once per actual change. If the client loses one or more sockets after being connected, the derived state moves back to `connecting` (with a `connecting` event). `sendRequest()` and `sendControl()` reject unless the client is in the `connected` state.
60
+
61
+ ### Telemetry staleness watchdog
62
+
63
+ A dead link does not always produce a close event — a tether or radio drop can leave the sockets looking connected while telemetry silently freezes, until TCP retransmission gives up minutes later. Because the drone publishes telemetry continuously (e.g. `DroneTimeTel` at 1 Hz), the client watches for it: if no message arrives on the telemetry socket for `stalenessTimeout` milliseconds (default `5000`) while `connected`, the client force-drops its connections. This converts the silent failure into the normal loss path — consumers see the usual `connecting` event, and the built-in reconnect loop restores the session when the link returns.
64
+
65
+ The watchdog only arms after the first telemetry message of a connection (a connection that never produced telemetry is not judged stale), watches the main telemetry socket only (sonar can be legitimately quiet), and disarms on `disconnect()`. Set `stalenessTimeout: 0` to disable it:
66
+
67
+ ```ts
68
+ const client = new BlueyeClient({ stalenessTimeout: 0 }); // no watchdog
69
+ ```
70
+
71
+ ## Transports
72
+
73
+ `BlueyeClient` talks to its sockets through a small transport interface. The default adapter uses [jszmq](https://github.com/BluEye-Robotics/jszmq) over WebSockets; an in-memory adapter ships alongside it for tests, so application code using `BlueyeClient` can be exercised without a drone or any network:
74
+
75
+ ```ts
76
+ import { BlueyeClient, InMemoryTransport } from "@blueyerobotics/blueye-ts";
77
+
78
+ const transport = new InMemoryTransport();
79
+ const rpc = transport.listen("mem://rpc");
80
+ rpc.onMessage(([topic, payload], reply) => {
81
+ // inspect the request, reply([topic, encoded]) as the drone would
82
+ });
83
+ transport.listen("mem://sub");
84
+ transport.listen("mem://pub");
85
+ transport.listen("mem://sonar");
86
+
87
+ const client = new BlueyeClient({
88
+ subUrl: "mem://sub",
89
+ rpcUrl: "mem://rpc",
90
+ pubUrl: "mem://pub",
91
+ sonarUrl: "mem://sonar",
92
+ transport,
93
+ });
94
+ ```
95
+
96
+ When you are done with a client, call `client.close()` to release the underlying sockets permanently; a closed client cannot be reused.
60
97
 
61
98
  ## Sonar support
62
99
 
63
100
  `BlueyeClient` connects the sonar websocket endpoint at `ws://192.168.1.101:9988` when a supported multibeam device is detected in a `DroneInfoTel` message.
64
101
 
65
- - On `connect()`, the sonar socket subscribes but only connects when a known multibeam device ID is found in the guest-port device list.
66
- - Once detected, the sonar socket connects and the global `connected` state requires it to be ready.
102
+ - On `connect()`, the sonar socket subscribes but only connects when a known multibeam device ID is found in the guest-port device list. Detection inspects every `DroneInfoTel` — one is requested over RPC when the connection comes up, and any later `DroneInfoTel` arriving over SUB is also considered.
103
+ - Once detected, the sonar socket connects and the global `connected` state requires it to be ready. Detection resets on `disconnect()`; the next connection starts without requiring sonar until it is detected again.
67
104
  - Sonar telemetry such as `MultibeamPingTel`, `MultibeamConfigTel`, and `MultibeamDiscoveryTel` is emitted through the same typed event interface as other telemetry messages.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  export { blueye, google } from "@blueyerobotics/protocol-definitions";
2
2
  export * from "./src/binlog-parser";
3
3
  export * from "./src/client";
4
- export * from "./src/schema";
4
+ export * from "./src/in-memory-transport";
5
+ export * from "./src/protocol";
6
+ export * from "./src/sonar-device";
7
+ export * from "./src/transport";
package/dist/index.js CHANGED
@@ -1,4 +1,7 @@
1
1
  export { blueye, google } from "@blueyerobotics/protocol-definitions";
2
2
  export * from "./src/binlog-parser";
3
3
  export * from "./src/client";
4
- export * from "./src/schema";
4
+ export * from "./src/in-memory-transport";
5
+ export * from "./src/protocol";
6
+ export * from "./src/sonar-device";
7
+ export * from "./src/transport";
@@ -1,4 +1,4 @@
1
- import { type Protocol, type ProtocolKey, type ProtocolType } from "./client";
1
+ import { type Protocol, type ProtocolKey, type ProtocolType } from "./protocol";
2
2
  export type Message = {
3
3
  [K in ProtocolKey]: {
4
4
  monotonicTime: number;
@@ -1,7 +1,7 @@
1
1
  import { blueye } from "@blueyerobotics/protocol-definitions";
2
2
  import { BinaryReader } from "@bufbuild/protobuf/wire";
3
3
  import { Gunzip } from "fflate";
4
- import { isInProtocol, } from "./client";
4
+ import { decodeMessage, isInProtocol, protocolTypeOf, topicToKey, } from "./protocol";
5
5
  /**
6
6
  * Parse a binlog file from raw (gzipped) data into structured messages..
7
7
  * @param rawData The raw binary data of the binlog file (gzip format).
@@ -69,35 +69,31 @@ export const parseMessages = (decompressed, fixTimes = true) => {
69
69
  const msgBytes = decompressed.buffer.slice(start, end);
70
70
  reader.pos = end;
71
71
  const msg = blueye.protocol.BinlogRecord.decode(new Uint8Array(msgBytes), length);
72
- const key = msg.payload?.typeUrl.split(".").at(-1);
73
- if (!key || !isInProtocol(key)) {
74
- console.warn(`Unknown protocol key: ${key}`);
72
+ if (msg.payload == null) {
73
+ console.warn("Missing payload in BinlogRecord");
75
74
  continue;
76
75
  }
77
- if (msg.payload == null) {
78
- console.warn(`Missing payload for key: ${key}`);
76
+ const key = topicToKey(msg.payload.typeUrl);
77
+ if (!isInProtocol(key)) {
78
+ console.warn(`Unknown protocol key: ${key}`);
79
79
  continue;
80
80
  }
81
- const data = blueye.protocol[key].decode(msg.payload.value);
81
+ const data = decodeMessage(key, msg.payload.value);
82
82
  let innerData;
83
83
  if (key === "GetTelemetryRep") {
84
84
  const telRep = data;
85
- const innerKey = telRep.payload?.typeUrl.split(".").at(-1);
86
- if (!innerKey || !isInProtocol(innerKey)) {
85
+ if (telRep.payload == null) {
86
+ console.warn("Missing inner payload in GetTelemetryRep");
87
+ continue;
88
+ }
89
+ const innerKey = topicToKey(telRep.payload.typeUrl);
90
+ if (!isInProtocol(innerKey)) {
87
91
  console.warn(`Unknown inner protocol key: ${innerKey}`);
88
92
  continue;
89
93
  }
90
- innerData = telRep.payload
91
- ? blueye.protocol[innerKey].decode(telRep.payload.value)
92
- : undefined;
94
+ innerData = decodeMessage(innerKey, telRep.payload.value);
93
95
  }
94
- let type = "Tel";
95
- if (key.endsWith("Ctrl"))
96
- type = "Ctrl";
97
- else if (key.endsWith("Rep"))
98
- type = "Rep";
99
- else if (key.endsWith("Req"))
100
- type = "Req";
96
+ const type = protocolTypeOf(key);
101
97
  messages.push({
102
98
  monotonicTime: msg.clockMonotonic?.getTime() ?? 0,
103
99
  time: msg.unixTimestamp?.getTime() ?? 0,
@@ -1,29 +1,16 @@
1
- import { blueye } from "@blueyerobotics/protocol-definitions";
2
1
  import { type LogLevel } from "consola";
3
2
  import { Emitter } from "strict-event-emitter";
4
- export declare const MULTIBEAM_DEVICE_IDS: number[];
5
- export type Protocol = typeof blueye.protocol;
6
- export type ProtocolType = "Req" | "Rep" | "Tel" | "Ctrl";
7
- export type ProtocolKey = Extract<keyof Protocol, `${string}${ProtocolType}`>;
8
- export type Req = keyof Pick<Protocol, Extract<ProtocolKey, `${string}Req`>>;
9
- export type Rep = keyof Pick<Protocol, Extract<ProtocolKey, `${string}Rep`>>;
10
- export type Tel = keyof Pick<Protocol, Extract<ProtocolKey, `${string}Tel`>>;
11
- export type Ctrl = keyof Pick<Protocol, Extract<ProtocolKey, `${string}Ctrl`>>;
12
- export type ReqToRep<T extends Req> = T extends `${infer Prefix}Req` ? `${Prefix}Rep` extends ProtocolKey ? Protocol[`${Prefix}Rep`] : never : never;
13
- export type MsgHandler<T extends Req | Ctrl> = Protocol[T];
14
- export type CreateArgs<T extends Req | Ctrl> = Parameters<MsgHandler<T>["create"]>[0];
15
- export type DecodedOutput<T extends Req> = ReturnType<ReqToRep<T>["decode"]>;
16
- export type DecodedTelOutput<T extends Tel> = ReturnType<Protocol[T]["decode"]>;
17
- type State = "connecting" | "connected" | "disconnected";
18
- export type SocketName = "sub" | "rpc" | "pub" | "sonar";
3
+ import { type ConnectionState, type SocketName } from "./connection-state";
4
+ import { type CreateArgs, type Ctrl, type DecodedOutput, type DecodedTelOutput, type Req, type Tel } from "./protocol";
5
+ import { type Transport } from "./transport";
6
+ export type { ConnectionState, SocketName } from "./connection-state";
19
7
  export type Events = {
20
- [K in State]: [];
8
+ [K in ConnectionState]: [];
21
9
  } & {
22
- [K in `${SocketName}-${State}`]: [];
10
+ [K in `${SocketName}-${ConnectionState}`]: [];
23
11
  } & {
24
12
  [K in Tel]: [DecodedTelOutput<K>];
25
13
  };
26
- export declare const isInProtocol: (key: string) => key is keyof typeof blueye.protocol;
27
14
  type Options = Partial<{
28
15
  subUrl: string;
29
16
  rpcUrl: string;
@@ -31,12 +18,15 @@ type Options = Partial<{
31
18
  sonarUrl: string;
32
19
  timeout: number;
33
20
  reconnectInterval: number;
21
+ stalenessTimeout: number;
34
22
  logLevel: LogLevel;
35
23
  autoConnect: boolean;
24
+ transport: Transport;
36
25
  }>;
37
26
  export declare class BlueyeClient extends Emitter<Events> {
38
27
  timeout: number;
39
28
  reconnectInterval: number;
29
+ stalenessTimeout: number;
40
30
  private subUrl;
41
31
  private rpcUrl;
42
32
  private pubUrl;
@@ -45,22 +35,32 @@ export declare class BlueyeClient extends Emitter<Events> {
45
35
  private rpc;
46
36
  private pub;
47
37
  private sonarSub;
48
- private queue;
38
+ private pipeline;
49
39
  private logger;
50
- private shouldBeConnected;
51
- private isSonarDetected;
52
- private socketState;
53
- constructor({ subUrl, rpcUrl, pubUrl, sonarUrl, timeout, reconnectInterval, logLevel, autoConnect, }?: Options);
54
- get state(): State;
55
- private updateSocketState;
40
+ private tracker;
41
+ private sonarIncompatibilityWarned;
42
+ private lastSubMessageAt;
43
+ private stalenessTimer;
44
+ constructor({ subUrl, rpcUrl, pubUrl, sonarUrl, timeout, reconnectInterval, stalenessTimeout, logLevel, autoConnect, transport, }?: Options);
45
+ get state(): ConnectionState;
46
+ private applyTransitions;
56
47
  private handleTelemetryMessage;
57
48
  private bindSocketLifecycle;
58
49
  private ensureConnected;
50
+ private startStalenessWatchdog;
51
+ private stopStalenessWatchdog;
52
+ private checkTelemetryStaleness;
53
+ private primeSonarDetection;
54
+ private evaluateSonarDetection;
59
55
  connect(): void;
60
56
  disconnect(): void;
57
+ /**
58
+ * Permanently close all sockets and release their resources. The client
59
+ * cannot be reused afterwards — create a new instance to reconnect.
60
+ */
61
+ close(): void;
61
62
  sendRequest<T extends Req>(req: T, opts?: CreateArgs<T>): Promise<DecodedOutput<T> | null>;
62
63
  getTelemetry<T extends Tel>(type: T): Promise<DecodedTelOutput<T>>;
63
64
  waitForTelemetry<T extends Tel>(type: T, timeout?: number | null): Promise<DecodedTelOutput<T>>;
64
65
  sendControl<T extends Ctrl>(ctrl: T, opts?: CreateArgs<T>): Promise<void>;
65
66
  }
66
- export {};
@@ -1,27 +1,18 @@
1
- import { Pub as ZMQPub, Req as ZMQRep, Sub as ZMQSub, } from "@blueyerobotics/jszmq";
2
- import { blueye } from "@blueyerobotics/protocol-definitions";
3
1
  import { createConsola, LogLevels, } from "consola";
4
- import * as semver from "semver";
5
2
  import { Emitter } from "strict-event-emitter";
6
- import { AsyncQueue } from "./async-queue";
7
- import { responseSchema, telemetrySchema } from "./schema";
3
+ import { ConnectionTracker, } from "./connection-state";
4
+ import { decodeMessage, encodeMessage, isCtrl, isRep, isReq, isTel, keyToTopic, topicToKey, } from "./protocol";
5
+ import { RequestPipeline } from "./request-pipeline";
6
+ import { detectSonar } from "./sonar-device";
7
+ import { JszmqTransport, } from "./transport";
8
8
  const DEFAULT_SUB_URL = "ws://192.168.1.101:9985";
9
9
  const DEFAULT_RPC_URL = "ws://192.168.1.101:9986";
10
10
  const DEFAULT_PUB_URL = "ws://192.168.1.101:9987";
11
11
  const DEFAULT_SONAR_URL = "ws://192.168.1.101:9988";
12
- export const MULTIBEAM_DEVICE_IDS = [13, 16, 18, 20, 29, 30, 41, 42];
13
- export const isInProtocol = (key) => {
14
- return key in blueye.protocol;
15
- };
16
- const hasSonarEndpoint = (version) => {
17
- const coercedVersion = semver.coerce(version);
18
- return coercedVersion
19
- ? semver.satisfies(coercedVersion, ">=4.7.0") || version.endsWith("-dev")
20
- : false;
21
- };
22
12
  export class BlueyeClient extends Emitter {
23
13
  timeout;
24
14
  reconnectInterval;
15
+ stalenessTimeout;
25
16
  subUrl;
26
17
  rpcUrl;
27
18
  pubUrl;
@@ -30,29 +21,26 @@ export class BlueyeClient extends Emitter {
30
21
  rpc;
31
22
  pub;
32
23
  sonarSub;
33
- queue;
24
+ pipeline;
34
25
  logger;
35
- shouldBeConnected = false;
36
- isSonarDetected = false;
37
- socketState = {
38
- sub: "disconnected",
39
- rpc: "disconnected",
40
- pub: "disconnected",
41
- sonar: "disconnected",
42
- };
43
- constructor({ subUrl = DEFAULT_SUB_URL, rpcUrl = DEFAULT_RPC_URL, pubUrl = DEFAULT_PUB_URL, sonarUrl = DEFAULT_SONAR_URL, timeout = 2000, reconnectInterval = 2000, logLevel = LogLevels.info, autoConnect = false, } = {}) {
26
+ tracker = new ConnectionTracker();
27
+ sonarIncompatibilityWarned = false;
28
+ lastSubMessageAt = null;
29
+ stalenessTimer = null;
30
+ constructor({ subUrl = DEFAULT_SUB_URL, rpcUrl = DEFAULT_RPC_URL, pubUrl = DEFAULT_PUB_URL, sonarUrl = DEFAULT_SONAR_URL, timeout = 2000, reconnectInterval = 2000, stalenessTimeout = 5000, logLevel = LogLevels.info, autoConnect = false, transport = new JszmqTransport(), } = {}) {
44
31
  super();
45
32
  this.timeout = timeout;
46
33
  this.reconnectInterval = reconnectInterval;
34
+ this.stalenessTimeout = stalenessTimeout;
47
35
  this.subUrl = subUrl;
48
36
  this.rpcUrl = rpcUrl;
49
37
  this.pubUrl = pubUrl;
50
38
  this.sonarUrl = sonarUrl;
51
- this.sub = new ZMQSub();
52
- this.rpc = new ZMQRep();
53
- this.pub = new ZMQPub();
54
- this.sonarSub = new ZMQSub();
55
- this.queue = new AsyncQueue();
39
+ this.sub = transport.createSocket("sub");
40
+ this.rpc = transport.createSocket("req");
41
+ this.pub = transport.createSocket("pub");
42
+ this.sonarSub = transport.createSocket("sub");
43
+ this.pipeline = new RequestPipeline(this.rpc);
56
44
  this.logger = createConsola({
57
45
  level: logLevel,
58
46
  formatOptions: { colors: true, compact: false },
@@ -62,104 +50,139 @@ export class BlueyeClient extends Emitter {
62
50
  this.bindSocketLifecycle("pub", this.pub);
63
51
  this.bindSocketLifecycle("sonar", this.sonarSub);
64
52
  this.sub.on("message", (topic, msg) => {
53
+ this.lastSubMessageAt = Date.now();
65
54
  this.handleTelemetryMessage("sub", topic, msg);
66
55
  });
67
56
  this.sonarSub.on("message", (topic, msg) => {
68
57
  this.handleTelemetryMessage("sonar", topic, msg);
69
58
  });
70
- this.emit(this.state);
59
+ // Any DroneInfoTel — whether primed via RPC on connect or arriving over
60
+ // SUB later — can reveal a multibeam sonar.
61
+ this.on("DroneInfoTel", (msg) => {
62
+ this.evaluateSonarDetection(msg);
63
+ });
71
64
  this.logger.info(`[client] ${this.state}`);
72
65
  if (autoConnect) {
73
66
  this.connect();
74
67
  }
75
68
  }
76
69
  get state() {
77
- if (!this.shouldBeConnected)
78
- return "disconnected";
79
- const { sub, rpc, pub } = this.socketState;
80
- if (sub === "connected" &&
81
- rpc === "connected" &&
82
- pub === "connected" &&
83
- (this.isSonarDetected ? this.socketState.sonar === "connected" : true)) {
84
- return "connected";
85
- }
86
- return "connecting";
70
+ return this.tracker.state;
87
71
  }
88
- updateSocketState(name, newState) {
89
- if (this.socketState[name] === newState) {
90
- return;
91
- }
92
- const oldState = this.state;
93
- this.socketState[name] = newState;
94
- this.logger.info(`[${name}] ${newState}`);
95
- this.emit(`${name}-${newState}`);
96
- // If all sockets are connected, emit "connected"
97
- this.emit(this.state);
98
- if (oldState !== this.state) {
99
- this.logger.info(`[client] ${this.state}`);
72
+ applyTransitions(transitions) {
73
+ for (const transition of transitions) {
74
+ if (transition.scope === "socket") {
75
+ this.logger.info(`[${transition.name}] ${transition.state}`);
76
+ this.emit(`${transition.name}-${transition.state}`);
77
+ }
78
+ else {
79
+ // Any client-state change invalidates the staleness baseline: after
80
+ // a loss and reconnect the watchdog must only judge telemetry
81
+ // received on the new connection, never a pre-outage timestamp.
82
+ this.lastSubMessageAt = null;
83
+ this.logger.info(`[client] ${transition.state}`);
84
+ this.emit(transition.state);
85
+ }
100
86
  }
101
87
  }
102
88
  handleTelemetryMessage(socketName, topic, msg) {
103
- const { key, data } = responseSchema.parse({ key: topic, data: msg });
104
- if (!isInProtocol(key) || !key.endsWith("Tel")) {
89
+ const key = topicToKey(topic);
90
+ if (!isTel(key)) {
105
91
  this.logger.warn(`[${socketName}] unknown protocol:`, key);
106
92
  return;
107
93
  }
108
- const protocol = blueye.protocol[key];
109
- const message = protocol.decode(data);
94
+ const message = decodeMessage(key, msg);
110
95
  this.logger.verbose(`[${socketName}] message:`, key, message);
111
96
  this.emit(key, message);
112
97
  }
113
98
  bindSocketLifecycle(name, socket) {
114
99
  socket.on("ready", () => {
115
- if (!this.shouldBeConnected)
116
- return;
117
- this.updateSocketState(name, "connected");
100
+ this.applyTransitions(this.tracker.socketReady(name));
118
101
  });
119
102
  socket.on("lost", () => {
120
- if (!this.shouldBeConnected)
121
- return;
122
- this.updateSocketState(name, "connecting");
103
+ this.applyTransitions(this.tracker.socketLost(name));
123
104
  });
124
105
  }
125
106
  ensureConnected(operation) {
126
- if (this.socketState[operation] !== "connected") {
107
+ if (this.state !== "connected") {
127
108
  throw new Error(`[client] cannot send ${operation} while ${this.state}; call connect() and wait for "connected"`);
128
109
  }
129
110
  }
130
- connect() {
131
- if (this.shouldBeConnected) {
132
- this.logger.warn("[client] already connecting or connected");
111
+ startStalenessWatchdog() {
112
+ this.stopStalenessWatchdog();
113
+ if (this.stalenessTimeout <= 0)
133
114
  return;
115
+ const checkInterval = Math.max(100, Math.floor(this.stalenessTimeout / 4));
116
+ this.stalenessTimer = setInterval(() => {
117
+ this.checkTelemetryStaleness();
118
+ }, checkInterval);
119
+ }
120
+ stopStalenessWatchdog() {
121
+ if (this.stalenessTimer != null) {
122
+ clearInterval(this.stalenessTimer);
123
+ this.stalenessTimer = null;
134
124
  }
135
- this.once("connected", async () => {
136
- const msg = await this.waitForTelemetry("DroneInfoTel");
137
- const version = msg.droneInfo?.blunuxVersion;
138
- if (!hasSonarEndpoint(version ?? "")) {
139
- this.logger.warn(`[sonar] incompatible Blunux version detected in DroneInfoTel: ${version}; sonar telemetry may not be available`);
140
- return;
141
- }
142
- const devices = [
143
- ...(msg.droneInfo?.gp?.gp1?.deviceList?.devices ?? []),
144
- ...(msg.droneInfo?.gp?.gp2?.deviceList?.devices ?? []),
145
- ...(msg.droneInfo?.gp?.gp3?.deviceList?.devices ?? []),
146
- ].map((device) => device.deviceId);
147
- if (devices.some((deviceId) => MULTIBEAM_DEVICE_IDS.includes(deviceId))) {
148
- this.logger.info("[sonar] multibeam device detected in DroneInfoTel");
149
- this.isSonarDetected = true;
150
- this.sonarSub.connect(this.sonarUrl);
125
+ }
126
+ checkTelemetryStaleness() {
127
+ // Only armed while connected AND after telemetry has actually flowed —
128
+ // a connection that never produced telemetry is not judged stale.
129
+ if (this.state !== "connected" || this.lastSubMessageAt == null)
130
+ return;
131
+ const silentFor = Date.now() - this.lastSubMessageAt;
132
+ if (silentFor <= this.stalenessTimeout)
133
+ return;
134
+ this.logger.warn(`[watchdog] no telemetry for ${silentFor}ms; dropping connections to force a reconnect`);
135
+ // Re-arm only once telemetry flows again, so a live-but-quiet server
136
+ // doesn't get dropped in a loop.
137
+ this.lastSubMessageAt = null;
138
+ // Convert the silent failure into the explicit loss the state machine
139
+ // already handles: sockets emit "lost" and their reconnect loop runs.
140
+ this.sub.dropConnection();
141
+ this.rpc.dropConnection();
142
+ this.pub.dropConnection();
143
+ this.sonarSub.dropConnection();
144
+ }
145
+ // Bound so disconnect() can remove it if the connection never came up
146
+ primeSonarDetection = async () => {
147
+ try {
148
+ const msg = await this.getTelemetry("DroneInfoTel");
149
+ this.evaluateSonarDetection(msg);
150
+ }
151
+ catch (error) {
152
+ this.logger.trace("[sonar] failed to get DroneInfoTel via RPC; waiting for SUB telemetry:", error);
153
+ }
154
+ };
155
+ evaluateSonarDetection(msg) {
156
+ if (!this.tracker.intended || this.tracker.isSonarRequired)
157
+ return;
158
+ const detection = detectSonar(msg);
159
+ if (!detection.detected) {
160
+ if (detection.reason === "incompatible-firmware" &&
161
+ !this.sonarIncompatibilityWarned) {
162
+ this.sonarIncompatibilityWarned = true;
163
+ this.logger.warn(`[sonar] incompatible Blunux version detected in DroneInfoTel: ${detection.version}; sonar telemetry may not be available`);
151
164
  }
152
- });
153
- this.sub.options.reconnectInterval = this.reconnectInterval;
154
- this.rpc.options.reconnectInterval = this.reconnectInterval;
155
- this.pub.options.reconnectInterval = this.reconnectInterval;
156
- this.sonarSub.options.reconnectInterval = this.reconnectInterval;
157
- this.shouldBeConnected = true;
158
- for (const name of ["sub", "rpc", "pub"]) {
159
- this.updateSocketState(name, "connecting");
165
+ return;
160
166
  }
161
- this.logger.info(`[client] ${this.state}`);
162
- this.emit(this.state);
167
+ this.logger.info(`[sonar] multibeam device detected in DroneInfoTel (deviceId: ${detection.deviceId})`);
168
+ const transitions = this.tracker.sonarDetected();
169
+ this.sonarSub.connect(this.sonarUrl);
170
+ this.applyTransitions(transitions);
171
+ }
172
+ connect() {
173
+ if (this.tracker.intended) {
174
+ this.logger.warn("[client] already connecting or connected");
175
+ return;
176
+ }
177
+ this.sonarIncompatibilityWarned = false;
178
+ this.lastSubMessageAt = null;
179
+ this.startStalenessWatchdog();
180
+ this.once("connected", this.primeSonarDetection);
181
+ this.sub.setReconnectInterval(this.reconnectInterval);
182
+ this.rpc.setReconnectInterval(this.reconnectInterval);
183
+ this.pub.setReconnectInterval(this.reconnectInterval);
184
+ this.sonarSub.setReconnectInterval(this.reconnectInterval);
185
+ this.applyTransitions(this.tracker.connectRequested());
163
186
  this.sub.subscribe("");
164
187
  this.sub.connect(this.subUrl);
165
188
  this.rpc.connect(this.rpcUrl);
@@ -167,51 +190,49 @@ export class BlueyeClient extends Emitter {
167
190
  this.sonarSub.subscribe("");
168
191
  }
169
192
  disconnect() {
170
- if (!this.shouldBeConnected) {
193
+ if (!this.tracker.intended) {
171
194
  this.logger.warn("[client] already disconnected");
172
195
  return;
173
196
  }
174
- this.shouldBeConnected = false;
197
+ this.stopStalenessWatchdog();
198
+ this.off("connected", this.primeSonarDetection);
175
199
  this.sub.unsubscribe("");
176
200
  this.sub.disconnect(this.subUrl);
177
201
  this.rpc.disconnect(this.rpcUrl);
178
202
  this.pub.disconnect(this.pubUrl);
179
203
  this.sonarSub.unsubscribe("");
180
204
  this.sonarSub.disconnect(this.sonarUrl);
181
- for (const name of ["sub", "rpc", "pub", "sonar"]) {
182
- this.updateSocketState(name, "disconnected");
205
+ this.applyTransitions(this.tracker.disconnectRequested());
206
+ }
207
+ /**
208
+ * Permanently close all sockets and release their resources. The client
209
+ * cannot be reused afterwards — create a new instance to reconnect.
210
+ */
211
+ close() {
212
+ if (this.tracker.intended) {
213
+ this.disconnect();
183
214
  }
215
+ this.stopStalenessWatchdog();
216
+ this.sub.close();
217
+ this.rpc.close();
218
+ this.pub.close();
219
+ this.sonarSub.close();
184
220
  }
185
221
  async sendRequest(req, opts = {}) {
186
222
  this.ensureConnected("rpc");
187
- if (!isInProtocol(req) || !req.endsWith("Req")) {
223
+ if (!isReq(req)) {
188
224
  throw new Error(`[rpc] unknown protocol: ${req}`);
189
225
  }
190
- const protocol = blueye.protocol[req];
191
- const message = protocol.create(opts);
192
- const encoded = protocol.encode(message).finish();
193
- const request = () => {
194
- return new Promise((resolve, reject) => {
195
- const timer = setTimeout(() => reject(new Error("[rpc] request timed out")), this.timeout);
196
- this.rpc.once("message", (topic, msg) => {
197
- clearTimeout(timer);
198
- resolve({
199
- key: new TextDecoder().decode(topic).split(".").at(-1) ?? "",
200
- data: msg,
201
- });
202
- });
203
- this.rpc.send([`blueye.protocol.${req}`, encoded]);
204
- });
205
- };
206
- const { key, data } = await this.queue.enqueue(request);
226
+ const encoded = encodeMessage(req, opts);
227
+ const [topic, data] = await this.pipeline.request([keyToTopic(req), encoded], this.timeout);
228
+ const key = topicToKey(topic);
207
229
  if (key === "Empty") {
208
230
  return null;
209
231
  }
210
- if (!isInProtocol(key) || !key.endsWith("Rep")) {
232
+ if (!isRep(key)) {
211
233
  throw new Error(`[rpc] unknown response protocol: ${key}`);
212
234
  }
213
- const rep = blueye.protocol[key];
214
- const result = rep.decode(data);
235
+ const result = decodeMessage(key, data);
215
236
  this.logger.debug("[rpc] decoded:", result);
216
237
  return result;
217
238
  }
@@ -222,12 +243,14 @@ export class BlueyeClient extends Emitter {
222
243
  if (!response) {
223
244
  throw new Error(`[rpc] no response for telemetry request: ${type}`);
224
245
  }
225
- const { payload } = telemetrySchema.parse(response);
226
- const { typeUrl, value } = payload;
227
- if (!isInProtocol(typeUrl) || !typeUrl.endsWith("Tel")) {
228
- throw new Error(`[rpc] unknown telemetry typeUrl: ${typeUrl}`);
246
+ if (!response.payload) {
247
+ throw new Error(`[rpc] no cached telemetry available for: ${type}`);
248
+ }
249
+ const key = topicToKey(response.payload.typeUrl);
250
+ if (!isTel(key)) {
251
+ throw new Error(`[rpc] unknown telemetry typeUrl: ${key}`);
229
252
  }
230
- const result = blueye.protocol[typeUrl].decode(value);
253
+ const result = decodeMessage(key, response.payload.value);
231
254
  this.logger.debug("[rpc] result:", result);
232
255
  return result;
233
256
  }
@@ -258,13 +281,11 @@ export class BlueyeClient extends Emitter {
258
281
  }
259
282
  async sendControl(ctrl, opts = {}) {
260
283
  this.ensureConnected("pub");
261
- if (!isInProtocol(ctrl) || !ctrl.endsWith("Ctrl")) {
284
+ if (!isCtrl(ctrl)) {
262
285
  throw new Error(`[pub] unknown protocol: ${ctrl}`);
263
286
  }
264
- const protocol = blueye.protocol[ctrl];
265
- const message = protocol.create(opts);
266
- const encoded = protocol.encode(message).finish();
267
- this.logger.debug("[pub] sending control:", ctrl, message);
268
- this.pub.send([`blueye.protocol.${ctrl}`, encoded]);
287
+ const encoded = encodeMessage(ctrl, opts);
288
+ this.logger.debug("[pub] sending control:", ctrl, opts);
289
+ this.pub.send([keyToTopic(ctrl), encoded]);
269
290
  }
270
291
  }