@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.
@@ -0,0 +1,36 @@
1
+ export type SocketName = "sub" | "rpc" | "pub" | "sonar";
2
+ export type ConnectionState = "connecting" | "connected" | "disconnected";
3
+ export type ConnectionTransition = {
4
+ scope: "socket";
5
+ name: SocketName;
6
+ state: ConnectionState;
7
+ } | {
8
+ scope: "client";
9
+ state: ConnectionState;
10
+ };
11
+ /**
12
+ * Owns every piece of connection state: the connect/disconnect intent, the
13
+ * per-socket readiness, and whether a detected sonar makes the sonar socket
14
+ * required. All mutations go through the methods below, and each returns the
15
+ * exact list of transitions that occurred — a transition is only ever
16
+ * reported when something actually changed, so consumers can treat the
17
+ * resulting events as edge-triggered.
18
+ */
19
+ export declare class ConnectionTracker {
20
+ private intent;
21
+ private sonarRequired;
22
+ private pending;
23
+ private sockets;
24
+ /** True between connectRequested() and disconnectRequested(). */
25
+ get intended(): boolean;
26
+ /** True once a sonar has been detected on the current connection. */
27
+ get isSonarRequired(): boolean;
28
+ get state(): ConnectionState;
29
+ connectRequested(): ConnectionTransition[];
30
+ disconnectRequested(): ConnectionTransition[];
31
+ socketReady(name: SocketName): ConnectionTransition[];
32
+ socketLost(name: SocketName): ConnectionTransition[];
33
+ sonarDetected(): ConnectionTransition[];
34
+ private setSocket;
35
+ private collect;
36
+ }
@@ -0,0 +1,100 @@
1
+ const CORE_SOCKETS = ["sub", "rpc", "pub"];
2
+ /**
3
+ * Owns every piece of connection state: the connect/disconnect intent, the
4
+ * per-socket readiness, and whether a detected sonar makes the sonar socket
5
+ * required. All mutations go through the methods below, and each returns the
6
+ * exact list of transitions that occurred — a transition is only ever
7
+ * reported when something actually changed, so consumers can treat the
8
+ * resulting events as edge-triggered.
9
+ */
10
+ export class ConnectionTracker {
11
+ intent = false;
12
+ sonarRequired = false;
13
+ pending = [];
14
+ sockets = {
15
+ sub: "disconnected",
16
+ rpc: "disconnected",
17
+ pub: "disconnected",
18
+ sonar: "disconnected",
19
+ };
20
+ /** True between connectRequested() and disconnectRequested(). */
21
+ get intended() {
22
+ return this.intent;
23
+ }
24
+ /** True once a sonar has been detected on the current connection. */
25
+ get isSonarRequired() {
26
+ return this.sonarRequired;
27
+ }
28
+ get state() {
29
+ if (!this.intent)
30
+ return "disconnected";
31
+ const required = this.sonarRequired
32
+ ? [...CORE_SOCKETS, "sonar"]
33
+ : CORE_SOCKETS;
34
+ return required.every((name) => this.sockets[name] === "connected")
35
+ ? "connected"
36
+ : "connecting";
37
+ }
38
+ connectRequested() {
39
+ if (this.intent)
40
+ return [];
41
+ return this.collect(() => {
42
+ this.intent = true;
43
+ for (const name of CORE_SOCKETS) {
44
+ this.setSocket(name, "connecting");
45
+ }
46
+ });
47
+ }
48
+ disconnectRequested() {
49
+ if (!this.intent)
50
+ return [];
51
+ return this.collect(() => {
52
+ this.intent = false;
53
+ // Sonar detection is per-connection: the next connect() starts without
54
+ // requiring the sonar socket until a sonar is detected again.
55
+ this.sonarRequired = false;
56
+ for (const name of Object.keys(this.sockets)) {
57
+ this.setSocket(name, "disconnected");
58
+ }
59
+ });
60
+ }
61
+ socketReady(name) {
62
+ if (!this.intent)
63
+ return [];
64
+ return this.collect(() => {
65
+ this.setSocket(name, "connected");
66
+ });
67
+ }
68
+ socketLost(name) {
69
+ if (!this.intent)
70
+ return [];
71
+ return this.collect(() => {
72
+ this.setSocket(name, "connecting");
73
+ });
74
+ }
75
+ sonarDetected() {
76
+ if (!this.intent || this.sonarRequired)
77
+ return [];
78
+ return this.collect(() => {
79
+ this.sonarRequired = true;
80
+ this.setSocket("sonar", "connecting");
81
+ });
82
+ }
83
+ setSocket(name, state) {
84
+ if (this.sockets[name] === state)
85
+ return;
86
+ this.sockets[name] = state;
87
+ this.pending.push({ scope: "socket", name, state });
88
+ }
89
+ collect(mutate) {
90
+ const before = this.state;
91
+ this.pending = [];
92
+ mutate();
93
+ const transitions = this.pending;
94
+ this.pending = [];
95
+ if (this.state !== before) {
96
+ transitions.push({ scope: "client", state: this.state });
97
+ }
98
+ return transitions;
99
+ }
100
+ }
@@ -0,0 +1,75 @@
1
+ import type { SocketKind, Transport, TransportEvents, TransportFrame, TransportSocket } from "./transport";
2
+ export type InMemoryReply = (frames: TransportFrame[]) => void;
3
+ export type InMemoryMessageHandler = (frames: Uint8Array[], reply: InMemoryReply) => void;
4
+ /**
5
+ * The "server" end of an in-memory URL. Tests use it to receive what the
6
+ * client sends and to publish or reply with frames of their own.
7
+ */
8
+ export declare class InMemoryEndpoint {
9
+ readonly url: string;
10
+ private hub;
11
+ private handlers;
12
+ closed: boolean;
13
+ constructor(url: string, hub: InMemoryTransport);
14
+ onMessage(handler: InMemoryMessageHandler): void;
15
+ /** Broadcast frames to every subscribed socket connected to this URL. */
16
+ send(frames: TransportFrame[]): void;
17
+ /** Simulate server loss: connected sockets emit "lost" and re-attach when a new endpoint is listening. */
18
+ close(): void;
19
+ /**
20
+ * Simulate a silently-dead link (tether/radio drop with no TCP FIN/RST):
21
+ * the endpoint stops delivering in either direction, but connected sockets
22
+ * receive NO "lost" event and continue to believe they are attached.
23
+ */
24
+ sever(): void;
25
+ deliver(frames: Uint8Array[], reply: InMemoryReply): void;
26
+ }
27
+ declare class InMemorySocket implements TransportSocket {
28
+ readonly kind: SocketKind;
29
+ private hub;
30
+ private emitter;
31
+ private wantedUrls;
32
+ private attachedUrls;
33
+ private subscriptions;
34
+ private closed;
35
+ constructor(kind: SocketKind, hub: InMemoryTransport);
36
+ connect(url: string): void;
37
+ disconnect(url: string): void;
38
+ close(): void;
39
+ send(frames: TransportFrame[]): void;
40
+ dropConnection(): void;
41
+ subscribe(topic: string): void;
42
+ unsubscribe(topic: string): void;
43
+ setReconnectInterval(_ms: number): void;
44
+ on<E extends keyof TransportEvents>(event: E, listener: (...args: TransportEvents[E]) => void): void;
45
+ once<E extends keyof TransportEvents>(event: E, listener: (...args: TransportEvents[E]) => void): void;
46
+ off<E extends keyof TransportEvents>(event: E, listener: (...args: TransportEvents[E]) => void): void;
47
+ wants(url: string): boolean;
48
+ isAttached(url: string): boolean;
49
+ attach(url: string): void;
50
+ detach(url: string): void;
51
+ emitMessage(frames: Uint8Array[]): void;
52
+ }
53
+ /**
54
+ * In-memory adapter for the transport seam. Deterministic and portless:
55
+ * sockets attach to endpoints the moment both sides exist, loss and recovery
56
+ * are explicit method calls, and no timers are involved.
57
+ */
58
+ export declare class InMemoryTransport implements Transport {
59
+ private endpoints;
60
+ private sockets;
61
+ createSocket(kind: SocketKind): TransportSocket;
62
+ /** Bring up the server end of a URL. Sockets wanting it attach immediately. */
63
+ listen(url: string): InMemoryEndpoint;
64
+ endpoint(url: string): InMemoryEndpoint | undefined;
65
+ /** Close every endpoint — simulates losing the whole server. */
66
+ closeAll(): void;
67
+ /** Sever every endpoint silently — simulates a dead link with no close event. */
68
+ severAll(): void;
69
+ tryAttach(socket: InMemorySocket, url: string): void;
70
+ dropEndpoint(url: string): void;
71
+ severEndpoint(url: string): void;
72
+ removeSocket(socket: InMemorySocket): void;
73
+ broadcast(url: string, frames: Uint8Array[]): void;
74
+ }
75
+ export {};
@@ -0,0 +1,253 @@
1
+ const utf8Encoder = new TextEncoder();
2
+ const encodeFrame = (frame) => typeof frame === "string" ? utf8Encoder.encode(frame) : frame;
3
+ class MiniEmitter {
4
+ listeners = new Map();
5
+ on(event, listener) {
6
+ let set = this.listeners.get(event);
7
+ if (!set) {
8
+ set = new Set();
9
+ this.listeners.set(event, set);
10
+ }
11
+ set.add(listener);
12
+ }
13
+ once(event, listener) {
14
+ const wrapper = (...args) => {
15
+ this.off(event, wrapper);
16
+ listener(...args);
17
+ };
18
+ // Track the original so off(listener) also removes the wrapper
19
+ wrapper.original = listener;
20
+ this.on(event, wrapper);
21
+ }
22
+ off(event, listener) {
23
+ const set = this.listeners.get(event);
24
+ if (!set)
25
+ return;
26
+ for (const registered of set) {
27
+ if (registered === listener ||
28
+ registered.original === listener) {
29
+ set.delete(registered);
30
+ }
31
+ }
32
+ }
33
+ emit(event, ...args) {
34
+ const set = this.listeners.get(event);
35
+ if (!set)
36
+ return;
37
+ for (const listener of [...set]) {
38
+ listener(...args);
39
+ }
40
+ }
41
+ }
42
+ /**
43
+ * The "server" end of an in-memory URL. Tests use it to receive what the
44
+ * client sends and to publish or reply with frames of their own.
45
+ */
46
+ export class InMemoryEndpoint {
47
+ url;
48
+ hub;
49
+ handlers = [];
50
+ closed = false;
51
+ constructor(url, hub) {
52
+ this.url = url;
53
+ this.hub = hub;
54
+ }
55
+ onMessage(handler) {
56
+ this.handlers.push(handler);
57
+ }
58
+ /** Broadcast frames to every subscribed socket connected to this URL. */
59
+ send(frames) {
60
+ this.hub.broadcast(this.url, frames.map(encodeFrame));
61
+ }
62
+ /** Simulate server loss: connected sockets emit "lost" and re-attach when a new endpoint is listening. */
63
+ close() {
64
+ this.closed = true;
65
+ this.hub.dropEndpoint(this.url);
66
+ }
67
+ /**
68
+ * Simulate a silently-dead link (tether/radio drop with no TCP FIN/RST):
69
+ * the endpoint stops delivering in either direction, but connected sockets
70
+ * receive NO "lost" event and continue to believe they are attached.
71
+ */
72
+ sever() {
73
+ this.closed = true;
74
+ this.hub.severEndpoint(this.url);
75
+ }
76
+ deliver(frames, reply) {
77
+ for (const handler of this.handlers) {
78
+ handler(frames, reply);
79
+ }
80
+ }
81
+ }
82
+ class InMemorySocket {
83
+ kind;
84
+ hub;
85
+ emitter = new MiniEmitter();
86
+ wantedUrls = new Set();
87
+ attachedUrls = new Set();
88
+ subscriptions = new Set();
89
+ closed = false;
90
+ constructor(kind, hub) {
91
+ this.kind = kind;
92
+ this.hub = hub;
93
+ }
94
+ connect(url) {
95
+ if (this.closed)
96
+ return;
97
+ this.wantedUrls.add(url);
98
+ this.hub.tryAttach(this, url);
99
+ }
100
+ disconnect(url) {
101
+ this.wantedUrls.delete(url);
102
+ this.attachedUrls.delete(url);
103
+ }
104
+ close() {
105
+ this.closed = true;
106
+ this.wantedUrls.clear();
107
+ this.attachedUrls.clear();
108
+ this.hub.removeSocket(this);
109
+ }
110
+ send(frames) {
111
+ if (this.closed)
112
+ return;
113
+ const encoded = frames.map(encodeFrame);
114
+ for (const url of this.attachedUrls) {
115
+ const endpoint = this.hub.endpoint(url);
116
+ endpoint?.deliver(encoded, (reply) => {
117
+ this.emitMessage(reply.map(encodeFrame));
118
+ });
119
+ }
120
+ }
121
+ dropConnection() {
122
+ if (this.closed)
123
+ return;
124
+ for (const url of [...this.attachedUrls]) {
125
+ this.detach(url);
126
+ }
127
+ // Mimic the transport's automatic reconnection: if the endpoint is
128
+ // still listening, the socket re-attaches right after the loss.
129
+ for (const url of this.wantedUrls) {
130
+ this.hub.tryAttach(this, url);
131
+ }
132
+ }
133
+ subscribe(topic) {
134
+ this.subscriptions.add(topic);
135
+ }
136
+ unsubscribe(topic) {
137
+ this.subscriptions.delete(topic);
138
+ }
139
+ setReconnectInterval(_ms) {
140
+ // Reconnection is event-driven in-memory: sockets re-attach as soon as an
141
+ // endpoint starts listening again, so the interval is irrelevant.
142
+ }
143
+ on(event, listener) {
144
+ this.emitter.on(event, listener);
145
+ }
146
+ once(event, listener) {
147
+ this.emitter.once(event, listener);
148
+ }
149
+ off(event, listener) {
150
+ this.emitter.off(event, listener);
151
+ }
152
+ wants(url) {
153
+ return !this.closed && this.wantedUrls.has(url);
154
+ }
155
+ isAttached(url) {
156
+ return this.attachedUrls.has(url);
157
+ }
158
+ attach(url) {
159
+ if (this.closed || !this.wantedUrls.has(url))
160
+ return;
161
+ if (this.attachedUrls.has(url))
162
+ return;
163
+ this.attachedUrls.add(url);
164
+ queueMicrotask(() => {
165
+ if (this.attachedUrls.has(url)) {
166
+ this.emitter.emit("ready");
167
+ }
168
+ });
169
+ }
170
+ detach(url) {
171
+ if (!this.attachedUrls.delete(url))
172
+ return;
173
+ queueMicrotask(() => {
174
+ if (!this.closed) {
175
+ this.emitter.emit("lost");
176
+ }
177
+ });
178
+ }
179
+ emitMessage(frames) {
180
+ if (this.closed)
181
+ return;
182
+ const [topic, payload] = frames;
183
+ queueMicrotask(() => {
184
+ if (!this.closed) {
185
+ this.emitter.emit("message", topic, payload);
186
+ }
187
+ });
188
+ }
189
+ }
190
+ /**
191
+ * In-memory adapter for the transport seam. Deterministic and portless:
192
+ * sockets attach to endpoints the moment both sides exist, loss and recovery
193
+ * are explicit method calls, and no timers are involved.
194
+ */
195
+ export class InMemoryTransport {
196
+ endpoints = new Map();
197
+ sockets = new Set();
198
+ createSocket(kind) {
199
+ const socket = new InMemorySocket(kind, this);
200
+ this.sockets.add(socket);
201
+ return socket;
202
+ }
203
+ /** Bring up the server end of a URL. Sockets wanting it attach immediately. */
204
+ listen(url) {
205
+ const endpoint = new InMemoryEndpoint(url, this);
206
+ this.endpoints.set(url, endpoint);
207
+ for (const socket of this.sockets) {
208
+ this.tryAttach(socket, url);
209
+ }
210
+ return endpoint;
211
+ }
212
+ endpoint(url) {
213
+ return this.endpoints.get(url);
214
+ }
215
+ /** Close every endpoint — simulates losing the whole server. */
216
+ closeAll() {
217
+ for (const endpoint of [...this.endpoints.values()]) {
218
+ endpoint.close();
219
+ }
220
+ }
221
+ /** Sever every endpoint silently — simulates a dead link with no close event. */
222
+ severAll() {
223
+ for (const endpoint of [...this.endpoints.values()]) {
224
+ endpoint.sever();
225
+ }
226
+ }
227
+ tryAttach(socket, url) {
228
+ if (this.endpoints.has(url) && socket.wants(url)) {
229
+ socket.attach(url);
230
+ }
231
+ }
232
+ dropEndpoint(url) {
233
+ this.endpoints.delete(url);
234
+ for (const socket of this.sockets) {
235
+ socket.detach(url);
236
+ }
237
+ }
238
+ severEndpoint(url) {
239
+ // Silent: the endpoint disappears but sockets are not detached and get
240
+ // no "lost" — they keep believing the link is up.
241
+ this.endpoints.delete(url);
242
+ }
243
+ removeSocket(socket) {
244
+ this.sockets.delete(socket);
245
+ }
246
+ broadcast(url, frames) {
247
+ for (const socket of this.sockets) {
248
+ if (socket.kind === "sub" && socket.isAttached(url)) {
249
+ socket.emitMessage(frames);
250
+ }
251
+ }
252
+ }
253
+ }
@@ -0,0 +1,29 @@
1
+ import { blueye } from "@blueyerobotics/protocol-definitions";
2
+ export type Protocol = typeof blueye.protocol;
3
+ export type ProtocolType = "Req" | "Rep" | "Tel" | "Ctrl";
4
+ export type ProtocolKey = Extract<keyof Protocol, `${string}${ProtocolType}`>;
5
+ export type Req = keyof Pick<Protocol, Extract<ProtocolKey, `${string}Req`>>;
6
+ export type Rep = keyof Pick<Protocol, Extract<ProtocolKey, `${string}Rep`>>;
7
+ export type Tel = keyof Pick<Protocol, Extract<ProtocolKey, `${string}Tel`>>;
8
+ export type Ctrl = keyof Pick<Protocol, Extract<ProtocolKey, `${string}Ctrl`>>;
9
+ export type ReqToRep<T extends Req> = T extends `${infer Prefix}Req` ? `${Prefix}Rep` extends ProtocolKey ? Protocol[`${Prefix}Rep`] : never : never;
10
+ export type MsgHandler<T extends Req | Ctrl> = Protocol[T];
11
+ export type CreateArgs<T extends Req | Ctrl> = Parameters<MsgHandler<T>["create"]>[0];
12
+ export type DecodedOutput<T extends Req> = ReturnType<ReqToRep<T>["decode"]>;
13
+ export type DecodedTelOutput<T extends Tel> = ReturnType<Protocol[T]["decode"]>;
14
+ export type DecodedMessage<K extends ProtocolKey> = ReturnType<Protocol[K]["decode"]>;
15
+ export declare const isInProtocol: (key: string) => key is keyof typeof blueye.protocol;
16
+ export declare const isReq: (key: string) => key is Req;
17
+ export declare const isRep: (key: string) => key is Rep;
18
+ export declare const isTel: (key: string) => key is Tel;
19
+ export declare const isCtrl: (key: string) => key is Ctrl;
20
+ /**
21
+ * Extract the protocol key from a fully-qualified topic or typeUrl —
22
+ * "blueye.protocol.BatteryTel" (as text or bytes) becomes "BatteryTel".
23
+ */
24
+ export declare const topicToKey: (topic: Uint8Array | string) => string;
25
+ /** The inverse of topicToKey: "BatteryTel" becomes "blueye.protocol.BatteryTel". */
26
+ export declare const keyToTopic: (key: ProtocolKey) => string;
27
+ export declare const protocolTypeOf: (key: ProtocolKey) => ProtocolType;
28
+ export declare const encodeMessage: <T extends Req | Ctrl>(key: T, opts: CreateArgs<T>) => Uint8Array;
29
+ export declare const decodeMessage: <K extends ProtocolKey>(key: K, data: Uint8Array) => DecodedMessage<K>;
@@ -0,0 +1,38 @@
1
+ import { blueye } from "@blueyerobotics/protocol-definitions";
2
+ const utf8Decoder = new TextDecoder();
3
+ export const isInProtocol = (key) => {
4
+ return key in blueye.protocol;
5
+ };
6
+ export const isReq = (key) => isInProtocol(key) && key.endsWith("Req");
7
+ export const isRep = (key) => isInProtocol(key) && key.endsWith("Rep");
8
+ export const isTel = (key) => isInProtocol(key) && key.endsWith("Tel");
9
+ export const isCtrl = (key) => isInProtocol(key) && key.endsWith("Ctrl");
10
+ /**
11
+ * Extract the protocol key from a fully-qualified topic or typeUrl —
12
+ * "blueye.protocol.BatteryTel" (as text or bytes) becomes "BatteryTel".
13
+ */
14
+ export const topicToKey = (topic) => {
15
+ const text = typeof topic === "string" ? topic : utf8Decoder.decode(topic);
16
+ return text.split(".").at(-1) ?? "";
17
+ };
18
+ /** The inverse of topicToKey: "BatteryTel" becomes "blueye.protocol.BatteryTel". */
19
+ export const keyToTopic = (key) => `blueye.protocol.${key}`;
20
+ export const protocolTypeOf = (key) => {
21
+ if (key.endsWith("Ctrl"))
22
+ return "Ctrl";
23
+ if (key.endsWith("Rep"))
24
+ return "Rep";
25
+ if (key.endsWith("Req"))
26
+ return "Req";
27
+ return "Tel";
28
+ };
29
+ export const encodeMessage = (key, opts) => {
30
+ const codec = blueye.protocol[key];
31
+ const message = codec.create(opts);
32
+ // The union of all message codecs collapses each encode parameter to never;
33
+ // `message` came from the same codec's create, so the call is sound.
34
+ return codec.encode(message).finish();
35
+ };
36
+ export const decodeMessage = (key, data) => {
37
+ return blueye.protocol[key].decode(data);
38
+ };
@@ -0,0 +1,23 @@
1
+ import type { TransportFrame, TransportSocket } from "./transport";
2
+ export type ReplyFrames = [topic: Uint8Array, payload: Uint8Array];
3
+ /**
4
+ * Owns the request/reply cycle over a REQ socket: requests are serialized
5
+ * (one in flight at a time), each reply is correlated to the request that is
6
+ * actually awaiting it, timeouts clean up after themselves, and one failed
7
+ * request never affects the next.
8
+ *
9
+ * REQ sockets are lockstep — a new request cannot be sent while a reply is
10
+ * still owed. After a timeout the pipeline keeps tracking the owed reply:
11
+ * a late arrival is discarded (never mis-delivered to a later request), and
12
+ * requests made while the reply is still owed fail fast. A connection loss
13
+ * clears the slate.
14
+ */
15
+ export declare class RequestPipeline {
16
+ private socket;
17
+ private tail;
18
+ private waiter;
19
+ private expectingReply;
20
+ constructor(socket: TransportSocket);
21
+ request(frames: TransportFrame[], timeoutMs: number): Promise<ReplyFrames>;
22
+ private execute;
23
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Owns the request/reply cycle over a REQ socket: requests are serialized
3
+ * (one in flight at a time), each reply is correlated to the request that is
4
+ * actually awaiting it, timeouts clean up after themselves, and one failed
5
+ * request never affects the next.
6
+ *
7
+ * REQ sockets are lockstep — a new request cannot be sent while a reply is
8
+ * still owed. After a timeout the pipeline keeps tracking the owed reply:
9
+ * a late arrival is discarded (never mis-delivered to a later request), and
10
+ * requests made while the reply is still owed fail fast. A connection loss
11
+ * clears the slate.
12
+ */
13
+ export class RequestPipeline {
14
+ socket;
15
+ tail = Promise.resolve();
16
+ waiter = null;
17
+ expectingReply = false;
18
+ constructor(socket) {
19
+ this.socket = socket;
20
+ this.socket.on("message", (topic, payload) => {
21
+ this.expectingReply = false;
22
+ const waiter = this.waiter;
23
+ this.waiter = null;
24
+ // No waiter means the request timed out — the late reply is discarded
25
+ waiter?.resolve([topic, payload]);
26
+ });
27
+ this.socket.on("lost", () => {
28
+ this.expectingReply = false;
29
+ const waiter = this.waiter;
30
+ this.waiter = null;
31
+ waiter?.reject(new Error("[rpc] connection lost while awaiting reply"));
32
+ });
33
+ }
34
+ request(frames, timeoutMs) {
35
+ const result = this.tail.then(() => this.execute(frames, timeoutMs));
36
+ // Isolation: one failed request must not poison the pipeline
37
+ this.tail = result.catch(() => { });
38
+ return result;
39
+ }
40
+ execute(frames, timeoutMs) {
41
+ return new Promise((resolve, reject) => {
42
+ if (this.expectingReply) {
43
+ reject(new Error("[rpc] previous request timed out and its reply is still owed; waiting for the reply or a reconnect"));
44
+ return;
45
+ }
46
+ const timer = setTimeout(() => {
47
+ // Keep expectingReply set: the reply is still owed, and the message
48
+ // handler must discard it when (if) it arrives.
49
+ this.waiter = null;
50
+ reject(new Error("[rpc] request timed out"));
51
+ }, timeoutMs);
52
+ this.waiter = {
53
+ resolve: (reply) => {
54
+ clearTimeout(timer);
55
+ resolve(reply);
56
+ },
57
+ reject: (error) => {
58
+ clearTimeout(timer);
59
+ reject(error);
60
+ },
61
+ };
62
+ this.expectingReply = true;
63
+ try {
64
+ this.socket.send(frames);
65
+ }
66
+ catch (error) {
67
+ clearTimeout(timer);
68
+ this.waiter = null;
69
+ this.expectingReply = false;
70
+ reject(error instanceof Error ? error : new Error(String(error)));
71
+ }
72
+ });
73
+ }
74
+ }
@@ -0,0 +1,25 @@
1
+ import type { DecodedTelOutput } from "./protocol";
2
+ export declare const MULTIBEAM_DEVICE_IDS: number[];
3
+ export type SonarDetection = {
4
+ detected: true;
5
+ deviceId: number;
6
+ } | {
7
+ detected: false;
8
+ reason: "incompatible-firmware";
9
+ version: string | undefined;
10
+ } | {
11
+ detected: false;
12
+ reason: "no-multibeam-device";
13
+ };
14
+ /**
15
+ * The sonar telemetry endpoint exists on Blunux >= 4.7.0; "-dev" builds are
16
+ * assumed to be newer than any release.
17
+ */
18
+ export declare const hasSonarEndpoint: (version: string) => boolean;
19
+ /**
20
+ * Decide from a DroneInfoTel whether a multibeam sonar is connected and
21
+ * reachable: the firmware must expose the sonar endpoint and a known
22
+ * multibeam device ID must be present in one of the guest-port device lists.
23
+ * Pure — no sockets, no side effects.
24
+ */
25
+ export declare const detectSonar: (msg: DecodedTelOutput<"DroneInfoTel">) => SonarDetection;