@rhizomatics/signalk-einklabel-plugin 1.2.2 → 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.
@@ -1,14 +1,13 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.GiciskyDriver = void 0;
4
- const bleDiscovery_1 = require("../bleDiscovery");
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
- const DEVICE_DISCOVERY_TIMEOUT_MS = 30000;
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(device, address, name, manufacturerId, manufacturerData) {
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: await device
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 { bluetooth, destroy } = (0, bleDiscovery_1.createBluetooth)();
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 adapter = await bluetooth.defaultAdapter();
53
- const device = await (0, bleDiscovery_1.getOrDiscoverDevice)(adapter, config.address, DEVICE_DISCOVERY_TIMEOUT_MS);
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 gatt = await device.gatt();
83
- const { cmd, img } = await findCommandAndImageCharacteristics(gatt);
84
- await cmd.startNotifications();
85
- try {
86
- const startAck = await writeAndAwaitAck(cmd, cmd, Buffer.from([0x01]));
87
- const chunkSize = ((0, protocol_1.decodeBlockSize)(startAck) ?? DEFAULT_BLOCK_SIZE) - PART_INDEX_HEADER_LENGTH;
88
- await writeAndAwaitAck(cmd, cmd, (0, protocol_1.writeScreenCommand)(payload.length, layout.packing === "chunked"));
89
- const startImageAck = await writeAndAwaitAck(cmd, cmd, Buffer.from([0x03]));
90
- const started = (0, protocol_1.decodeTransferAck)(startImageAck);
91
- if (!started?.ok) {
92
- throw new Error(`gicisky device rejected start-image-transfer request: ${startImageAck.toString("hex")}`);
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
- let part = started.nextPart;
95
- let lastPart = -1;
96
- let repeats = 0;
97
- while (part * chunkSize < payload.length) {
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
- finally {
118
- await cmd.stopNotifications().catch(() => { });
101
+ else {
102
+ repeats = 0;
103
+ lastPart = decoded.nextPart;
104
+ }
105
+ part = decoded.nextPart;
119
106
  }
120
107
  }
121
108
  finally {
122
- await device.disconnect();
109
+ await conn.stopNotifications(cmdServiceUuid, cmdUuid).catch(() => { });
123
110
  }
124
111
  }
125
112
  finally {
126
- destroy();
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(gatt) {
158
+ async function findCommandAndImageCharacteristics(conn) {
137
159
  const candidates = [];
138
- for (const serviceUuid of await gatt.services()) {
139
- if (!serviceUuid.toLowerCase().startsWith(protocol_1.CANDIDATE_SERVICE_UUID_PREFIX)) {
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 service = await gatt.getPrimaryService(serviceUuid);
143
- for (const charUuid of await service.characteristics()) {
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 { cmd: candidates[0].char, img: candidates[1].char };
152
- }
153
- /** Writes `data` to `writeChar`, then awaits the next notification on `notifyChar` (which may be the same characteristic). */
154
- function writeAndAwaitAck(writeChar, notifyChar, data) {
155
- const ack = new Promise((resolve, reject) => {
156
- const timer = setTimeout(() => {
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
  }
@@ -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 (see `bleDiscovery.ts`'s `forEachAdvertisedDevice`)
74
- * has already matched to this vendor via `matchesAdvertisement` - only called for matches, so a
75
- * device gets at most one vendor-specific connect/read regardless of how many drivers are
76
- * registered, not one attempt per driver.
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(device: Device, address: string, name: string | undefined, manufacturerId: number | undefined, manufacturerData: Buffer | undefined): Promise<DiscoveredDevice>;
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(device: Device, address: string, name: string | undefined, manufacturerId: number | undefined, manufacturerData: Buffer | undefined): Promise<DiscoveredDevice>;
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
- const DEVICE_DISCOVERY_TIMEOUT_MS = 30000;
16
- /** Used while identifying a device during a scan - kept short since a scan may be enumerating several devices. */
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(device, address, name, manufacturerId, manufacturerData) {
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(device, address, advertisedInfo);
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: await device
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 { bluetooth, destroy } = (0, bleDiscovery_1.createBluetooth)();
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 adapter = await bluetooth.defaultAdapter();
57
- const device = await (0, bleDiscovery_1.getOrDiscoverDevice)(adapter, config.address, DEVICE_DISCOVERY_TIMEOUT_MS);
58
- await (0, bleDiscovery_1.connectWithTimeout)(device, config.connectTimeoutMs ?? DEFAULT_PAINT_CONNECT_TIMEOUT_MS);
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 gatt = await device.gatt();
61
- const service = await gatt.getPrimaryService(protocol_1.WOLINK_SERVICE_UUID);
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 dataChar.writeValueWithResponse(Buffer.concat([(0, protocol_1.commandHeader)(protocol_1.COMMAND.uploadBlock, offset), chunk]));
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 dataChar.writeValueWithResponse((0, protocol_1.commandHeader)(protocol_1.COMMAND.refreshUncompressed, pixelData.length));
98
- await Promise.race([statusReceived, (0, bleDiscovery_1.sleep)(STATUS_WAIT_TIMEOUT_MS)]);
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 device.disconnect();
103
+ await conn.stopNotifications(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.status).catch(() => { });
102
104
  }
103
105
  }
104
106
  finally {
105
- destroy();
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 - BlueZ's cached
113
- * advertisement for a device matched purely by its name prefix can lack that, which would
114
- * otherwise leave a real, nearby device's model (and so its entry in the config UI's
115
- * device picker - see `deviceOptions()` in `config.ts`) silently missing. Reads the same
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(device, address, advertisedInfo) {
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 (0, bleDiscovery_1.connectWithTimeout)(device, SCAN_CONNECT_TIMEOUT_MS);
124
- try {
125
- const gatt = await device.gatt();
126
- const service = await gatt.getPrimaryService(protocol_1.WOLINK_SERVICE_UUID);
127
- const batteryChar = await service.getCharacteristic(protocol_1.WOLINK_CHARACTERISTIC_UUIDS.battery);
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
- // `connectWithTimeout` bounds the connect step itself, but a GATT call past that point (e.g.
150
- // `getPrimaryService`/`readValue`) has no timeout of its own either - race the whole read so
151
- // one unresponsive device can't stall the rest of the scan (see `plugin.ts`'s `scanInProgress`,
152
- // which otherwise stays set forever and silently skips every later scan).
153
- return Promise.race([
154
- read(),
155
- (0, bleDiscovery_1.sleep)(SCAN_CONNECT_TIMEOUT_MS * 3).then(() => {
156
- console.error(`${pluginVersion_1.PLUGIN_NAME}: zhsunyco [${address}]: battery/config read timed out after ${SCAN_CONNECT_TIMEOUT_MS * 2}ms`);
157
- return fallback;
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
  }
@@ -0,0 +1,27 @@
1
+ import { EmailConfig } from "../config";
2
+ import { Bitmap } from "../render/types";
3
+ import { FieldRow } from "../render/fieldsTable";
4
+ /** Either the SVG template's field-by-field table (`buildFieldsTable`), a `TemplateProvider`'s own rendered-content text (`describeContent`), or neither (a provider offered no `describeContent`). */
5
+ export type EmailContentBody = {
6
+ kind: "table";
7
+ rows: FieldRow[];
8
+ } | {
9
+ kind: "text";
10
+ text: string;
11
+ } | {
12
+ kind: "none";
13
+ };
14
+ export interface LabelEmailContent {
15
+ friendlyName: string;
16
+ description?: string;
17
+ bitmap: Bitmap;
18
+ body: EmailContentBody;
19
+ }
20
+ /**
21
+ * Renders and sends one device's repaint as an email - a PNG attachment (also shown inline via a `cid`
22
+ * reference), the same field-by-field table `esl-cli fields` shows for a hand-authored SVG template (or
23
+ * a `TemplateProvider`'s own rendered-content text for something like a GenAI prompt - see `body`), and a
24
+ * link back to the docs site. Called from `considerRepaint` (`../repaintScheduler.ts`) whenever a
25
+ * device's `emailTo` is set and its render succeeds.
26
+ */
27
+ export declare function sendLabelEmail(emailConfig: EmailConfig, to: string, content: LabelEmailContent): Promise<void>;