@rhizomatics/signalk-einklabel-plugin 1.2.3 → 1.3.0-beta10

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.
Files changed (36) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +97 -9
  3. package/dist/cli/index.d.ts +4 -1
  4. package/dist/cli/index.js +30 -6
  5. package/dist/config.d.ts +53 -14
  6. package/dist/config.js +172 -41
  7. package/dist/devices/bleBackend.d.ts +49 -0
  8. package/dist/devices/bleBackend.js +268 -0
  9. package/dist/devices/bleDiscovery.d.ts +20 -2
  10. package/dist/devices/bleDiscovery.js +101 -3
  11. package/dist/devices/discoveryCoordinator.d.ts +25 -6
  12. package/dist/devices/discoveryCoordinator.js +148 -9
  13. package/dist/devices/gattConnection.d.ts +9 -0
  14. package/dist/devices/gattConnection.js +2 -0
  15. package/dist/devices/gicisky/compression.d.ts +22 -9
  16. package/dist/devices/gicisky/compression.js +128 -13
  17. package/dist/devices/gicisky/encode.d.ts +1 -1
  18. package/dist/devices/gicisky/encode.js +2 -2
  19. package/dist/devices/gicisky/index.d.ts +7 -2
  20. package/dist/devices/gicisky/index.js +118 -96
  21. package/dist/devices/gicisky/layout.d.ts +13 -1
  22. package/dist/devices/gicisky/layout.js +21 -0
  23. package/dist/devices/types.d.ts +48 -6
  24. package/dist/devices/types.js +2 -0
  25. package/dist/devices/zhsunyco/compression.d.ts +1 -0
  26. package/dist/devices/zhsunyco/compression.js +30 -0
  27. package/dist/devices/zhsunyco/index.d.ts +8 -2
  28. package/dist/devices/zhsunyco/index.js +92 -82
  29. package/dist/devices/zhsunyco/protocol.d.ts +2 -0
  30. package/dist/devices/zhsunyco/protocol.js +2 -0
  31. package/dist/plugin.js +38 -11
  32. package/dist/render/mirror.d.ts +11 -0
  33. package/dist/render/mirror.js +24 -0
  34. package/dist/repaintScheduler.js +29 -12
  35. package/docs/assets/images/mini_tidal_clock.png +0 -0
  36. package/package.json +3 -3
@@ -1,4 +1,3 @@
1
- import { Device } from "@naugehyde/node-ble";
2
1
  import { Bitmap } from "../../render/types";
3
2
  import { DeviceMetadata, DiscoveredDevice, VendorDeviceConfig, VendorDriver } from "../types";
4
3
  export declare class GiciskyDriver implements VendorDriver {
@@ -6,6 +5,12 @@ export declare class GiciskyDriver implements VendorDriver {
6
5
  matchesAdvertisement(_name: string | undefined, manufacturerId: number | undefined): boolean;
7
6
  metadataForPid(pid: number): DeviceMetadata | undefined;
8
7
  supportedDevices(): DeviceMetadata[];
9
- identifyDevice(device: Device, address: string, name: string | undefined, manufacturerId: number | undefined, manufacturerData: Buffer | undefined): Promise<DiscoveredDevice>;
8
+ identifyDevice({ address, name, manufacturerId, manufacturerData, rssi, }: {
9
+ address: string;
10
+ name: string | undefined;
11
+ manufacturerId: number | undefined;
12
+ manufacturerData: Buffer | undefined;
13
+ rssi: number | undefined;
14
+ }): Promise<DiscoveredDevice>;
10
15
  paint(bitmap: Bitmap, config: VendorDeviceConfig): Promise<void>;
11
16
  }
@@ -1,14 +1,14 @@
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
+ const mirror_1 = require("../../render/mirror");
9
10
  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`. */
11
+ /** How long to actively rescan for a fresh advertisement when the cached one is missing/stale - see `BleBackend.waitForManufacturerData`. */
12
12
  const MANUFACTURER_DATA_RESCAN_TIMEOUT_MS = 15000;
13
13
  const DEFAULT_PAINT_CONNECT_TIMEOUT_MS = 60000;
14
14
  const ACK_TIMEOUT_MS = 15000;
@@ -30,7 +30,7 @@ class GiciskyDriver {
30
30
  supportedDevices() {
31
31
  return metadata_1.GICISKY_PID_METADATA;
32
32
  }
33
- async identifyDevice(device, address, name, manufacturerId, manufacturerData) {
33
+ async identifyDevice({ address, name, manufacturerId, manufacturerData, rssi, }) {
34
34
  const info = manufacturerData ? (0, protocol_1.decodeAdvertisedInfo)(manufacturerData) : undefined;
35
35
  return {
36
36
  address,
@@ -40,128 +40,150 @@ class GiciskyDriver {
40
40
  metadata: info ? this.metadataForPid(info.deviceId) : undefined,
41
41
  manufacturerId,
42
42
  batteryMv: info?.batteryMv,
43
- rssi: await device
44
- .getRSSI()
45
- .then((value) => (value === undefined ? undefined : Number(value)))
46
- .catch(() => undefined),
43
+ rssi,
47
44
  };
48
45
  }
49
46
  async paint(bitmap, config) {
50
- const { bluetooth, destroy } = (0, bleDiscovery_1.createBluetooth)();
47
+ const backend = config.gattBackend ?? (0, bleBackend_1.nodeBleBackend)();
48
+ /**
49
+ * Unlike zhsunyco, there's no GATT characteristic that reports the device's PID on demand -
50
+ * the only source for it is the advertisement. That cache can be empty or stale (e.g. nothing
51
+ * has actively scanned since the adapter/provider last restarted) even though a connect below
52
+ * would succeed instantly via BlueZ's/the BLE Manager's own device cache - so rescan for a fresh
53
+ * advertisement here rather than failing on the first read.
54
+ */
55
+ const manufacturerData = await backend.waitForManufacturerData(config.address, protocol_1.GICISKY_MANUFACTURER_ID, MANUFACTURER_DATA_RESCAN_TIMEOUT_MS);
56
+ const info = manufacturerData ? (0, protocol_1.decodeAdvertisedInfo)(manufacturerData) : undefined;
57
+ // A device that's quiet right now (e.g. still refreshing from the last paint) can still be painted
58
+ // when its PID is already known from config/a previous scan - the connect below doesn't need the
59
+ // advertisement, only this lookup does.
60
+ const pid = info?.deviceId ?? config.pid;
61
+ const metadata = config.modelOverride
62
+ ? { pid: pid ?? 0, ...config.modelOverride }
63
+ : pid !== undefined
64
+ ? this.metadataForPid(pid)
65
+ : undefined;
66
+ if (!metadata) {
67
+ throw new Error(pid === undefined
68
+ ? "gicisky device isn't advertising - rescanned but got nothing back (out of range, asleep, or already connected " +
69
+ "elsewhere) - pass --width/--height/--voffset/--colours to describe it manually"
70
+ : `gicisky device reports unrecognised deviceId 0x${pid.toString(16).padStart(4, "0")} - ` +
71
+ "pass --width/--height/--voffset/--colours to describe it manually");
72
+ }
73
+ const layout = (0, layout_1.withCompressionFormat)((pid !== undefined && layout_1.GICISKY_PID_LAYOUT[pid]) || (0, layout_1.defaultLayoutFor)(metadata.colours), config.compressionFormat ?? "auto", metadata.colours);
74
+ const framed = (0, mirror_1.mirrorBitmap)((0, reframe_1.reframeBitmap)(bitmap, metadata.width, metadata.height, config.reframe ?? "crop"), config.mirror ?? "none");
75
+ const payload = (0, encode_1.encodeBitmap)(framed, metadata, layout, config.compress ?? true);
76
+ const conn = await backend.connectGatt(config.address, config.connectTimeoutMs ?? DEFAULT_PAINT_CONNECT_TIMEOUT_MS);
51
77
  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);
78
+ const { cmdServiceUuid, cmdUuid, imgServiceUuid, imgUuid } = await findCommandAndImageCharacteristics(conn);
79
+ const ack = new AckChannel();
80
+ await conn.startNotifications(cmdServiceUuid, cmdUuid, ack.onNotify);
81
81
  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")}`);
82
+ const startAck = await writeAndAwaitAck(conn, cmdServiceUuid, cmdUuid, Buffer.from([0x01]), ack);
83
+ const chunkSize = ((0, protocol_1.decodeBlockSize)(startAck) ?? DEFAULT_BLOCK_SIZE) - PART_INDEX_HEADER_LENGTH;
84
+ await writeAndAwaitAck(conn, cmdServiceUuid, cmdUuid, (0, protocol_1.writeScreenCommand)(payload.length, layout.packing === "chunked"), ack);
85
+ const startImageAck = await writeAndAwaitAck(conn, cmdServiceUuid, cmdUuid, Buffer.from([0x03]), ack);
86
+ const started = (0, protocol_1.decodeTransferAck)(startImageAck);
87
+ if (!started?.ok) {
88
+ throw new Error(`gicisky device rejected start-image-transfer request: ${startImageAck.toString("hex")}`);
89
+ }
90
+ let part = started.nextPart;
91
+ let lastPart = -1;
92
+ let repeats = 0;
93
+ while (part * chunkSize < payload.length) {
94
+ const chunk = payload.subarray(part * chunkSize, Math.min(part * chunkSize + chunkSize, payload.length));
95
+ const ackData = await writeAndAwaitAck(conn, imgServiceUuid, imgUuid, (0, protocol_1.imageChunkPacket)(part, chunk), ack);
96
+ const decoded = (0, protocol_1.decodeTransferAck)(ackData);
97
+ if (decoded && !decoded.ok && part * chunkSize + chunk.length >= payload.length) {
98
+ // The device answers the final chunk with a non-zero status (seen: `05 08 00000000`) once
99
+ // it has the whole image and starts refreshing - the transfer's done, not failed. Matches
100
+ // hass-gicisky's writer, which ends the transfer on any non-zero status rather than erroring.
101
+ break;
93
102
  }
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
- }
109
- }
110
- else {
111
- repeats = 0;
112
- lastPart = decoded.nextPart;
103
+ if (!decoded?.ok) {
104
+ throw new Error(`gicisky device reported an error transferring image part ${part}: ${ackData.toString("hex")}`);
105
+ }
106
+ if (decoded.nextPart === lastPart) {
107
+ repeats++;
108
+ if (repeats >= MAX_STALLED_REPEATS) {
109
+ throw new Error(`gicisky image transfer stalled - device kept re-requesting part ${decoded.nextPart}`);
113
110
  }
114
- part = decoded.nextPart;
115
111
  }
116
- }
117
- finally {
118
- await cmd.stopNotifications().catch(() => { });
112
+ else {
113
+ repeats = 0;
114
+ lastPart = decoded.nextPart;
115
+ }
116
+ part = decoded.nextPart;
119
117
  }
120
118
  }
121
119
  finally {
122
- await device.disconnect();
120
+ await conn.stopNotifications(cmdServiceUuid, cmdUuid).catch(() => { });
123
121
  }
124
122
  }
125
123
  finally {
126
- destroy();
124
+ await conn.disconnect();
127
125
  }
128
126
  }
129
127
  }
130
128
  exports.GiciskyDriver = GiciskyDriver;
129
+ /**
130
+ * Single-slot "await the next notification" dispatcher - the gicisky protocol is strictly
131
+ * request/response (never more than one write in flight), so a persistent `startNotifications`
132
+ * callback (`GattConnection`'s shape, unlike node-ble's per-call `.once("valuechanged")`) just needs
133
+ * to hand its next delivery to whichever `waitForAck` call is currently pending. Mirrors how
134
+ * signalk-bluetti-plugin's `ProtocolSession.feed()` dispatches notification bytes to a single
135
+ * pending request.
136
+ */
137
+ class AckChannel {
138
+ constructor() {
139
+ this.onNotify = (data) => {
140
+ const pending = this.pending;
141
+ if (!pending)
142
+ return;
143
+ this.pending = undefined;
144
+ clearTimeout(pending.timer);
145
+ pending.resolve(data);
146
+ };
147
+ }
148
+ waitForAck(timeoutMs) {
149
+ return new Promise((resolve, reject) => {
150
+ const timer = setTimeout(() => {
151
+ this.pending = undefined;
152
+ reject(new Error("gicisky device did not acknowledge in time"));
153
+ }, timeoutMs);
154
+ this.pending = { resolve, timer };
155
+ });
156
+ }
157
+ }
158
+ /** Writes `data` to `(writeServiceUuid, writeUuid)`, then awaits the next ack delivered to `ack`. */
159
+ async function writeAndAwaitAck(conn, writeServiceUuid, writeUuid, data, ack) {
160
+ const pending = ack.waitForAck(ACK_TIMEOUT_MS);
161
+ await conn.write(writeServiceUuid, writeUuid, data, false);
162
+ return pending;
163
+ }
131
164
  /**
132
165
  * Walks every service whose UUID starts `0000f`, collecting their characteristics and sorting by
133
166
  * 16-bit UUID value - mirrors both reference drivers, which locate their command/image
134
167
  * characteristics this way rather than by a hardcoded service UUID (see `protocol.ts`).
135
168
  */
136
- async function findCommandAndImageCharacteristics(gatt) {
169
+ async function findCommandAndImageCharacteristics(conn) {
137
170
  const candidates = [];
138
- for (const serviceUuid of await gatt.services()) {
139
- if (!serviceUuid.toLowerCase().startsWith(protocol_1.CANDIDATE_SERVICE_UUID_PREFIX)) {
171
+ for (const service of await conn.discoverServices()) {
172
+ if (!service.uuid.toLowerCase().startsWith(protocol_1.CANDIDATE_SERVICE_UUID_PREFIX)) {
140
173
  continue;
141
174
  }
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) });
175
+ for (const characteristic of service.characteristics) {
176
+ candidates.push({ serviceUuid: service.uuid, uuid: characteristic.uuid });
145
177
  }
146
178
  }
147
179
  candidates.sort((a, b) => parseInt(a.uuid.slice(4, 8), 16) - parseInt(b.uuid.slice(4, 8), 16));
148
180
  if (candidates.length < 2) {
149
181
  throw new Error(`gicisky device exposes ${candidates.length} candidate characteristic(s) under a "0000f..." service, expected at least 2`);
150
182
  }
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);
183
+ return {
184
+ cmdServiceUuid: candidates[0].serviceUuid,
185
+ cmdUuid: candidates[0].uuid,
186
+ imgServiceUuid: candidates[1].serviceUuid,
187
+ imgUuid: candidates[1].uuid,
188
+ };
167
189
  }
@@ -1,3 +1,4 @@
1
+ import { CompressionFormat } from "../types";
1
2
  /**
2
3
  * Per-model wire-layout quirks, keyed the same way as `GICISKY_PID_METADATA` (by `deviceId`).
3
4
  * These aren't part of the shared `DeviceMetadata` shape since no other vendor needs them -
@@ -9,7 +10,7 @@
9
10
  export type GiciskyPacking =
10
11
  /** Plain concatenated bit-planes, no framing - covers most panels. */
11
12
  "plain"
12
- /** Two bit-planes each split into raw 64-byte chunks framed per `compression.ts`'s `compress()` - the 7.5"/10.2" panels. */
13
+ /** Two bit-planes each split into 64-byte chunks (QuickLZ-compressed or raw) framed per `compression.ts` - the 7.5"/10.2" panels. */
13
14
  | "chunked"
14
15
  /**
15
16
  * A panel this driver can identify (for `scan`/discovery) but can't paint correctly yet - either
@@ -33,3 +34,14 @@ export interface GiciskyLayout {
33
34
  export declare const GICISKY_PID_LAYOUT: Record<number, GiciskyLayout>;
34
35
  /** Best-effort layout for a `modelOverride`d PID this table has no entry for - assumes the common case. */
35
36
  export declare function defaultLayoutFor(colours: string[]): GiciskyLayout;
37
+ /**
38
+ * Applies a user's opt-in `compressionFormat: "chunked"` - switching a `"plain"` two-plane (BW + red)
39
+ * layout to the QuickLZ-chunked framing, with the flagged `writeScreen` command that goes with it.
40
+ * Opt-in rather than a model default because only one data point says a plain panel accepts it:
41
+ * Cabalist's 2023 BLE captures (https://github.com/Cabalist/gicisky_image_notes) of the vendor app
42
+ * sending a 4.2" BWR exactly this way, on firmware that may not match what's shipping now.
43
+ *
44
+ * Throws for layouts the framing can't carry, rather than quietly sending plain anyway - the user
45
+ * asked for chunked, so a repaint error says why they're not getting it.
46
+ */
47
+ export declare function withCompressionFormat(layout: GiciskyLayout, format: CompressionFormat, colours: string[]): GiciskyLayout;
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.GICISKY_PID_LAYOUT = void 0;
4
4
  exports.defaultLayoutFor = defaultLayoutFor;
5
+ exports.withCompressionFormat = withCompressionFormat;
5
6
  const DEFAULT_LAYOUT = {
6
7
  rotation: 0,
7
8
  mirrorX: false,
@@ -33,3 +34,23 @@ exports.GICISKY_PID_LAYOUT = {
33
34
  function defaultLayoutFor(colours) {
34
35
  return { ...DEFAULT_LAYOUT, fourColour: colours.includes("yellow") };
35
36
  }
37
+ /**
38
+ * Applies a user's opt-in `compressionFormat: "chunked"` - switching a `"plain"` two-plane (BW + red)
39
+ * layout to the QuickLZ-chunked framing, with the flagged `writeScreen` command that goes with it.
40
+ * Opt-in rather than a model default because only one data point says a plain panel accepts it:
41
+ * Cabalist's 2023 BLE captures (https://github.com/Cabalist/gicisky_image_notes) of the vendor app
42
+ * sending a 4.2" BWR exactly this way, on firmware that may not match what's shipping now.
43
+ *
44
+ * Throws for layouts the framing can't carry, rather than quietly sending plain anyway - the user
45
+ * asked for chunked, so a repaint error says why they're not getting it.
46
+ */
47
+ function withCompressionFormat(layout, format, colours) {
48
+ if (format === "auto" || layout.packing === "chunked") {
49
+ return layout;
50
+ }
51
+ if (layout.packing !== "plain" || layout.fourColour || !colours.includes("red")) {
52
+ throw new Error('gicisky paint: compressionFormat "chunked" needs a panel sent as separate black/white and red planes - ' +
53
+ 'this model isn\'t (four-colour, black/white only, or unsupported) - set it back to "auto"');
54
+ }
55
+ return { ...layout, packing: "chunked" };
56
+ }
@@ -1,7 +1,17 @@
1
- import { Device } from "@naugehyde/node-ble";
2
1
  import { Bitmap } from "../render/types";
3
2
  import { ReframeMode } from "../render/reframe";
3
+ import { MirrorMode } from "../render/mirror";
4
+ import { BleBackend } from "./bleBackend";
5
+ import { GattConnection } from "./gattConnection";
4
6
  export type Colour = "black" | "white" | "red" | "yellow";
7
+ /**
8
+ * Which wire format to send in - `"auto"` uses whatever the driver's model table says;
9
+ * `"chunked"` opts a gicisky panel with separate BW/red planes (e.g. the 4.2" BWR, normally sent
10
+ * plain) into the QuickLZ-compressed chunk framing the 7.5"/10.2" use - see `withCompressionFormat`
11
+ * in `gicisky/layout.ts`. Ignored by other vendors.
12
+ */
13
+ export type CompressionFormat = "auto" | "chunked";
14
+ export declare const COMPRESSION_FORMATS: CompressionFormat[];
5
15
  /**
6
16
  * Static facts about one device model, keyed by (vendor, pid) by the registry —
7
17
  * PID alone is not assumed unique across vendors.
@@ -52,6 +62,13 @@ export type DeviceModelOverride = Omit<DeviceMetadata, "pid">;
52
62
  /** Per-device settings the user supplies when registering a device, beyond what's in DeviceMetadata. */
53
63
  export interface VendorDeviceConfig {
54
64
  address: string;
65
+ /**
66
+ * The PID already known for this device (from the configured `"<vendor>:<pid>@<address>"` or a
67
+ * previous scan) - a fallback for drivers that otherwise only learn it from a fresh advertisement
68
+ * (gicisky), so a device that's quiet at paint time can still be painted. A live advertised PID
69
+ * takes precedence when there is one.
70
+ */
71
+ pid?: number;
55
72
  /** AES key for vendors that need it, entered by the user. If omitted, vendors that have one may fall back to a stock/manufacturer-default key instead of failing. */
56
73
  aesKey?: string;
57
74
  /** Forces the device model facts instead of looking up the advertised PID - for hardware not yet in the driver's table. */
@@ -60,6 +77,20 @@ export interface VendorDeviceConfig {
60
77
  connectTimeoutMs?: number;
61
78
  /** 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
79
  reframe?: ReframeMode;
80
+ /** Flips the image after reframing, before encoding - see `MirrorMode`. Defaults to `"none"`. */
81
+ mirror?: MirrorMode;
82
+ /** Compress the upload where the vendor protocol supports it (zhsunyco; gicisky's chunked 7.5"/10.2" panels); ignored otherwise. Defaults to `true`. */
83
+ compress?: boolean;
84
+ /** Overrides the model's own wire format - see `CompressionFormat`. Defaults to `"auto"`. */
85
+ compressionFormat?: CompressionFormat;
86
+ /**
87
+ * How `paint()` reaches the device's BLE hardware - omitted (always true for the CLI, which has no
88
+ * `ServerAPI`/`app.bleApi` to source one from) means direct BlueZ access via a fresh
89
+ * `nodeBleBackend()` (`bleBackend.ts`). The SignalK plugin (`plugin.ts`/`repaintScheduler.ts`) passes
90
+ * a `bleApiBackend()` here instead when the user has opted into the SignalK BLE Manager API
91
+ * (`PluginConfig.useBleApi`) and the server offers `app.bleApi`.
92
+ */
93
+ gattBackend?: BleBackend;
63
94
  }
64
95
  export interface VendorDriver {
65
96
  vendor: string;
@@ -70,12 +101,23 @@ export interface VendorDriver {
70
101
  /** All device models this driver currently has confirmed metadata for. */
71
102
  supportedDevices(): DeviceMetadata[];
72
103
  /**
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.
104
+ * Identifies one device the shared caller (`discoveryCoordinator.ts`'s `runScan`, backed by either
105
+ * `forEachAdvertisedDevice` over direct BlueZ or the BLE Manager API's advertisement stream) has
106
+ * already matched to this vendor via `matchesAdvertisement` - only called for matches, so a device
107
+ * gets at most one vendor-specific connect/read regardless of how many drivers are registered, not
108
+ * one attempt per driver.
109
+ *
110
+ * `connect` opens a `GattConnection` to this device on whichever backend the scan is using - call it
111
+ * lazily, only when the advertisement alone doesn't carry enough to identify the device (e.g.
112
+ * zhsunyco's battery/config read fallback); most matches never need it.
77
113
  */
78
- identifyDevice(device: Device, address: string, name: string | undefined, manufacturerId: number | undefined, manufacturerData: Buffer | undefined): Promise<DiscoveredDevice>;
114
+ identifyDevice(advertisement: {
115
+ address: string;
116
+ name: string | undefined;
117
+ manufacturerId: number | undefined;
118
+ manufacturerData: Buffer | undefined;
119
+ rssi: number | undefined;
120
+ }, connect: () => Promise<GattConnection>): Promise<DiscoveredDevice>;
79
121
  /** Quantise the common bitmap to this device's palette/encoding and send it over BLE. */
80
122
  paint(bitmap: Bitmap, config: VendorDeviceConfig): Promise<void>;
81
123
  }
@@ -1,2 +1,4 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.COMPRESSION_FORMATS = void 0;
4
+ exports.COMPRESSION_FORMATS = ["auto", "chunked"];
@@ -0,0 +1 @@
1
+ export declare function compressWolinkBlocks(data: Buffer): Buffer;
@@ -0,0 +1,30 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.compressWolinkBlocks = compressWolinkBlocks;
4
+ const zlib_1 = require("zlib");
5
+ /**
6
+ * Wolink block-deflate payload format, ported from the `zhsunyco_esl` package bundled with the
7
+ * Home Assistant Wolink ESL integration (`zhsunyco_esl/protocol.py`):
8
+ *
9
+ * A5 A6 <blockCount u8> 02, then per block: <1-based index u8> <compressedLength u16le> <raw deflate>
10
+ *
11
+ * Each block holds up to 8 KB of the uncompressed 2bpp buffer. Sent in place of the raw buffer,
12
+ * followed by `COMMAND.refreshCompressed` carrying the compressed length.
13
+ */
14
+ const WOLINK_BLOCK_SIZE = 8192;
15
+ const WOLINK_BLOCK_FORMAT = 0x02;
16
+ function compressWolinkBlocks(data) {
17
+ const blockCount = Math.ceil(data.length / WOLINK_BLOCK_SIZE);
18
+ if (blockCount > 0xff) {
19
+ throw new Error(`zhsunyco compression: ${data.length} bytes needs ${blockCount} blocks, format allows at most 255`);
20
+ }
21
+ const parts = [Buffer.from([0xa5, 0xa6, blockCount, WOLINK_BLOCK_FORMAT])];
22
+ for (let i = 0; i < blockCount; i++) {
23
+ const deflated = (0, zlib_1.deflateRawSync)(data.subarray(i * WOLINK_BLOCK_SIZE, (i + 1) * WOLINK_BLOCK_SIZE), { level: 9 });
24
+ const header = Buffer.alloc(3);
25
+ header[0] = i + 1;
26
+ header.writeUInt16LE(deflated.length, 1);
27
+ parts.push(header, deflated);
28
+ }
29
+ return Buffer.concat(parts);
30
+ }
@@ -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
  }