@rhizomatics/signalk-einklabel-plugin 0.10.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/config.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.BUNDLED_TEMPLATES_DIR = exports.ALL_DEVICES = void 0;
3
+ exports.RENDER_FALLBACK_TEMPLATE_NAME = exports.BUNDLED_TEMPLATES_DIR = exports.ALL_DEVICES = void 0;
4
4
  exports.defaultConfig = defaultConfig;
5
5
  exports.readCurrentConfig = readCurrentConfig;
6
6
  exports.healNestedConfig = healNestedConfig;
@@ -12,6 +12,7 @@ exports.configUiSchema = configUiSchema;
12
12
  const fs_1 = require("fs");
13
13
  const os_1 = require("os");
14
14
  const path_1 = require("path");
15
+ const templateProviders_1 = require("./render/templateProviders");
15
16
  const resolveApiUrl_1 = require("./resolveApiUrl");
16
17
  /**
17
18
  * Special `device` value meaning "every currently-known discovered device" instead of one specific
@@ -31,6 +32,18 @@ exports.ALL_DEVICES = "ALL";
31
32
  * `templates/.assets/lunar_phases`) just to keep a binding the override never touched working.
32
33
  */
33
34
  exports.BUNDLED_TEMPLATES_DIR = (0, path_1.join)(__dirname, "..", "templates");
35
+ /**
36
+ * Bundled template family pushed instead of a device's real content whenever a repaint fails - a broken
37
+ * hand-authored template, or a registered `TemplateProvider`'s `render()` rejecting (e.g.
38
+ * `@rhizomatics/signalk-einklabel-genai-plugin`'s LLM call failing) - see `considerRepaint` in
39
+ * `./repaintScheduler.ts`. Generic on purpose: it has no idea *why* the render failed, only that it
40
+ * did, and that the previous, possibly now-wrong, content must never be left on screen unmarked.
41
+ *
42
+ * Dot-prefixed like `.assets`/`.blank` so `listTemplateFamilies` excludes it from the "Template"
43
+ * dropdown - it's an internal fallback mechanism, not something a user should ever pick as a device's
44
+ * own template.
45
+ */
46
+ exports.RENDER_FALLBACK_TEMPLATE_NAME = ".error";
34
47
  const SIGNALK_HOME_DIR = (0, path_1.join)((0, os_1.homedir)(), ".signalk");
35
48
  const DEFAULT_TEMPLATES_DIR = (0, path_1.join)(SIGNALK_HOME_DIR, "einklabel", "templates");
36
49
  function defaultConfig() {
@@ -117,18 +130,21 @@ function healNestedConfig(app) {
117
130
  });
118
131
  }
119
132
  /**
120
- * Resolves the user-facing `templatesDir` setting to an actual directory, mirroring
121
- * signalk-parquet's `outputDirectory` convention: empty means the default location, a relative
122
- * path is resolved against `~/.signalk` (where SignalK itself stores its config by default), and
123
- * an absolute path is used as-is.
133
+ * Resolves a user-facing `<x>Dir` setting to an actual directory, mirroring signalk-parquet's
134
+ * `outputDirectory` convention: empty means `defaultDir`, a relative path is resolved against
135
+ * `~/.signalk` (where SignalK itself stores its config by default), and an absolute path is used as-is.
124
136
  */
125
- function resolveTemplatesDir(templatesDir) {
126
- const trimmed = templatesDir?.trim();
137
+ function resolveDir(dir, defaultDir) {
138
+ const trimmed = dir?.trim();
127
139
  if (!trimmed) {
128
- return DEFAULT_TEMPLATES_DIR;
140
+ return defaultDir;
129
141
  }
130
142
  return (0, path_1.isAbsolute)(trimmed) ? trimmed : (0, path_1.join)(SIGNALK_HOME_DIR, trimmed);
131
143
  }
144
+ /** See `resolveDir` - `templatesDir`'s own resolution. */
145
+ function resolveTemplatesDir(templatesDir) {
146
+ return resolveDir(templatesDir, DEFAULT_TEMPLATES_DIR);
147
+ }
132
148
  /**
133
149
  * Enum for the combined "device" field, built from recently scanned devices - including ones a
134
150
  * driver identified as its vendor but whose PID isn't in its metadata table yet (clearly labelled,
@@ -219,11 +235,18 @@ function listTemplateFamilies(dir) {
219
235
  .filter((entry) => entry.isDirectory() && !entry.name.startsWith(".") && listTemplateVariants((0, path_1.join)(dir, entry.name)).length > 0)
220
236
  .map((entry) => entry.name);
221
237
  }
222
- /** Local templates (files or template-family directories) take priority over a same-named bundled one; both show up as options. */
238
+ /**
239
+ * Local templates (files or template-family directories) take priority over a same-named bundled one;
240
+ * both show up as options, followed by every registered `TemplateProvider`'s own entries (e.g.
241
+ * `signalk-einklabel-genai-plugin` contributing `"forecast (GenAI)"` - see `./render/templateProviders.ts`)
242
+ * - so a provider-backed "template" is picked from this exact same dropdown, distinguished only by its
243
+ * `suffix`, with no separate render-mode field at all.
244
+ */
223
245
  function templateNameOptions(templatesDir) {
224
246
  const local = [...listSvgFiles(templatesDir), ...listTemplateFamilies(templatesDir)];
225
247
  const bundled = [...listSvgFiles(exports.BUNDLED_TEMPLATES_DIR), ...listTemplateFamilies(exports.BUNDLED_TEMPLATES_DIR)].filter((name) => !local.includes(name));
226
- return [...local, ...bundled];
248
+ const provided = (0, templateProviders_1.allTemplateProviders)().flatMap((provider) => provider.listTemplates());
249
+ return [...local, ...bundled, ...provided];
227
250
  }
228
251
  function sameColours(a, b) {
229
252
  if (a.length !== b.length)
@@ -350,6 +373,12 @@ function configSchema(app, discovered = []) {
350
373
  'address, or "All discovered devices" to paint this same template/trigger to every device the plugin currently ' +
351
374
  "knows about - simplest for a single label, and also covers several identical labels without listing each one.",
352
375
  }, deviceValues, deviceLabels),
376
+ description: {
377
+ type: "string",
378
+ title: "Location/description (optional)",
379
+ description: 'Free-text notes about where this label is physically mounted/viewed from, e.g. "chart table, viewed from ~1m ' +
380
+ 'in poor light" - available to any template as source=einklabel,path=description or source=label,path=description.',
381
+ },
353
382
  templateName: withEnum({ type: "string", title: "Template" }, templateNameOptions(resolveTemplatesDir(current.templatesDir))),
354
383
  repaintTrigger: {
355
384
  type: "string",
@@ -392,6 +421,7 @@ function configUiSchema() {
392
421
  return {
393
422
  devices: {
394
423
  items: {
424
+ description: { "ui:widget": "textarea" },
395
425
  repaintTrigger: { "ui:widget": "radio" },
396
426
  },
397
427
  },
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Wire framing for `packing: "chunked"` devices (the 7.5"/10.2" panels).
3
+ *
4
+ * The vendor firmware's real format supports each 64-byte chunk being either a `0x75`-tagged
5
+ * QuickLZ-compressed block or a `0x74`-tagged raw one - see hass-gicisky's `gicisky_ble/compression.py`,
6
+ * which ports a vendor-specific 64-bucket-hash variant of QuickLZ Level 1 to produce the smaller
7
+ * `0x75` form. This driver always emits the `0x74` raw form instead: larger over the wire, but the
8
+ * framing itself (chunk headers, the part1/part2 split) is unchanged, so a device that accepts
9
+ * hass-gicisky's output accepts this too - it just costs more BLE writes per repaint than a real
10
+ * compressor would.
11
+ */
12
+ /**
13
+ * Frames two equal-length bit-planes (e.g. BW and red) as `[4-byte LE length of planeB]` followed
14
+ * by each plane's raw-chunked bytes, matching `compress()`'s output shape in the reference driver.
15
+ */
16
+ export declare function frameChunkedPlanes(planeA: Buffer, planeB: Buffer): Buffer;
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+ /**
3
+ * Wire framing for `packing: "chunked"` devices (the 7.5"/10.2" panels).
4
+ *
5
+ * The vendor firmware's real format supports each 64-byte chunk being either a `0x75`-tagged
6
+ * QuickLZ-compressed block or a `0x74`-tagged raw one - see hass-gicisky's `gicisky_ble/compression.py`,
7
+ * which ports a vendor-specific 64-bucket-hash variant of QuickLZ Level 1 to produce the smaller
8
+ * `0x75` form. This driver always emits the `0x74` raw form instead: larger over the wire, but the
9
+ * framing itself (chunk headers, the part1/part2 split) is unchanged, so a device that accepts
10
+ * hass-gicisky's output accepts this too - it just costs more BLE writes per repaint than a real
11
+ * compressor would.
12
+ */
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.frameChunkedPlanes = frameChunkedPlanes;
15
+ const CHUNK_SIZE = 64;
16
+ const RAW_CHUNK_TAG = 0x74;
17
+ function chunkRaw(data) {
18
+ const chunks = [];
19
+ for (let offset = 0; offset < data.length; offset += CHUNK_SIZE) {
20
+ const chunk = data.subarray(offset, Math.min(offset + CHUNK_SIZE, data.length));
21
+ const header = Buffer.from([RAW_CHUNK_TAG, 3 + chunk.length, chunk.length]);
22
+ chunks.push(header, chunk);
23
+ }
24
+ return Buffer.concat(chunks);
25
+ }
26
+ /**
27
+ * Frames two equal-length bit-planes (e.g. BW and red) as `[4-byte LE length of planeB]` followed
28
+ * by each plane's raw-chunked bytes, matching `compress()`'s output shape in the reference driver.
29
+ */
30
+ function frameChunkedPlanes(planeA, planeB) {
31
+ const header = Buffer.alloc(4);
32
+ header.writeUInt32LE(planeB.length, 0);
33
+ return Buffer.concat([header, chunkRaw(planeA), chunkRaw(planeB)]);
34
+ }
@@ -0,0 +1,10 @@
1
+ import { Bitmap } from "../../render/types";
2
+ import { DeviceMetadata } from "../types";
3
+ import { GiciskyLayout } from "./layout";
4
+ /**
5
+ * Quantises a common RGBA bitmap and packs it into the wire format this model's `GiciskyLayout`
6
+ * calls for. Mirrors `GiciskyClient._make_image_packet` (hass-gicisky's `writer.py`), except
7
+ * colour selection uses palette-nearest classification (matching this codebase's `zhsunyco`
8
+ * driver) rather than the reference driver's raw-luminance thresholds.
9
+ */
10
+ export declare function encodeBitmap(bitmap: Bitmap, metadata: DeviceMetadata, layout: GiciskyLayout): Buffer;
@@ -0,0 +1,130 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.encodeBitmap = encodeBitmap;
4
+ const compression_1 = require("./compression");
5
+ /**
6
+ * Nearest-colour classification into a device's actual palette - same decision tree and
7
+ * fallback-through-the-palette approach as `zhsunyco/encode.ts`'s `nearestColour`, duplicated
8
+ * rather than shared since each vendor package here is self-contained (see README's
9
+ * "ESL Vendor - Sub-package per vendor").
10
+ */
11
+ const FALLBACK = {
12
+ yellow: "red",
13
+ red: "black",
14
+ };
15
+ function classifyColour(r, g, b, supported) {
16
+ let colour = "black";
17
+ if (r > 150 && g > 150 && b > 150)
18
+ colour = "white";
19
+ else if (r > 150 && g > 100 && b < 80)
20
+ colour = "yellow";
21
+ else if (r > 150 && g < 80 && b < 80)
22
+ colour = "red";
23
+ while (!supported.includes(colour)) {
24
+ const fallback = FALLBACK[colour];
25
+ if (!fallback)
26
+ break;
27
+ colour = fallback;
28
+ }
29
+ return colour;
30
+ }
31
+ /**
32
+ * Rotates a bitmap 90 degrees counter-clockwise once (in the raster sense: x right, y down) -
33
+ * equivalent to Pillow's `Image.rotate(90, expand=True)` / `numpy.rot90`, which the reference
34
+ * driver relies on. `rotateBitmap` below composes this to cover 0/90/180/270.
35
+ */
36
+ function rotate90(bitmap) {
37
+ const { width, height, data } = bitmap;
38
+ const outWidth = height;
39
+ const outHeight = width;
40
+ const out = new Uint8Array(outWidth * outHeight * 4);
41
+ for (let oy = 0; oy < outHeight; oy++) {
42
+ for (let ox = 0; ox < outWidth; ox++) {
43
+ const ix = width - 1 - oy;
44
+ const iy = ox;
45
+ const srcOffset = (iy * width + ix) * 4;
46
+ const dstOffset = (oy * outWidth + ox) * 4;
47
+ out.set(data.subarray(srcOffset, srcOffset + 4), dstOffset);
48
+ }
49
+ }
50
+ return { width: outWidth, height: outHeight, data: out };
51
+ }
52
+ function rotateBitmap(bitmap, degrees) {
53
+ let result = bitmap;
54
+ for (let turns = degrees / 90; turns > 0; turns--) {
55
+ result = rotate90(result);
56
+ }
57
+ return result;
58
+ }
59
+ /**
60
+ * Packs one 1-bit plane, MSB-first, row-major (8 pixels/byte) - `predicate` decides whether a
61
+ * given pixel's classified colour sets the bit.
62
+ */
63
+ function packPlane(bitmap, layout, supported, predicate) {
64
+ const { width, height } = bitmap;
65
+ const bytesPerRow = Math.ceil(width / 8);
66
+ const plane = Buffer.alloc(bytesPerRow * height);
67
+ for (let y = 0; y < height; y++) {
68
+ for (let x = 0; x < width; x++) {
69
+ const sx = layout.mirrorX ? width - 1 - x : x;
70
+ const sy = layout.mirrorY ? height - 1 - y : y;
71
+ const offset = (sy * width + sx) * 4;
72
+ const colour = classifyColour(bitmap.data[offset], bitmap.data[offset + 1], bitmap.data[offset + 2], supported);
73
+ if (predicate(colour)) {
74
+ const byteIndex = y * bytesPerRow + (x >> 3);
75
+ plane[byteIndex] |= 0x80 >> (x % 8);
76
+ }
77
+ }
78
+ }
79
+ return plane;
80
+ }
81
+ /** 2-bit packing across the full palette, MSB-first, 4 pixels/byte, row-major: black=0, white=1, yellow=2, red=3. */
82
+ const FOUR_COLOUR_CODE = { black: 0, white: 1, yellow: 2, red: 3 };
83
+ function packFourColour(bitmap, layout, supported) {
84
+ const { width, height } = bitmap;
85
+ const pixelsPerByte = 4;
86
+ const bytesPerRow = Math.ceil(width / pixelsPerByte);
87
+ const plane = Buffer.alloc(bytesPerRow * height);
88
+ for (let y = 0; y < height; y++) {
89
+ for (let x = 0; x < width; x++) {
90
+ const sx = layout.mirrorX ? width - 1 - x : x;
91
+ const sy = layout.mirrorY ? height - 1 - y : y;
92
+ const offset = (sy * width + sx) * 4;
93
+ const colour = classifyColour(bitmap.data[offset], bitmap.data[offset + 1], bitmap.data[offset + 2], supported);
94
+ const code = FOUR_COLOUR_CODE[colour];
95
+ const byteIndex = y * bytesPerRow + Math.floor(x / pixelsPerByte);
96
+ const shift = 6 - (x % pixelsPerByte) * 2;
97
+ plane[byteIndex] |= code << shift;
98
+ }
99
+ }
100
+ return plane;
101
+ }
102
+ /**
103
+ * Quantises a common RGBA bitmap and packs it into the wire format this model's `GiciskyLayout`
104
+ * calls for. Mirrors `GiciskyClient._make_image_packet` (hass-gicisky's `writer.py`), except
105
+ * colour selection uses palette-nearest classification (matching this codebase's `zhsunyco`
106
+ * driver) rather than the reference driver's raw-luminance thresholds.
107
+ */
108
+ function encodeBitmap(bitmap, metadata, layout) {
109
+ if (layout.packing === "unsupported") {
110
+ throw new Error(`gicisky paint: device "${metadata.label}" isn't supported yet (needs compression/resize support this driver doesn't implement)`);
111
+ }
112
+ if (bitmap.width !== metadata.width || bitmap.height !== metadata.height) {
113
+ throw new Error(`gicisky paint: bitmap is ${bitmap.width}x${bitmap.height}, device "${metadata.label}" expects ${metadata.width}x${metadata.height}`);
114
+ }
115
+ const rotated = rotateBitmap(bitmap, layout.rotation);
116
+ const supported = metadata.colours;
117
+ if (layout.fourColour) {
118
+ return packFourColour(rotated, layout, supported);
119
+ }
120
+ const isWhite = (colour) => (layout.invertLuminance ? colour !== "white" : colour === "white");
121
+ const bwPlane = packPlane(rotated, layout, supported, isWhite);
122
+ if (!supported.includes("red")) {
123
+ return bwPlane;
124
+ }
125
+ const redPlane = packPlane(rotated, layout, supported, (colour) => colour === "red");
126
+ if (layout.packing === "chunked") {
127
+ return (0, compression_1.frameChunkedPlanes)(bwPlane, redPlane);
128
+ }
129
+ return Buffer.concat([bwPlane, redPlane]);
130
+ }
@@ -0,0 +1,11 @@
1
+ import { Device } from "@naugehyde/node-ble";
2
+ import { Bitmap } from "../../render/types";
3
+ import { DeviceMetadata, DiscoveredDevice, VendorDeviceConfig, VendorDriver } from "../types";
4
+ export declare class GiciskyDriver implements VendorDriver {
5
+ readonly vendor = "gicisky";
6
+ matchesAdvertisement(_name: string | undefined, manufacturerId: number | undefined): boolean;
7
+ metadataForPid(pid: number): DeviceMetadata | undefined;
8
+ supportedDevices(): DeviceMetadata[];
9
+ identifyDevice(device: Device, address: string, name: string | undefined, manufacturerId: number | undefined, manufacturerData: Buffer | undefined): Promise<DiscoveredDevice>;
10
+ paint(bitmap: Bitmap, config: VendorDeviceConfig): Promise<void>;
11
+ }
@@ -0,0 +1,164 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.GiciskyDriver = void 0;
4
+ const bleDiscovery_1 = require("../bleDiscovery");
5
+ const metadata_1 = require("./metadata");
6
+ const layout_1 = require("./layout");
7
+ const encode_1 = require("./encode");
8
+ const reframe_1 = require("../../render/reframe");
9
+ const protocol_1 = require("./protocol");
10
+ const DEVICE_DISCOVERY_TIMEOUT_MS = 30000;
11
+ const DEFAULT_PAINT_CONNECT_TIMEOUT_MS = 60000;
12
+ const ACK_TIMEOUT_MS = 15000;
13
+ /** Fallback if a device's `requestBlockSize` ack doesn't decode - matches the block size both reference drivers assume. */
14
+ const DEFAULT_BLOCK_SIZE = 244;
15
+ /** Bytes of image data per chunk: block size minus the 4-byte part-index header each chunk is prefixed with. */
16
+ const PART_INDEX_HEADER_LENGTH = 4;
17
+ const MAX_STALLED_REPEATS = 3;
18
+ class GiciskyDriver {
19
+ constructor() {
20
+ this.vendor = "gicisky";
21
+ }
22
+ matchesAdvertisement(_name, manufacturerId) {
23
+ return manufacturerId === protocol_1.GICISKY_MANUFACTURER_ID;
24
+ }
25
+ metadataForPid(pid) {
26
+ return metadata_1.GICISKY_PID_METADATA.find((model) => model.pid === pid);
27
+ }
28
+ supportedDevices() {
29
+ return metadata_1.GICISKY_PID_METADATA;
30
+ }
31
+ async identifyDevice(device, address, name, manufacturerId, manufacturerData) {
32
+ const info = manufacturerData ? (0, protocol_1.decodeAdvertisedInfo)(manufacturerData) : undefined;
33
+ return {
34
+ address,
35
+ name,
36
+ vendor: this.vendor,
37
+ pid: info?.deviceId,
38
+ metadata: info ? this.metadataForPid(info.deviceId) : undefined,
39
+ manufacturerId,
40
+ batteryMv: info?.batteryMv,
41
+ rssi: await device
42
+ .getRSSI()
43
+ .then((value) => (value === undefined ? undefined : Number(value)))
44
+ .catch(() => undefined),
45
+ };
46
+ }
47
+ async paint(bitmap, config) {
48
+ const { bluetooth, destroy } = (0, bleDiscovery_1.createBluetooth)();
49
+ try {
50
+ const adapter = await bluetooth.defaultAdapter();
51
+ const device = await (0, bleDiscovery_1.getOrDiscoverDevice)(adapter, config.address, DEVICE_DISCOVERY_TIMEOUT_MS);
52
+ /**
53
+ * Unlike zhsunyco, there's no GATT characteristic that reports the device's PID on demand -
54
+ * the only source for it is the advertisement, cached on the `Device` object by BlueZ from
55
+ * the last time it was seen (the same cache `identifyDevice`/a scan reads, just without
56
+ * connecting first - see `forEachAdvertisedDevice` in `bleDiscovery.ts`).
57
+ */
58
+ const manufacturerData = await device
59
+ .getManufacturerData()
60
+ .then((data) => data[protocol_1.GICISKY_MANUFACTURER_ID.toString()])
61
+ .catch(() => undefined);
62
+ const info = manufacturerData ? (0, protocol_1.decodeAdvertisedInfo)(manufacturerData) : undefined;
63
+ const metadata = config.modelOverride
64
+ ? { pid: info?.deviceId ?? 0, ...config.modelOverride }
65
+ : info
66
+ ? this.metadataForPid(info.deviceId)
67
+ : undefined;
68
+ if (!metadata) {
69
+ throw new Error(info === undefined
70
+ ? "gicisky device has no cached advertisement to identify it from - scan for it first, or pass --width/--height/--voffset/--colours to describe it manually"
71
+ : `gicisky device reports unrecognised deviceId 0x${info.deviceId.toString(16).padStart(4, "0")} - ` +
72
+ "pass --width/--height/--voffset/--colours to describe it manually");
73
+ }
74
+ const layout = (info && layout_1.GICISKY_PID_LAYOUT[info.deviceId]) || (0, layout_1.defaultLayoutFor)(metadata.colours);
75
+ const framed = (0, reframe_1.reframeBitmap)(bitmap, metadata.width, metadata.height, config.reframe ?? "fixed");
76
+ const payload = (0, encode_1.encodeBitmap)(framed, metadata, layout);
77
+ await (0, bleDiscovery_1.connectWithTimeout)(device, config.connectTimeoutMs ?? DEFAULT_PAINT_CONNECT_TIMEOUT_MS);
78
+ try {
79
+ const gatt = await device.gatt();
80
+ const { cmd, img } = await findCommandAndImageCharacteristics(gatt);
81
+ await cmd.startNotifications();
82
+ try {
83
+ const startAck = await writeAndAwaitAck(cmd, cmd, Buffer.from([0x01]));
84
+ const chunkSize = ((0, protocol_1.decodeBlockSize)(startAck) ?? DEFAULT_BLOCK_SIZE) - PART_INDEX_HEADER_LENGTH;
85
+ await writeAndAwaitAck(cmd, cmd, (0, protocol_1.writeScreenCommand)(payload.length, layout.packing === "chunked"));
86
+ const startImageAck = await writeAndAwaitAck(cmd, cmd, Buffer.from([0x03]));
87
+ const started = (0, protocol_1.decodeTransferAck)(startImageAck);
88
+ if (!started?.ok) {
89
+ throw new Error(`gicisky device rejected start-image-transfer request: ${startImageAck.toString("hex")}`);
90
+ }
91
+ let part = started.nextPart;
92
+ let lastPart = -1;
93
+ let repeats = 0;
94
+ while (part * chunkSize < payload.length) {
95
+ const chunk = payload.subarray(part * chunkSize, Math.min(part * chunkSize + chunkSize, payload.length));
96
+ const ack = await writeAndAwaitAck(img, cmd, (0, protocol_1.imageChunkPacket)(part, chunk));
97
+ const decoded = (0, protocol_1.decodeTransferAck)(ack);
98
+ if (!decoded?.ok) {
99
+ throw new Error(`gicisky device reported an error transferring image part ${part}: ${ack.toString("hex")}`);
100
+ }
101
+ if (decoded.nextPart === lastPart) {
102
+ repeats++;
103
+ if (repeats >= MAX_STALLED_REPEATS) {
104
+ throw new Error(`gicisky image transfer stalled - device kept re-requesting part ${decoded.nextPart}`);
105
+ }
106
+ }
107
+ else {
108
+ repeats = 0;
109
+ lastPart = decoded.nextPart;
110
+ }
111
+ part = decoded.nextPart;
112
+ }
113
+ }
114
+ finally {
115
+ await cmd.stopNotifications().catch(() => { });
116
+ }
117
+ }
118
+ finally {
119
+ await device.disconnect();
120
+ }
121
+ }
122
+ finally {
123
+ destroy();
124
+ }
125
+ }
126
+ }
127
+ exports.GiciskyDriver = GiciskyDriver;
128
+ /**
129
+ * Walks every service whose UUID starts `0000f`, collecting their characteristics and sorting by
130
+ * 16-bit UUID value - mirrors both reference drivers, which locate their command/image
131
+ * characteristics this way rather than by a hardcoded service UUID (see `protocol.ts`).
132
+ */
133
+ async function findCommandAndImageCharacteristics(gatt) {
134
+ const candidates = [];
135
+ for (const serviceUuid of await gatt.services()) {
136
+ if (!serviceUuid.toLowerCase().startsWith(protocol_1.CANDIDATE_SERVICE_UUID_PREFIX)) {
137
+ continue;
138
+ }
139
+ const service = await gatt.getPrimaryService(serviceUuid);
140
+ for (const charUuid of await service.characteristics()) {
141
+ candidates.push({ uuid: charUuid, char: await service.getCharacteristic(charUuid) });
142
+ }
143
+ }
144
+ candidates.sort((a, b) => parseInt(a.uuid.slice(4, 8), 16) - parseInt(b.uuid.slice(4, 8), 16));
145
+ if (candidates.length < 2) {
146
+ throw new Error(`gicisky device exposes ${candidates.length} candidate characteristic(s) under a "0000f..." service, expected at least 2`);
147
+ }
148
+ return { cmd: candidates[0].char, img: candidates[1].char };
149
+ }
150
+ /** Writes `data` to `writeChar`, then awaits the next notification on `notifyChar` (which may be the same characteristic). */
151
+ function writeAndAwaitAck(writeChar, notifyChar, data) {
152
+ const ack = new Promise((resolve, reject) => {
153
+ const timer = setTimeout(() => {
154
+ notifyChar.removeListener("valuechanged", onValue);
155
+ reject(new Error("gicisky device did not acknowledge in time"));
156
+ }, ACK_TIMEOUT_MS);
157
+ function onValue(value) {
158
+ clearTimeout(timer);
159
+ resolve(value);
160
+ }
161
+ notifyChar.once("valuechanged", onValue);
162
+ });
163
+ return writeChar.writeValueWithoutResponse(data).then(() => ack);
164
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Per-model wire-layout quirks, keyed the same way as `GICISKY_PID_METADATA` (by `deviceId`).
3
+ * These aren't part of the shared `DeviceMetadata` shape since no other vendor needs them -
4
+ * `DeviceMetadata.width`/`.height` is the pre-rotation "visual" canvas (what a template is
5
+ * designed at); this table says how `encode.ts` turns that into the physical byte layout each
6
+ * model actually expects. Transcribed from `DeviceEntry`/`GiciskyClient._make_image_packet` in
7
+ * hass-gicisky's `gicisky_ble/devices.py` and `gicisky_ble/writer.py`.
8
+ */
9
+ export type GiciskyPacking =
10
+ /** Plain concatenated bit-planes, no framing - covers most panels. */
11
+ "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
+ | "chunked"
14
+ /**
15
+ * A panel this driver can identify (for `scan`/discovery) but can't paint correctly yet - either
16
+ * its vendor-firmware "compressed" line framing doesn't reconcile with the row-major bit-packing
17
+ * `_make_image_packet` otherwise produces (the 3.7" panel, and the 7.5" panel on pre-`0x8101`
18
+ * firmware, both of which use this legacy `compression=True` mode rather than `compression2`),
19
+ * or it needs a resize/resample step this driver doesn't implement (the TFT panel).
20
+ */
21
+ | "unsupported";
22
+ export interface GiciskyLayout {
23
+ /** Degrees the composed image is rotated (always a multiple of 90) before bit-packing. */
24
+ rotation: 0 | 90 | 180 | 270;
25
+ mirrorX: boolean;
26
+ mirrorY: boolean;
27
+ /** Swaps which bit value means "white" in the BW plane - some panels' controllers wire the panel's polarity the other way round. */
28
+ invertLuminance: boolean;
29
+ /** 2 bits/pixel across the device's full 4-colour palette, rather than separate 1-bit BW/red planes. */
30
+ fourColour: boolean;
31
+ packing: GiciskyPacking;
32
+ }
33
+ export declare const GICISKY_PID_LAYOUT: Record<number, GiciskyLayout>;
34
+ /** Best-effort layout for a `modelOverride`d PID this table has no entry for - assumes the common case. */
35
+ export declare function defaultLayoutFor(colours: string[]): GiciskyLayout;
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.GICISKY_PID_LAYOUT = void 0;
4
+ exports.defaultLayoutFor = defaultLayoutFor;
5
+ const DEFAULT_LAYOUT = {
6
+ rotation: 0,
7
+ mirrorX: false,
8
+ mirrorY: false,
9
+ invertLuminance: false,
10
+ fourColour: false,
11
+ packing: "plain",
12
+ };
13
+ exports.GICISKY_PID_LAYOUT = {
14
+ 0x00a0: { ...DEFAULT_LAYOUT, packing: "unsupported" },
15
+ 0x000b: { ...DEFAULT_LAYOUT, rotation: 270, mirrorX: true },
16
+ 0x010b: { ...DEFAULT_LAYOUT, rotation: 270, mirrorX: true },
17
+ 0x0028: { ...DEFAULT_LAYOUT, rotation: 90 },
18
+ 0x0033: { ...DEFAULT_LAYOUT, rotation: 90 },
19
+ 0x002e: { ...DEFAULT_LAYOUT, rotation: 90, fourColour: true },
20
+ 0x022b: { ...DEFAULT_LAYOUT, rotation: 180, mirrorX: true, packing: "unsupported" },
21
+ 0x004b: { ...DEFAULT_LAYOUT },
22
+ 0x004e: { ...DEFAULT_LAYOUT, fourColour: true },
23
+ /**
24
+ * Only the newer (post-`0x8101`) firmware's `compression2` framing is implemented (see
25
+ * `packing: "chunked"` above) - a 7.5" panel still on `0x8101` needs the legacy `compression`
26
+ * framing this driver doesn't support yet, and `paint()` has no way to tell the two apart
27
+ * without re-reading the advertised firmware version at paint time.
28
+ */
29
+ 0x012b: { ...DEFAULT_LAYOUT, mirrorY: true, invertLuminance: true, packing: "chunked" },
30
+ 0x008b: { ...DEFAULT_LAYOUT, packing: "chunked" },
31
+ };
32
+ /** Best-effort layout for a `modelOverride`d PID this table has no entry for - assumes the common case. */
33
+ function defaultLayoutFor(colours) {
34
+ return { ...DEFAULT_LAYOUT, fourColour: colours.includes("yellow") };
35
+ }
@@ -0,0 +1,16 @@
1
+ import { DeviceMetadata } from "../types";
2
+ /**
3
+ * Keyed by the 14-bit `deviceId` decoded from the advertisement (see `decodeAdvertisedInfo` in
4
+ * `protocol.ts`) - Gicisky's own namespace, not shared with other vendors' PID spaces.
5
+ *
6
+ * Sourced from `DEVICE_TYPES` in hass-gicisky's `gicisky_ble/devices.py`, the more actively
7
+ * maintained of the two reference implementations in the sibling `lab` checkouts. That file also
8
+ * carries a much longer comment table of `deviceId`s seen in the wild that aren't yet mapped to
9
+ * physical panel facts (`width`/`height`/colours) - those aren't reproduced here since there'd be
10
+ * nothing to paint correctly for them yet.
11
+ *
12
+ * `width`/`height` are the pre-rotation "visual" canvas size - what a template should be designed
13
+ * at - matching `DeviceEntry.width`/`.height` there; `paintLayout` (`layout.ts`) then rotates/mirrors
14
+ * during encoding to produce the physical wire layout.
15
+ */
16
+ export declare const GICISKY_PID_METADATA: DeviceMetadata[];