@rhizomatics/signalk-einklabel-plugin 1.2.3 → 1.3.0-beta1
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 +4 -0
- package/README.md +55 -6
- package/dist/cli/index.js +5 -5
- package/dist/config.d.ts +7 -0
- package/dist/config.js +14 -0
- package/dist/devices/bleBackend.d.ts +21 -0
- package/dist/devices/bleBackend.js +96 -0
- package/dist/devices/bleDiscovery.d.ts +11 -0
- package/dist/devices/bleDiscovery.js +89 -0
- package/dist/devices/discoveryCoordinator.d.ts +7 -6
- package/dist/devices/discoveryCoordinator.js +81 -9
- package/dist/devices/gattConnection.d.ts +9 -0
- package/dist/devices/gattConnection.js +2 -0
- package/dist/devices/gicisky/index.d.ts +7 -2
- package/dist/devices/gicisky/index.js +107 -96
- package/dist/devices/types.d.ts +26 -6
- package/dist/devices/zhsunyco/index.d.ts +8 -2
- package/dist/devices/zhsunyco/index.js +83 -79
- package/dist/plugin.js +27 -9
- package/dist/repaintScheduler.js +4 -2
- package/docs/assets/images/mini_tidal_clock.png +0 -0
- package/package.json +2 -2
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.GiciskyDriver = void 0;
|
|
4
|
-
const
|
|
4
|
+
const bleBackend_1 = require("../bleBackend");
|
|
5
5
|
const metadata_1 = require("./metadata");
|
|
6
6
|
const layout_1 = require("./layout");
|
|
7
7
|
const encode_1 = require("./encode");
|
|
8
8
|
const reframe_1 = require("../../render/reframe");
|
|
9
9
|
const protocol_1 = require("./protocol");
|
|
10
|
-
|
|
11
|
-
/** How long to actively rescan for a fresh advertisement when the cached one is missing/stale - see `waitForManufacturerData`. */
|
|
10
|
+
/** How long to actively rescan for a fresh advertisement when the cached one is missing/stale - see `BleBackend.waitForManufacturerData`. */
|
|
12
11
|
const MANUFACTURER_DATA_RESCAN_TIMEOUT_MS = 15000;
|
|
13
12
|
const DEFAULT_PAINT_CONNECT_TIMEOUT_MS = 60000;
|
|
14
13
|
const ACK_TIMEOUT_MS = 15000;
|
|
@@ -30,7 +29,7 @@ class GiciskyDriver {
|
|
|
30
29
|
supportedDevices() {
|
|
31
30
|
return metadata_1.GICISKY_PID_METADATA;
|
|
32
31
|
}
|
|
33
|
-
async identifyDevice(
|
|
32
|
+
async identifyDevice({ address, name, manufacturerId, manufacturerData, rssi, }) {
|
|
34
33
|
const info = manufacturerData ? (0, protocol_1.decodeAdvertisedInfo)(manufacturerData) : undefined;
|
|
35
34
|
return {
|
|
36
35
|
address,
|
|
@@ -40,128 +39,140 @@ class GiciskyDriver {
|
|
|
40
39
|
metadata: info ? this.metadataForPid(info.deviceId) : undefined,
|
|
41
40
|
manufacturerId,
|
|
42
41
|
batteryMv: info?.batteryMv,
|
|
43
|
-
rssi
|
|
44
|
-
.getRSSI()
|
|
45
|
-
.then((value) => (value === undefined ? undefined : Number(value)))
|
|
46
|
-
.catch(() => undefined),
|
|
42
|
+
rssi,
|
|
47
43
|
};
|
|
48
44
|
}
|
|
49
45
|
async paint(bitmap, config) {
|
|
50
|
-
const
|
|
46
|
+
const backend = config.gattBackend ?? (0, bleBackend_1.nodeBleBackend)();
|
|
47
|
+
/**
|
|
48
|
+
* Unlike zhsunyco, there's no GATT characteristic that reports the device's PID on demand -
|
|
49
|
+
* the only source for it is the advertisement. That cache can be empty or stale (e.g. nothing
|
|
50
|
+
* has actively scanned since the adapter/provider last restarted) even though a connect below
|
|
51
|
+
* would succeed instantly via BlueZ's/the BLE Manager's own device cache - so rescan for a fresh
|
|
52
|
+
* advertisement here rather than failing on the first read.
|
|
53
|
+
*/
|
|
54
|
+
const manufacturerData = await backend.waitForManufacturerData(config.address, protocol_1.GICISKY_MANUFACTURER_ID, MANUFACTURER_DATA_RESCAN_TIMEOUT_MS);
|
|
55
|
+
const info = manufacturerData ? (0, protocol_1.decodeAdvertisedInfo)(manufacturerData) : undefined;
|
|
56
|
+
const metadata = config.modelOverride
|
|
57
|
+
? { pid: info?.deviceId ?? 0, ...config.modelOverride }
|
|
58
|
+
: info
|
|
59
|
+
? this.metadataForPid(info.deviceId)
|
|
60
|
+
: undefined;
|
|
61
|
+
if (!metadata) {
|
|
62
|
+
throw new Error(info === undefined
|
|
63
|
+
? "gicisky device isn't advertising - rescanned but got nothing back (out of range, asleep, or already connected " +
|
|
64
|
+
"elsewhere) - pass --width/--height/--voffset/--colours to describe it manually"
|
|
65
|
+
: `gicisky device reports unrecognised deviceId 0x${info.deviceId.toString(16).padStart(4, "0")} - ` +
|
|
66
|
+
"pass --width/--height/--voffset/--colours to describe it manually");
|
|
67
|
+
}
|
|
68
|
+
const layout = (info && layout_1.GICISKY_PID_LAYOUT[info.deviceId]) || (0, layout_1.defaultLayoutFor)(metadata.colours);
|
|
69
|
+
const framed = (0, reframe_1.reframeBitmap)(bitmap, metadata.width, metadata.height, config.reframe ?? "crop");
|
|
70
|
+
const payload = (0, encode_1.encodeBitmap)(framed, metadata, layout);
|
|
71
|
+
const conn = await backend.connectGatt(config.address, config.connectTimeoutMs ?? DEFAULT_PAINT_CONNECT_TIMEOUT_MS);
|
|
51
72
|
try {
|
|
52
|
-
const
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
* Unlike zhsunyco, there's no GATT characteristic that reports the device's PID on demand -
|
|
56
|
-
* the only source for it is the advertisement, cached on the `Device` object by BlueZ from
|
|
57
|
-
* the last time it was seen (the same cache `identifyDevice`/a scan reads, just without
|
|
58
|
-
* connecting first - see `forEachAdvertisedDevice` in `bleDiscovery.ts`). That cache can be
|
|
59
|
-
* empty or stale (e.g. nothing has actively scanned since `bluetoothd` last restarted) even
|
|
60
|
-
* though `getOrDiscoverDevice` above found the device instantly via BlueZ's own cache of
|
|
61
|
-
* *devices* - so rescan for a fresh advertisement here rather than failing on the first read.
|
|
62
|
-
*/
|
|
63
|
-
const manufacturerData = await (0, bleDiscovery_1.waitForManufacturerData)(adapter, device, protocol_1.GICISKY_MANUFACTURER_ID, MANUFACTURER_DATA_RESCAN_TIMEOUT_MS);
|
|
64
|
-
const info = manufacturerData ? (0, protocol_1.decodeAdvertisedInfo)(manufacturerData) : undefined;
|
|
65
|
-
const metadata = config.modelOverride
|
|
66
|
-
? { pid: info?.deviceId ?? 0, ...config.modelOverride }
|
|
67
|
-
: info
|
|
68
|
-
? this.metadataForPid(info.deviceId)
|
|
69
|
-
: undefined;
|
|
70
|
-
if (!metadata) {
|
|
71
|
-
throw new Error(info === undefined
|
|
72
|
-
? "gicisky device isn't advertising - rescanned but got nothing back (out of range, asleep, or already connected " +
|
|
73
|
-
"elsewhere) - pass --width/--height/--voffset/--colours to describe it manually"
|
|
74
|
-
: `gicisky device reports unrecognised deviceId 0x${info.deviceId.toString(16).padStart(4, "0")} - ` +
|
|
75
|
-
"pass --width/--height/--voffset/--colours to describe it manually");
|
|
76
|
-
}
|
|
77
|
-
const layout = (info && layout_1.GICISKY_PID_LAYOUT[info.deviceId]) || (0, layout_1.defaultLayoutFor)(metadata.colours);
|
|
78
|
-
const framed = (0, reframe_1.reframeBitmap)(bitmap, metadata.width, metadata.height, config.reframe ?? "crop");
|
|
79
|
-
const payload = (0, encode_1.encodeBitmap)(framed, metadata, layout);
|
|
80
|
-
await (0, bleDiscovery_1.connectWithTimeout)(device, config.connectTimeoutMs ?? DEFAULT_PAINT_CONNECT_TIMEOUT_MS);
|
|
73
|
+
const { cmdServiceUuid, cmdUuid, imgServiceUuid, imgUuid } = await findCommandAndImageCharacteristics(conn);
|
|
74
|
+
const ack = new AckChannel();
|
|
75
|
+
await conn.startNotifications(cmdServiceUuid, cmdUuid, ack.onNotify);
|
|
81
76
|
try {
|
|
82
|
-
const
|
|
83
|
-
const
|
|
84
|
-
await
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
77
|
+
const startAck = await writeAndAwaitAck(conn, cmdServiceUuid, cmdUuid, Buffer.from([0x01]), ack);
|
|
78
|
+
const chunkSize = ((0, protocol_1.decodeBlockSize)(startAck) ?? DEFAULT_BLOCK_SIZE) - PART_INDEX_HEADER_LENGTH;
|
|
79
|
+
await writeAndAwaitAck(conn, cmdServiceUuid, cmdUuid, (0, protocol_1.writeScreenCommand)(payload.length, layout.packing === "chunked"), ack);
|
|
80
|
+
const startImageAck = await writeAndAwaitAck(conn, cmdServiceUuid, cmdUuid, Buffer.from([0x03]), ack);
|
|
81
|
+
const started = (0, protocol_1.decodeTransferAck)(startImageAck);
|
|
82
|
+
if (!started?.ok) {
|
|
83
|
+
throw new Error(`gicisky device rejected start-image-transfer request: ${startImageAck.toString("hex")}`);
|
|
84
|
+
}
|
|
85
|
+
let part = started.nextPart;
|
|
86
|
+
let lastPart = -1;
|
|
87
|
+
let repeats = 0;
|
|
88
|
+
while (part * chunkSize < payload.length) {
|
|
89
|
+
const chunk = payload.subarray(part * chunkSize, Math.min(part * chunkSize + chunkSize, payload.length));
|
|
90
|
+
const ackData = await writeAndAwaitAck(conn, imgServiceUuid, imgUuid, (0, protocol_1.imageChunkPacket)(part, chunk), ack);
|
|
91
|
+
const decoded = (0, protocol_1.decodeTransferAck)(ackData);
|
|
92
|
+
if (!decoded?.ok) {
|
|
93
|
+
throw new Error(`gicisky device reported an error transferring image part ${part}: ${ackData.toString("hex")}`);
|
|
93
94
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const chunk = payload.subarray(part * chunkSize, Math.min(part * chunkSize + chunkSize, payload.length));
|
|
99
|
-
const ack = await writeAndAwaitAck(img, cmd, (0, protocol_1.imageChunkPacket)(part, chunk));
|
|
100
|
-
const decoded = (0, protocol_1.decodeTransferAck)(ack);
|
|
101
|
-
if (!decoded?.ok) {
|
|
102
|
-
throw new Error(`gicisky device reported an error transferring image part ${part}: ${ack.toString("hex")}`);
|
|
103
|
-
}
|
|
104
|
-
if (decoded.nextPart === lastPart) {
|
|
105
|
-
repeats++;
|
|
106
|
-
if (repeats >= MAX_STALLED_REPEATS) {
|
|
107
|
-
throw new Error(`gicisky image transfer stalled - device kept re-requesting part ${decoded.nextPart}`);
|
|
108
|
-
}
|
|
95
|
+
if (decoded.nextPart === lastPart) {
|
|
96
|
+
repeats++;
|
|
97
|
+
if (repeats >= MAX_STALLED_REPEATS) {
|
|
98
|
+
throw new Error(`gicisky image transfer stalled - device kept re-requesting part ${decoded.nextPart}`);
|
|
109
99
|
}
|
|
110
|
-
else {
|
|
111
|
-
repeats = 0;
|
|
112
|
-
lastPart = decoded.nextPart;
|
|
113
|
-
}
|
|
114
|
-
part = decoded.nextPart;
|
|
115
100
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
101
|
+
else {
|
|
102
|
+
repeats = 0;
|
|
103
|
+
lastPart = decoded.nextPart;
|
|
104
|
+
}
|
|
105
|
+
part = decoded.nextPart;
|
|
119
106
|
}
|
|
120
107
|
}
|
|
121
108
|
finally {
|
|
122
|
-
await
|
|
109
|
+
await conn.stopNotifications(cmdServiceUuid, cmdUuid).catch(() => { });
|
|
123
110
|
}
|
|
124
111
|
}
|
|
125
112
|
finally {
|
|
126
|
-
|
|
113
|
+
await conn.disconnect();
|
|
127
114
|
}
|
|
128
115
|
}
|
|
129
116
|
}
|
|
130
117
|
exports.GiciskyDriver = GiciskyDriver;
|
|
118
|
+
/**
|
|
119
|
+
* Single-slot "await the next notification" dispatcher - the gicisky protocol is strictly
|
|
120
|
+
* request/response (never more than one write in flight), so a persistent `startNotifications`
|
|
121
|
+
* callback (`GattConnection`'s shape, unlike node-ble's per-call `.once("valuechanged")`) just needs
|
|
122
|
+
* to hand its next delivery to whichever `waitForAck` call is currently pending. Mirrors how
|
|
123
|
+
* signalk-bluetti-plugin's `ProtocolSession.feed()` dispatches notification bytes to a single
|
|
124
|
+
* pending request.
|
|
125
|
+
*/
|
|
126
|
+
class AckChannel {
|
|
127
|
+
constructor() {
|
|
128
|
+
this.onNotify = (data) => {
|
|
129
|
+
const pending = this.pending;
|
|
130
|
+
if (!pending)
|
|
131
|
+
return;
|
|
132
|
+
this.pending = undefined;
|
|
133
|
+
clearTimeout(pending.timer);
|
|
134
|
+
pending.resolve(data);
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
waitForAck(timeoutMs) {
|
|
138
|
+
return new Promise((resolve, reject) => {
|
|
139
|
+
const timer = setTimeout(() => {
|
|
140
|
+
this.pending = undefined;
|
|
141
|
+
reject(new Error("gicisky device did not acknowledge in time"));
|
|
142
|
+
}, timeoutMs);
|
|
143
|
+
this.pending = { resolve, timer };
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/** Writes `data` to `(writeServiceUuid, writeUuid)`, then awaits the next ack delivered to `ack`. */
|
|
148
|
+
async function writeAndAwaitAck(conn, writeServiceUuid, writeUuid, data, ack) {
|
|
149
|
+
const pending = ack.waitForAck(ACK_TIMEOUT_MS);
|
|
150
|
+
await conn.write(writeServiceUuid, writeUuid, data, false);
|
|
151
|
+
return pending;
|
|
152
|
+
}
|
|
131
153
|
/**
|
|
132
154
|
* Walks every service whose UUID starts `0000f`, collecting their characteristics and sorting by
|
|
133
155
|
* 16-bit UUID value - mirrors both reference drivers, which locate their command/image
|
|
134
156
|
* characteristics this way rather than by a hardcoded service UUID (see `protocol.ts`).
|
|
135
157
|
*/
|
|
136
|
-
async function findCommandAndImageCharacteristics(
|
|
158
|
+
async function findCommandAndImageCharacteristics(conn) {
|
|
137
159
|
const candidates = [];
|
|
138
|
-
for (const
|
|
139
|
-
if (!
|
|
160
|
+
for (const service of await conn.discoverServices()) {
|
|
161
|
+
if (!service.uuid.toLowerCase().startsWith(protocol_1.CANDIDATE_SERVICE_UUID_PREFIX)) {
|
|
140
162
|
continue;
|
|
141
163
|
}
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
candidates.push({ uuid: charUuid, char: await service.getCharacteristic(charUuid) });
|
|
164
|
+
for (const characteristic of service.characteristics) {
|
|
165
|
+
candidates.push({ serviceUuid: service.uuid, uuid: characteristic.uuid });
|
|
145
166
|
}
|
|
146
167
|
}
|
|
147
168
|
candidates.sort((a, b) => parseInt(a.uuid.slice(4, 8), 16) - parseInt(b.uuid.slice(4, 8), 16));
|
|
148
169
|
if (candidates.length < 2) {
|
|
149
170
|
throw new Error(`gicisky device exposes ${candidates.length} candidate characteristic(s) under a "0000f..." service, expected at least 2`);
|
|
150
171
|
}
|
|
151
|
-
return {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
notifyChar.removeListener("valuechanged", onValue);
|
|
158
|
-
reject(new Error("gicisky device did not acknowledge in time"));
|
|
159
|
-
}, ACK_TIMEOUT_MS);
|
|
160
|
-
function onValue(value) {
|
|
161
|
-
clearTimeout(timer);
|
|
162
|
-
resolve(value);
|
|
163
|
-
}
|
|
164
|
-
notifyChar.once("valuechanged", onValue);
|
|
165
|
-
});
|
|
166
|
-
return writeChar.writeValueWithoutResponse(data).then(() => ack);
|
|
172
|
+
return {
|
|
173
|
+
cmdServiceUuid: candidates[0].serviceUuid,
|
|
174
|
+
cmdUuid: candidates[0].uuid,
|
|
175
|
+
imgServiceUuid: candidates[1].serviceUuid,
|
|
176
|
+
imgUuid: candidates[1].uuid,
|
|
177
|
+
};
|
|
167
178
|
}
|
package/dist/devices/types.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { Device } from "@naugehyde/node-ble";
|
|
2
1
|
import { Bitmap } from "../render/types";
|
|
3
2
|
import { ReframeMode } from "../render/reframe";
|
|
3
|
+
import { BleBackend } from "./bleBackend";
|
|
4
|
+
import { GattConnection } from "./gattConnection";
|
|
4
5
|
export type Colour = "black" | "white" | "red" | "yellow";
|
|
5
6
|
/**
|
|
6
7
|
* Static facts about one device model, keyed by (vendor, pid) by the registry —
|
|
@@ -60,6 +61,14 @@ export interface VendorDeviceConfig {
|
|
|
60
61
|
connectTimeoutMs?: number;
|
|
61
62
|
/** How to fit the bitmap onto the panel when its size doesn't already match - see `ReframeMode`. Defaults to `"crop"` - a live label showing *something*, even off-size, beats a repaint that just fails outright; pass `"fixed"` explicitly to get the old reject-the-mismatch behaviour back. */
|
|
62
63
|
reframe?: ReframeMode;
|
|
64
|
+
/**
|
|
65
|
+
* How `paint()` reaches the device's BLE hardware - omitted (always true for the CLI, which has no
|
|
66
|
+
* `ServerAPI`/`app.bleApi` to source one from) means direct BlueZ access via a fresh
|
|
67
|
+
* `nodeBleBackend()` (`bleBackend.ts`). The SignalK plugin (`plugin.ts`/`repaintScheduler.ts`) passes
|
|
68
|
+
* a `bleApiBackend()` here instead when the user has opted into the SignalK BLE Manager API
|
|
69
|
+
* (`PluginConfig.useBleApi`) and the server offers `app.bleApi`.
|
|
70
|
+
*/
|
|
71
|
+
gattBackend?: BleBackend;
|
|
63
72
|
}
|
|
64
73
|
export interface VendorDriver {
|
|
65
74
|
vendor: string;
|
|
@@ -70,12 +79,23 @@ export interface VendorDriver {
|
|
|
70
79
|
/** All device models this driver currently has confirmed metadata for. */
|
|
71
80
|
supportedDevices(): DeviceMetadata[];
|
|
72
81
|
/**
|
|
73
|
-
* Identifies one device the shared caller (
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
82
|
+
* Identifies one device the shared caller (`discoveryCoordinator.ts`'s `runScan`, backed by either
|
|
83
|
+
* `forEachAdvertisedDevice` over direct BlueZ or the BLE Manager API's advertisement stream) has
|
|
84
|
+
* already matched to this vendor via `matchesAdvertisement` - only called for matches, so a device
|
|
85
|
+
* gets at most one vendor-specific connect/read regardless of how many drivers are registered, not
|
|
86
|
+
* one attempt per driver.
|
|
87
|
+
*
|
|
88
|
+
* `connect` opens a `GattConnection` to this device on whichever backend the scan is using - call it
|
|
89
|
+
* lazily, only when the advertisement alone doesn't carry enough to identify the device (e.g.
|
|
90
|
+
* zhsunyco's battery/config read fallback); most matches never need it.
|
|
77
91
|
*/
|
|
78
|
-
identifyDevice(
|
|
92
|
+
identifyDevice(advertisement: {
|
|
93
|
+
address: string;
|
|
94
|
+
name: string | undefined;
|
|
95
|
+
manufacturerId: number | undefined;
|
|
96
|
+
manufacturerData: Buffer | undefined;
|
|
97
|
+
rssi: number | undefined;
|
|
98
|
+
}, connect: () => Promise<GattConnection>): Promise<DiscoveredDevice>;
|
|
79
99
|
/** Quantise the common bitmap to this device's palette/encoding and send it over BLE. */
|
|
80
100
|
paint(bitmap: Bitmap, config: VendorDeviceConfig): Promise<void>;
|
|
81
101
|
}
|
|
@@ -1,11 +1,17 @@
|
|
|
1
|
-
import { Device } from "@naugehyde/node-ble";
|
|
2
1
|
import { Bitmap } from "../../render/types";
|
|
3
2
|
import { DeviceMetadata, DiscoveredDevice, VendorDeviceConfig, VendorDriver } from "../types";
|
|
3
|
+
import { GattConnection } from "../gattConnection";
|
|
4
4
|
export declare class ZhsunycoDriver implements VendorDriver {
|
|
5
5
|
readonly vendor = "zhsunyco";
|
|
6
6
|
matchesAdvertisement(name: string | undefined, manufacturerId: number | undefined): boolean;
|
|
7
7
|
metadataForPid(pid: number, hwVersion?: string): DeviceMetadata | undefined;
|
|
8
8
|
supportedDevices(): DeviceMetadata[];
|
|
9
|
-
identifyDevice(
|
|
9
|
+
identifyDevice({ address, name, manufacturerId, manufacturerData, rssi, }: {
|
|
10
|
+
address: string;
|
|
11
|
+
name: string | undefined;
|
|
12
|
+
manufacturerId: number | undefined;
|
|
13
|
+
manufacturerData: Buffer | undefined;
|
|
14
|
+
rssi: number | undefined;
|
|
15
|
+
}, connect: () => Promise<GattConnection>): Promise<DiscoveredDevice>;
|
|
10
16
|
paint(bitmap: Bitmap, config: VendorDeviceConfig): Promise<void>;
|
|
11
17
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.ZhsunycoDriver = void 0;
|
|
4
4
|
const bleDiscovery_1 = require("../bleDiscovery");
|
|
5
|
+
const bleBackend_1 = require("../bleBackend");
|
|
5
6
|
const pluginVersion_1 = require("../../pluginVersion");
|
|
6
7
|
const metadata_1 = require("./metadata");
|
|
7
8
|
const encode_1 = require("./encode");
|
|
@@ -12,9 +13,8 @@ const UPLOAD_CHUNK_SIZE = 238;
|
|
|
12
13
|
const CHUNK_WRITE_DELAY_MS = 20;
|
|
13
14
|
const AUTH_SETTLE_DELAY_MS = 500;
|
|
14
15
|
const STATUS_WAIT_TIMEOUT_MS = 60000;
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const SCAN_CONNECT_TIMEOUT_MS = 10000;
|
|
16
|
+
/** Bounds `readDeviceDetails`' whole connect+read attempt during a scan - see its doc comment. */
|
|
17
|
+
const IDENTIFY_READ_TIMEOUT_MS = 10000;
|
|
18
18
|
/** Fallback when `VendorDeviceConfig.connectTimeoutMs` is omitted (e.g. a bare CLI `paint` call) - matches `defaultConfig().paintConnectTimeoutSeconds`. */
|
|
19
19
|
const DEFAULT_PAINT_CONNECT_TIMEOUT_MS = 60000;
|
|
20
20
|
class ZhsunycoDriver {
|
|
@@ -31,9 +31,9 @@ class ZhsunycoDriver {
|
|
|
31
31
|
supportedDevices() {
|
|
32
32
|
return metadata_1.ZHSUNYCO_PID_METADATA;
|
|
33
33
|
}
|
|
34
|
-
async identifyDevice(
|
|
34
|
+
async identifyDevice({ address, name, manufacturerId, manufacturerData, rssi, }, connect) {
|
|
35
35
|
const advertisedInfo = manufacturerData ? (0, protocol_1.decodeAdvertisedInfo)(manufacturerData) : undefined;
|
|
36
|
-
const { info, batteryMv } = await readDeviceDetails(
|
|
36
|
+
const { info, batteryMv } = await readDeviceDetails(address, advertisedInfo, connect);
|
|
37
37
|
return {
|
|
38
38
|
address,
|
|
39
39
|
name,
|
|
@@ -43,99 +43,92 @@ class ZhsunycoDriver {
|
|
|
43
43
|
metadata: info ? this.metadataForPid(info.pid, info.hwVersion) : undefined,
|
|
44
44
|
manufacturerId,
|
|
45
45
|
batteryMv,
|
|
46
|
-
rssi
|
|
47
|
-
.getRSSI()
|
|
48
|
-
.then((value) => (value === undefined ? undefined : Number(value)))
|
|
49
|
-
.catch(() => undefined),
|
|
46
|
+
rssi,
|
|
50
47
|
};
|
|
51
48
|
}
|
|
52
49
|
async paint(bitmap, config) {
|
|
53
50
|
const aesKey = (0, protocol_1.resolveAesKey)(config.aesKey);
|
|
54
|
-
const
|
|
51
|
+
const backend = config.gattBackend ?? (0, bleBackend_1.nodeBleBackend)();
|
|
52
|
+
const conn = await backend.connectGatt(config.address, config.connectTimeoutMs ?? DEFAULT_PAINT_CONNECT_TIMEOUT_MS);
|
|
55
53
|
try {
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
54
|
+
const info = (0, protocol_1.decodeAdvertisedInfo)(await conn.read(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.config));
|
|
55
|
+
if (!info) {
|
|
56
|
+
throw new Error("zhsunyco device did not return valid config data");
|
|
57
|
+
}
|
|
58
|
+
const metadata = config.modelOverride ? { pid: info.pid, ...config.modelOverride } : this.metadataForPid(info.pid, info.hwVersion);
|
|
59
|
+
if (!metadata) {
|
|
60
|
+
throw new Error(`zhsunyco device reports unrecognised PID 0x${info.pid.toString(16).padStart(4, "0")} - ` +
|
|
61
|
+
"pass --width/--height/--voffset/--colours to describe it manually");
|
|
62
|
+
}
|
|
63
|
+
const statusReceived = new Promise((resolve, reject) => {
|
|
64
|
+
conn
|
|
65
|
+
.startNotifications(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.status, (data) => {
|
|
66
|
+
const { errorCode } = (0, protocol_1.decodeStatus)(data);
|
|
67
|
+
if (errorCode === 0) {
|
|
68
|
+
resolve();
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
reject(new Error(`zhsunyco device reported error 0x${errorCode.toString(16).padStart(2, "0")} after refresh`));
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
.catch(reject);
|
|
75
|
+
});
|
|
59
76
|
try {
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
const dataChar = await service.getCharacteristic(protocol_1.WOLINK_CHARACTERISTIC_UUIDS.data);
|
|
63
|
-
const configChar = await service.getCharacteristic(protocol_1.WOLINK_CHARACTERISTIC_UUIDS.config);
|
|
64
|
-
const authChar = await service.getCharacteristic(protocol_1.WOLINK_CHARACTERISTIC_UUIDS.authenticate);
|
|
65
|
-
const statusChar = await service.getCharacteristic(protocol_1.WOLINK_CHARACTERISTIC_UUIDS.status);
|
|
66
|
-
const info = (0, protocol_1.decodeAdvertisedInfo)(await configChar.readValue());
|
|
67
|
-
if (!info) {
|
|
68
|
-
throw new Error("zhsunyco device did not return valid config data");
|
|
69
|
-
}
|
|
70
|
-
const metadata = config.modelOverride ? { pid: info.pid, ...config.modelOverride } : this.metadataForPid(info.pid, info.hwVersion);
|
|
71
|
-
if (!metadata) {
|
|
72
|
-
throw new Error(`zhsunyco device reports unrecognised PID 0x${info.pid.toString(16).padStart(4, "0")} - ` +
|
|
73
|
-
"pass --width/--height/--voffset/--colours to describe it manually");
|
|
74
|
-
}
|
|
75
|
-
const statusReceived = new Promise((resolve, reject) => {
|
|
76
|
-
statusChar.once("valuechanged", (data) => {
|
|
77
|
-
const { errorCode } = (0, protocol_1.decodeStatus)(data);
|
|
78
|
-
if (errorCode === 0) {
|
|
79
|
-
resolve();
|
|
80
|
-
}
|
|
81
|
-
else {
|
|
82
|
-
reject(new Error(`zhsunyco device reported error 0x${errorCode.toString(16).padStart(2, "0")} after refresh`));
|
|
83
|
-
}
|
|
84
|
-
});
|
|
85
|
-
});
|
|
86
|
-
await statusChar.startNotifications();
|
|
87
|
-
const challenge = await authChar.readValue();
|
|
88
|
-
await authChar.writeValueWithoutResponse((0, protocol_1.authResponse)(challenge, aesKey));
|
|
77
|
+
const challenge = await conn.read(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.authenticate);
|
|
78
|
+
await conn.write(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.authenticate, (0, protocol_1.authResponse)(challenge, aesKey), false);
|
|
89
79
|
await (0, bleDiscovery_1.sleep)(AUTH_SETTLE_DELAY_MS);
|
|
90
80
|
const framed = (0, reframe_1.reframeBitmap)(bitmap, metadata.width, metadata.height - metadata.voffset, config.reframe ?? "crop");
|
|
91
81
|
const pixelData = (0, encode_1.encodeBitmap)(framed, metadata);
|
|
92
82
|
for (let offset = 0; offset < pixelData.length; offset += UPLOAD_CHUNK_SIZE) {
|
|
93
83
|
const chunk = pixelData.subarray(offset, offset + UPLOAD_CHUNK_SIZE);
|
|
94
|
-
await
|
|
84
|
+
await conn.write(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.data, Buffer.concat([(0, protocol_1.commandHeader)(protocol_1.COMMAND.uploadBlock, offset), chunk]), true);
|
|
95
85
|
await (0, bleDiscovery_1.sleep)(CHUNK_WRITE_DELAY_MS);
|
|
96
86
|
}
|
|
97
|
-
await
|
|
98
|
-
|
|
87
|
+
await conn.write(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.data, (0, protocol_1.commandHeader)(protocol_1.COMMAND.refreshUncompressed, pixelData.length), true);
|
|
88
|
+
// `Promise.race` can't cancel its loser, so once `statusReceived` settles (the common case)
|
|
89
|
+
// this timer would otherwise sit alive for the rest of its 60s regardless - see the identical
|
|
90
|
+
// reasoning on `readDeviceDetails`'s own race, below.
|
|
91
|
+
let statusTimer;
|
|
92
|
+
const statusTimeout = new Promise((resolve) => {
|
|
93
|
+
statusTimer = setTimeout(resolve, STATUS_WAIT_TIMEOUT_MS);
|
|
94
|
+
});
|
|
95
|
+
try {
|
|
96
|
+
await Promise.race([statusReceived, statusTimeout]);
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
clearTimeout(statusTimer);
|
|
100
|
+
}
|
|
99
101
|
}
|
|
100
102
|
finally {
|
|
101
|
-
await
|
|
103
|
+
await conn.stopNotifications(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.status).catch(() => { });
|
|
102
104
|
}
|
|
103
105
|
}
|
|
104
106
|
finally {
|
|
105
|
-
|
|
107
|
+
await conn.disconnect();
|
|
106
108
|
}
|
|
107
109
|
}
|
|
108
110
|
}
|
|
109
111
|
exports.ZhsunycoDriver = ZhsunycoDriver;
|
|
110
112
|
/**
|
|
111
113
|
* Battery level needs a connection regardless, so reuse it to also fill in the PID/hwVersion
|
|
112
|
-
* when the advertisement didn't carry decodable manufacturer data -
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
* config characteristic `paint()` reads, just to identify the device rather than to size a
|
|
114
|
+
* when the advertisement didn't carry decodable manufacturer data - a scan matched purely by name
|
|
115
|
+
* prefix can lack that, which would otherwise leave a real, nearby device's model (and so its entry
|
|
116
|
+
* in the config UI's device picker - see `deviceOptions()` in `config.ts`) silently missing. Reads
|
|
117
|
+
* the same config characteristic `paint()` reads, just to identify the device rather than to size a
|
|
117
118
|
* render.
|
|
118
119
|
*/
|
|
119
|
-
async function readDeviceDetails(
|
|
120
|
+
async function readDeviceDetails(address, advertisedInfo, connect) {
|
|
120
121
|
const fallback = { info: advertisedInfo, batteryMv: undefined };
|
|
121
122
|
const read = async () => {
|
|
123
|
+
let conn;
|
|
122
124
|
try {
|
|
123
|
-
await (
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const batteryMv = (0, protocol_1.decodeBatteryMv)(await batteryChar.readValue());
|
|
129
|
-
let info = advertisedInfo;
|
|
130
|
-
if (!info) {
|
|
131
|
-
const configChar = await service.getCharacteristic(protocol_1.WOLINK_CHARACTERISTIC_UUIDS.config);
|
|
132
|
-
info = (0, protocol_1.decodeAdvertisedInfo)(await configChar.readValue());
|
|
133
|
-
}
|
|
134
|
-
return { info, batteryMv };
|
|
135
|
-
}
|
|
136
|
-
finally {
|
|
137
|
-
await device.disconnect();
|
|
125
|
+
conn = await connect();
|
|
126
|
+
const batteryMv = (0, protocol_1.decodeBatteryMv)(await conn.read(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.battery));
|
|
127
|
+
let info = advertisedInfo;
|
|
128
|
+
if (!info) {
|
|
129
|
+
info = (0, protocol_1.decodeAdvertisedInfo)(await conn.read(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.config));
|
|
138
130
|
}
|
|
131
|
+
return { info, batteryMv };
|
|
139
132
|
}
|
|
140
133
|
catch (err) {
|
|
141
134
|
// Swallowed rather than thrown - a device that refuses this connect (e.g. busy elsewhere,
|
|
@@ -145,16 +138,27 @@ async function readDeviceDetails(device, address, advertisedInfo) {
|
|
|
145
138
|
console.error(`${pluginVersion_1.PLUGIN_NAME}: zhsunyco [${address}]: battery/config read failed: ${err.message}`);
|
|
146
139
|
return fallback;
|
|
147
140
|
}
|
|
141
|
+
finally {
|
|
142
|
+
await conn?.disconnect().catch(() => { });
|
|
143
|
+
}
|
|
148
144
|
};
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
145
|
+
// A GATT call has no timeout of its own - race the whole read so one unresponsive device can't
|
|
146
|
+
// stall the rest of the scan (see `plugin.ts`'s `scanInProgress`, which otherwise stays set forever
|
|
147
|
+
// and silently skips every later scan). `Promise.race` alone can't cancel its loser, so a `read()`
|
|
148
|
+
// that settles fast (e.g. `connect` rejecting immediately because the device is busy elsewhere -
|
|
149
|
+
// exactly the case this function's own doc comment covers) would otherwise leave this timer running
|
|
150
|
+
// for the rest of its 30s regardless - harmless in the end, but needless to hold onto that long.
|
|
151
|
+
let timer;
|
|
152
|
+
const timeout = new Promise((resolve) => {
|
|
153
|
+
timer = setTimeout(() => {
|
|
154
|
+
console.error(`${pluginVersion_1.PLUGIN_NAME}: zhsunyco [${address}]: battery/config read timed out after ${IDENTIFY_READ_TIMEOUT_MS * 2}ms`);
|
|
155
|
+
resolve(fallback);
|
|
156
|
+
}, IDENTIFY_READ_TIMEOUT_MS * 3);
|
|
157
|
+
});
|
|
158
|
+
try {
|
|
159
|
+
return await Promise.race([read(), timeout]);
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
clearTimeout(timer);
|
|
163
|
+
}
|
|
160
164
|
}
|
package/dist/plugin.js
CHANGED
|
@@ -10,7 +10,7 @@ const bleDiscovery_1 = require("./devices/bleDiscovery");
|
|
|
10
10
|
const discoveredDevicesStore_1 = require("./devices/discoveredDevicesStore");
|
|
11
11
|
const repaintScheduler_1 = require("./repaintScheduler");
|
|
12
12
|
/** Mirrors signalk-bluetti-plugin's convention: scan briefly, report finds via plugin status for the user to copy-paste. */
|
|
13
|
-
async function runStartupScan(app, durationSeconds) {
|
|
13
|
+
async function runStartupScan(app, durationSeconds, useBleApi) {
|
|
14
14
|
const alreadyRunning = (0, discoveryCoordinator_1.scanInProgressSince)();
|
|
15
15
|
if (alreadyRunning !== undefined) {
|
|
16
16
|
const elapsedSeconds = ((Date.now() - alreadyRunning) / 1000).toFixed(0);
|
|
@@ -21,7 +21,7 @@ async function runStartupScan(app, durationSeconds) {
|
|
|
21
21
|
app.setPluginStatus(`Scanning for ESL devices for ${durationSeconds}s...`);
|
|
22
22
|
}
|
|
23
23
|
const startedAt = Date.now();
|
|
24
|
-
const { foundThisScan } = await (0, discoveryCoordinator_1.ensureScan)(app, durationSeconds).catch((err) => {
|
|
24
|
+
const { foundThisScan } = await (0, discoveryCoordinator_1.ensureScan)(app, durationSeconds, useBleApi).catch((err) => {
|
|
25
25
|
app.debug(`startup scan failed: ${err.message}`);
|
|
26
26
|
return { foundThisScan: [] };
|
|
27
27
|
});
|
|
@@ -36,6 +36,10 @@ async function runStartupScan(app, durationSeconds) {
|
|
|
36
36
|
function createPlugin(app) {
|
|
37
37
|
(0, registry_1.registerDriver)(new zhsunyco_1.ZhsunycoDriver());
|
|
38
38
|
(0, registry_1.registerDriver)(new gicisky_1.GiciskyDriver());
|
|
39
|
+
// Present on SignalK server >= 2.32.0 (the BLE Provider/Consumer API - "BLE Manager" in the admin
|
|
40
|
+
// UI); `ServerAPI`'s own type says this is always defined, but that's only true on servers new
|
|
41
|
+
// enough to have added it - checked at runtime rather than trusted from the type.
|
|
42
|
+
const bleApiAvailable = !!app.bleApi;
|
|
39
43
|
let scheduler;
|
|
40
44
|
let stopped = false;
|
|
41
45
|
const plugin = {
|
|
@@ -55,18 +59,32 @@ function createPlugin(app) {
|
|
|
55
59
|
app.debug(`starting with ${pluginConfig.devices.length} configured device(s)`);
|
|
56
60
|
(0, config_1.healNestedConfig)(app);
|
|
57
61
|
stopped = false;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
62
|
+
const useBleApi = pluginConfig.useBleApi && bleApiAvailable;
|
|
63
|
+
if (pluginConfig.useBleApi && !bleApiAvailable) {
|
|
64
|
+
app.debug("useBleApi is enabled but this SignalK server has no BLE Manager API (requires >= 2.32.0) - falling back to direct BlueZ access");
|
|
65
|
+
}
|
|
66
|
+
app.debug(useBleApi ? "using the SignalK BLE Manager API for Bluetooth access" : "using direct BlueZ access for Bluetooth");
|
|
67
|
+
const start = () => {
|
|
63
68
|
if (stopped)
|
|
64
69
|
return;
|
|
65
70
|
if (pluginConfig.scanOnStart) {
|
|
66
|
-
void runStartupScan(app, pluginConfig.scanDurationSeconds);
|
|
71
|
+
void runStartupScan(app, pluginConfig.scanDurationSeconds, useBleApi);
|
|
67
72
|
}
|
|
68
73
|
scheduler = (0, repaintScheduler_1.startRepaintScheduler)(app, pluginConfig);
|
|
69
|
-
}
|
|
74
|
+
};
|
|
75
|
+
if (useBleApi) {
|
|
76
|
+
// The server/provider governs its own local-adapter readiness once BLE Manager mode owns
|
|
77
|
+
// `hci0` (or has none at all, behind a remote gateway) - waiting on a *local* BlueZ adapter
|
|
78
|
+
// here would be waiting on something this mode may never even need.
|
|
79
|
+
start();
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
// Waits (with backoff, indefinitely on Linux) for a BLE adapter before the startup scan or
|
|
83
|
+
// the repaint scheduler touch BLE at all - see `waitForAdapter`'s doc comment on the
|
|
84
|
+
// boot-time race this covers. `stopped` is checked again inside `start()`, not just passed
|
|
85
|
+
// as `cancelled`, since the wait can also resolve `true` on its own right as `stop()` runs.
|
|
86
|
+
void (0, bleDiscovery_1.waitForAdapter)((message) => app.debug(message), () => stopped).then(start);
|
|
87
|
+
}
|
|
70
88
|
},
|
|
71
89
|
stop() {
|
|
72
90
|
stopped = true;
|