@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.
- package/CHANGELOG.md +28 -0
- package/README.md +97 -9
- package/dist/cli/index.d.ts +4 -1
- package/dist/cli/index.js +30 -6
- package/dist/config.d.ts +53 -14
- package/dist/config.js +172 -41
- package/dist/devices/bleBackend.d.ts +49 -0
- package/dist/devices/bleBackend.js +268 -0
- package/dist/devices/bleDiscovery.d.ts +20 -2
- package/dist/devices/bleDiscovery.js +101 -3
- package/dist/devices/discoveryCoordinator.d.ts +25 -6
- package/dist/devices/discoveryCoordinator.js +148 -9
- package/dist/devices/gattConnection.d.ts +9 -0
- package/dist/devices/gattConnection.js +2 -0
- package/dist/devices/gicisky/compression.d.ts +22 -9
- package/dist/devices/gicisky/compression.js +128 -13
- package/dist/devices/gicisky/encode.d.ts +1 -1
- package/dist/devices/gicisky/encode.js +2 -2
- package/dist/devices/gicisky/index.d.ts +7 -2
- package/dist/devices/gicisky/index.js +118 -96
- package/dist/devices/gicisky/layout.d.ts +13 -1
- package/dist/devices/gicisky/layout.js +21 -0
- package/dist/devices/types.d.ts +48 -6
- package/dist/devices/types.js +2 -0
- package/dist/devices/zhsunyco/compression.d.ts +1 -0
- package/dist/devices/zhsunyco/compression.js +30 -0
- package/dist/devices/zhsunyco/index.d.ts +8 -2
- package/dist/devices/zhsunyco/index.js +92 -82
- package/dist/devices/zhsunyco/protocol.d.ts +2 -0
- package/dist/devices/zhsunyco/protocol.js +2 -0
- package/dist/plugin.js +38 -11
- package/dist/render/mirror.d.ts +11 -0
- package/dist/render/mirror.js +24 -0
- package/dist/repaintScheduler.js +29 -12
- package/docs/assets/images/mini_tidal_clock.png +0 -0
- 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(
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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);
|
|
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
|
|
83
|
-
const
|
|
84
|
-
await
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
118
|
-
|
|
112
|
+
else {
|
|
113
|
+
repeats = 0;
|
|
114
|
+
lastPart = decoded.nextPart;
|
|
115
|
+
}
|
|
116
|
+
part = decoded.nextPart;
|
|
119
117
|
}
|
|
120
118
|
}
|
|
121
119
|
finally {
|
|
122
|
-
await
|
|
120
|
+
await conn.stopNotifications(cmdServiceUuid, cmdUuid).catch(() => { });
|
|
123
121
|
}
|
|
124
122
|
}
|
|
125
123
|
finally {
|
|
126
|
-
|
|
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(
|
|
169
|
+
async function findCommandAndImageCharacteristics(conn) {
|
|
137
170
|
const candidates = [];
|
|
138
|
-
for (const
|
|
139
|
-
if (!
|
|
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
|
|
143
|
-
|
|
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 {
|
|
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);
|
|
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
|
|
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
|
+
}
|
package/dist/devices/types.d.ts
CHANGED
|
@@ -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 (
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
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(
|
|
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
|
}
|
package/dist/devices/types.js
CHANGED
|
@@ -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(
|
|
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
|
}
|