@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/CHANGELOG.md +22 -0
- package/README.md +111 -9
- package/dist/cli/index.d.ts +15 -1
- package/dist/cli/index.js +103 -37
- package/dist/config.d.ts +27 -7
- package/dist/config.js +40 -10
- package/dist/devices/gicisky/compression.d.ts +16 -0
- package/dist/devices/gicisky/compression.js +34 -0
- package/dist/devices/gicisky/encode.d.ts +10 -0
- package/dist/devices/gicisky/encode.js +130 -0
- package/dist/devices/gicisky/index.d.ts +11 -0
- package/dist/devices/gicisky/index.js +164 -0
- package/dist/devices/gicisky/layout.d.ts +35 -0
- package/dist/devices/gicisky/layout.js +35 -0
- package/dist/devices/gicisky/metadata.d.ts +16 -0
- package/dist/devices/gicisky/metadata.js +127 -0
- package/dist/devices/gicisky/protocol.d.ts +53 -0
- package/dist/devices/gicisky/protocol.js +88 -0
- package/dist/devices/types.d.ts +11 -0
- package/dist/devices/zhsunyco/index.js +3 -1
- package/dist/devices/zhsunyco/metadata.js +9 -0
- package/dist/index.d.ts +36 -3
- package/dist/index.js +28 -3
- package/dist/plugin.js +2 -0
- package/dist/render/binding.d.ts +86 -3
- package/dist/render/binding.js +135 -3
- package/dist/render/formatters.js +18 -0
- package/dist/render/reframe.d.ts +12 -0
- package/dist/render/reframe.js +36 -0
- package/dist/render/templateProviders.d.ts +58 -0
- package/dist/render/templateProviders.js +16 -0
- package/dist/repaintScheduler.js +126 -51
- package/package.json +15 -12
- package/templates/.error/250x128-BWRY.svg +12 -0
- package/templates/.error/416x240-BWRY.svg +12 -0
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
|
|
121
|
-
*
|
|
122
|
-
*
|
|
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
|
|
126
|
-
const trimmed =
|
|
137
|
+
function resolveDir(dir, defaultDir) {
|
|
138
|
+
const trimmed = dir?.trim();
|
|
127
139
|
if (!trimmed) {
|
|
128
|
-
return
|
|
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
|
-
/**
|
|
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
|
-
|
|
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[];
|