@blueyerobotics/blueye-ts 4.0.4 → 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 +40 -3
- package/dist/index.d.ts +4 -1
- package/dist/index.js +4 -1
- package/dist/src/binlog-parser.d.ts +1 -1
- package/dist/src/binlog-parser.js +15 -19
- package/dist/src/client.d.ts +27 -27
- package/dist/src/client.js +150 -129
- package/dist/src/connection-state.d.ts +36 -0
- package/dist/src/connection-state.js +100 -0
- package/dist/src/in-memory-transport.d.ts +75 -0
- package/dist/src/in-memory-transport.js +253 -0
- package/dist/src/protocol.d.ts +29 -0
- package/dist/src/protocol.js +38 -0
- package/dist/src/request-pipeline.d.ts +23 -0
- package/dist/src/request-pipeline.js +74 -0
- package/dist/src/sonar-device.d.ts +25 -0
- package/dist/src/sonar-device.js +34 -0
- package/dist/src/transport.d.ts +31 -0
- package/dist/src/transport.js +69 -0
- package/package.json +10 -11
- package/dist/src/async-queue.d.ts +0 -4
- package/dist/src/async-queue.js +0 -8
- package/dist/src/schema.d.ts +0 -11
- package/dist/src/schema.js +0 -14
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
|
-
|
|
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/
|
|
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/
|
|
4
|
+
export * from "./src/in-memory-transport";
|
|
5
|
+
export * from "./src/protocol";
|
|
6
|
+
export * from "./src/sonar-device";
|
|
7
|
+
export * from "./src/transport";
|
|
@@ -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 "./
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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 =
|
|
81
|
+
const data = decodeMessage(key, msg.payload.value);
|
|
82
82
|
let innerData;
|
|
83
83
|
if (key === "GetTelemetryRep") {
|
|
84
84
|
const telRep = data;
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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,
|
package/dist/src/client.d.ts
CHANGED
|
@@ -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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
export type
|
|
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
|
|
8
|
+
[K in ConnectionState]: [];
|
|
21
9
|
} & {
|
|
22
|
-
[K in `${SocketName}-${
|
|
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
|
|
38
|
+
private pipeline;
|
|
49
39
|
private logger;
|
|
50
|
-
private
|
|
51
|
-
private
|
|
52
|
-
private
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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 {};
|
package/dist/src/client.js
CHANGED
|
@@ -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 {
|
|
7
|
-
import {
|
|
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
|
-
|
|
24
|
+
pipeline;
|
|
34
25
|
logger;
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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 =
|
|
52
|
-
this.rpc =
|
|
53
|
-
this.pub =
|
|
54
|
-
this.sonarSub =
|
|
55
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
104
|
-
if (!
|
|
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
|
|
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
|
-
|
|
116
|
-
return;
|
|
117
|
-
this.updateSocketState(name, "connected");
|
|
100
|
+
this.applyTransitions(this.tracker.socketReady(name));
|
|
118
101
|
});
|
|
119
102
|
socket.on("lost", () => {
|
|
120
|
-
|
|
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.
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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(`[
|
|
162
|
-
this.
|
|
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.
|
|
193
|
+
if (!this.tracker.intended) {
|
|
171
194
|
this.logger.warn("[client] already disconnected");
|
|
172
195
|
return;
|
|
173
196
|
}
|
|
174
|
-
this.
|
|
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
|
-
|
|
182
|
-
|
|
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 (!
|
|
223
|
+
if (!isReq(req)) {
|
|
188
224
|
throw new Error(`[rpc] unknown protocol: ${req}`);
|
|
189
225
|
}
|
|
190
|
-
const
|
|
191
|
-
const
|
|
192
|
-
const
|
|
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 (!
|
|
232
|
+
if (!isRep(key)) {
|
|
211
233
|
throw new Error(`[rpc] unknown response protocol: ${key}`);
|
|
212
234
|
}
|
|
213
|
-
const
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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 =
|
|
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 (!
|
|
284
|
+
if (!isCtrl(ctrl)) {
|
|
262
285
|
throw new Error(`[pub] unknown protocol: ${ctrl}`);
|
|
263
286
|
}
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
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
|
}
|