@rhizomatics/signalk-einklabel-plugin 1.0.0 → 1.2.1
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 +15 -0
- package/README.md +55 -5
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +46 -11
- 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 +3 -0
- package/dist/devices/zhsunyco/index.js +3 -1
- package/dist/plugin.js +2 -0
- package/dist/render/binding.d.ts +9 -0
- package/dist/render/binding.js +21 -0
- package/dist/render/reframe.d.ts +12 -0
- package/dist/render/reframe.js +36 -0
- package/package.json +12 -10
- package/templates/tides/296x128-BWRY.svg +160 -0
- package/dist/render/llmPrompt.d.ts +0 -86
- package/dist/render/llmPrompt.js +0 -199
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
# 1.2.1
|
|
2
|
+
|
|
3
|
+
- Additional tide template for 296x128
|
|
4
|
+
|
|
5
|
+
# 1.2.0
|
|
6
|
+
|
|
7
|
+
- Added support for Gicisky labels (also known by other brands), tested with a 2.9" BWRY model
|
|
8
|
+
- CLI render command use template height and width as defaults, can still be overridden by arguments to preview particular size of label
|
|
9
|
+
- CLI paint commands use label height and width as defaults, overridden by arguments if needed
|
|
10
|
+
- CLI paint command now has a `--reframe` option for labels that don't match template size, so can quickly test a label without having perfect template right away
|
|
11
|
+
|
|
12
|
+
# 1.1.0
|
|
13
|
+
|
|
14
|
+
- Label images can now optionally be emailed to an address every time they change, including a table of fields and values used to complete the template, or the prompt if genai based
|
|
15
|
+
|
|
1
16
|
# 1.0.0
|
|
2
17
|
|
|
3
18
|
## Full Release
|
package/README.md
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
[](https://codecov.io/gh/rhizomatics/signalk-einklabel-plugin)
|
|
7
7
|
[](https://github.com)
|
|
8
8
|
[](https://github.com/rhizomatics/signalk-einklabel-plugin/blob/main/LICENSE)
|
|
9
|
+
[](https://boat-tech-directory.rhizomatics.org.uk)
|
|
9
10
|
|
|
10
11
|
A SignalK plugin to display data from SignalK paths, Resource APIs and plugins on Electronic Shelf Labels (ESL) over a Bluetooth Low Energy (BLE) connection using simple SVG templates, or optionally created from a crafted prompt by GenAI if the companion [`@rhizomatics/signalk-einklabel-genai-plugin`](https://github.com/rhizomatics/signalk-einklabel-genai-plugin) is installed.
|
|
11
12
|
|
|
@@ -17,7 +18,7 @@ Electronic Shelf Labels are [eInk](https://en.wikipedia.org/wiki/E_Ink) devices
|
|
|
17
18
|
|
|
18
19
|
Since they are designed to be used in large quantity in small shops, they are cheap and simple devices. Earlier models required dedicated controllers, or updates over Wifi or NFC, whereas many modern ones are standalone BLE devices that can be updated from a phone or server.
|
|
19
20
|
|
|
20
|
-
Being battery operated, they can be stuck on anywhere without wiring - the only location constraints are bluetooth range, visibility (they need ambient light since the display is more like paper than a traditional lit-up electronic display) and out of the weather
|
|
21
|
+
Being battery operated, they can be stuck on anywhere without wiring - the only location constraints are bluetooth range, visibility (they need ambient light since the display is more like paper than a traditional lit-up electronic display) and, for some labels, being out of the weather if they are not waterproof, although IP65 labels are available.
|
|
21
22
|
|
|
22
23
|
## Pre-requisites
|
|
23
24
|
|
|
@@ -34,8 +35,9 @@ Most of requirements below are to make SignalK work with Bluetooth Low Energy, w
|
|
|
34
35
|
|
|
35
36
|
- Bluetooth adapters for Linux can be tricky, TP-Link UB400 and Asus USB-BT500 are two well-known and available ones
|
|
36
37
|
- Some Raspberry Pi models come with suitable Bluetooth built in
|
|
37
|
-
|
|
38
|
-
-
|
|
38
|
+
|
|
39
|
+
> - Don't worry about the very latest Bluetooth versions, 4.0 is minimum for BLE, 5.0 is nice
|
|
40
|
+
> - Home Assistant is massively more popular than SignalK, and often also run on Raspberry Pi and similar, so good source of advice
|
|
39
41
|
|
|
40
42
|
3. `bluez` package installed in Linux
|
|
41
43
|
|
|
@@ -44,7 +46,7 @@ Most of requirements below are to make SignalK work with Bluetooth Low Energy, w
|
|
|
44
46
|
|
|
45
47
|
4. One or more supported Electronic Shelf Labels
|
|
46
48
|
|
|
47
|
-
- The
|
|
49
|
+
- The labels used for testing this are the [Zhsunyco 3.7" BWRY](https://www.aliexpress.com/item/1005010050104435.html) and a [Gicisky 2.9" BWRY](https://www.aliexpress.com/item/1005012933325056.html)
|
|
48
50
|
|
|
49
51
|
5. Correct time zone set on server if local time is to be shown on display
|
|
50
52
|
|
|
@@ -147,6 +149,14 @@ Also known as 'Suny' and 'WOLink'.
|
|
|
147
149
|
|
|
148
150
|
Python code for a variety of their labels at https://github.com/roxburghm/zhsunyco-esl and https://github.com/NickWaterton/Wolink
|
|
149
151
|
|
|
152
|
+
### Gicisky
|
|
153
|
+
|
|
154
|
+
Known by other names, e.g. 'Picksmart', and with white label brands
|
|
155
|
+
|
|
156
|
+
- BLE ESLs
|
|
157
|
+
- Official store is on [AliExpress](https://www.aliexpress.com/store/911771479/pages/all-items.html?productGroupId=40000001654819&spm=a2g0o.store_pc_home.pcShopHead_6000727597996.1_1)
|
|
158
|
+
- Cheapest labels under £10 GBP / $13 USD
|
|
159
|
+
|
|
150
160
|
## Templating
|
|
151
161
|
|
|
152
162
|
Templates are simply SVG files, to which expressions can be added to use SignalK data, with options to make it easier to read, like rounding or simplifying dates and times. The template can have sample data in the placeholder, so is easy to layout and visualize.
|
|
@@ -270,7 +280,15 @@ See also the commands useful for debugging under [Developing Templates]
|
|
|
270
280
|
- `render` - transform an SVG template and data into a PNG
|
|
271
281
|
- `paint` - render an SVG template and data to a selected ESL
|
|
272
282
|
|
|
273
|
-
The width, height, vertical offset and colour palette for the device are taken from the internal register of devices, however can be overridden on the command line
|
|
283
|
+
The width, height, vertical offset and colour palette for the device are taken from the internal register of devices, however can be overridden on the command line with `-w/--width`, `--height`, `--voffset` and `--colours`. This could be used to help you choose what size of label to buy, or to get an unsupported label working.
|
|
284
|
+
|
|
285
|
+
Left unset, both `render` and `paint` default `-w/--width`/`--height` to the template's own declared `width`/`height` (or `viewBox`) - neither command connects to a device just to size the render, since that would mean an extra BLE connect ahead of `paint`'s own, and doing two back-to-back is exactly the kind of churn that trips real BLE hardware.
|
|
286
|
+
|
|
287
|
+
`paint` also takes `--reframe <mode>`, applied once it has connected and identified the device, for when the rendered image doesn't come out the same size as its actual panel:
|
|
288
|
+
|
|
289
|
+
- `fixed` (default) - no adjustment; a size mismatch is rejected with an error, as it always has been
|
|
290
|
+
- `scale` - stretches the rendered image onto the panel's exact dimensions (independently per axis, not preserving aspect ratio)
|
|
291
|
+
- `crop` - keeps pixels 1:1, placed from the top-left; a bigger render is truncated to fit, a smaller one leaves the extra panel space blank
|
|
274
292
|
|
|
275
293
|
`esl-cli` can also be extended with new subcommands by a `-r/--require`'d package - see [Extending](#extending) below - which is how [`@rhizomatics/signalk-einklabel-genai-plugin`](#genai-rendering) adds its own `prompt`/`generate` commands for testing prompts without a device.
|
|
276
294
|
|
|
@@ -328,8 +346,40 @@ The `esl-cli` can be used to debug and validate templates quickly:
|
|
|
328
346
|
- `fields` - List the fields in the template, with the source specification and the rendered data value
|
|
329
347
|
- `field` - Accept a source specification (outside of any template context) and return the rendered value if available
|
|
330
348
|
|
|
349
|
+
Use `--help` to get the full set of arguments for any of the commands.
|
|
350
|
+
|
|
331
351
|
### Examples
|
|
332
352
|
|
|
353
|
+
#### Paint Image Directly
|
|
354
|
+
|
|
355
|
+
The label address previously discovered via `esl-cli scan`
|
|
356
|
+
|
|
357
|
+
```bash
|
|
358
|
+
npx esl-cli paint -t templates/tides/250x128-BWRY.svg -a FF:FF:92:84:53:93
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
If the label turns out to be a different size than the template (e.g. it's a 250x128 template on a 416x240 panel), that's normally rejected as a mismatch - add `--reframe` to fit it instead:
|
|
362
|
+
|
|
363
|
+
```bash
|
|
364
|
+
npx esl-cli paint -t templates/tides/250x128-BWRY.svg -a FF:FF:92:84:53:93 --reframe scale
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
#### Test Template Without Updating Label
|
|
368
|
+
|
|
369
|
+
This will work even if you don't have a label, or even bluetooth. (The `-u` can be left out if your SignalK server running locally on default ports).
|
|
370
|
+
|
|
371
|
+
```bash
|
|
372
|
+
npx esl-cli render -t templates/tides/250x128-BWRY.svg -o example.png -u http://localhost
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
and this version will work even without a running SignalK server, using some pre-packaged example data:
|
|
376
|
+
|
|
377
|
+
```bash
|
|
378
|
+
npx esl-cli render -t templates/tides/250x128-BWRY.svg -o example.png -e examples
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
|
|
333
383
|
#### List all Fields and Rendered Values
|
|
334
384
|
|
|
335
385
|
```bash
|
package/dist/cli/index.d.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from "commander";
|
|
3
3
|
import { Colour } from "../devices/types";
|
|
4
|
+
import { ReframeMode } from "../render/reframe";
|
|
4
5
|
import { Binding } from "../render/binding";
|
|
5
6
|
import { TemplateContext } from "../render/types";
|
|
6
7
|
/** Tried in order when -u/--url is omitted (and -e/--example-data isn't given) - first one that answers wins. */
|
|
7
8
|
export declare const DEFAULT_SIGNALK_URLS: string[];
|
|
8
9
|
export declare function parseColours(code: string): Colour[];
|
|
10
|
+
export declare function parseReframeMode(value: string): ReframeMode;
|
|
9
11
|
/** Shared by every command that takes -u/--url and -e/--example-data - -e wins when both are present; when neither is given, probes DEFAULT_SIGNALK_URLS for a default. */
|
|
10
12
|
export declare function assembleContext(opts: {
|
|
11
13
|
url?: string;
|
package/dist/cli/index.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
4
|
exports.program = exports.DEFAULT_SIGNALK_URLS = void 0;
|
|
5
5
|
exports.parseColours = parseColours;
|
|
6
|
+
exports.parseReframeMode = parseReframeMode;
|
|
6
7
|
exports.assembleContext = assembleContext;
|
|
7
8
|
const promises_1 = require("fs/promises");
|
|
8
9
|
const path_1 = require("path");
|
|
@@ -10,6 +11,7 @@ const commander_1 = require("commander");
|
|
|
10
11
|
const xmldom_1 = require("@xmldom/xmldom");
|
|
11
12
|
const registry_1 = require("../devices/registry");
|
|
12
13
|
const zhsunyco_1 = require("../devices/zhsunyco");
|
|
14
|
+
const gicisky_1 = require("../devices/gicisky");
|
|
13
15
|
const bleDiscovery_1 = require("../devices/bleDiscovery");
|
|
14
16
|
const svgRenderer_1 = require("../render/svgRenderer");
|
|
15
17
|
const png_1 = require("../render/png");
|
|
@@ -20,6 +22,7 @@ const liveContext_1 = require("./liveContext");
|
|
|
20
22
|
const httpJson_1 = require("../httpJson");
|
|
21
23
|
const log_1 = require("./log");
|
|
22
24
|
(0, registry_1.registerDriver)(new zhsunyco_1.ZhsunycoDriver());
|
|
25
|
+
(0, registry_1.registerDriver)(new gicisky_1.GiciskyDriver());
|
|
23
26
|
const VENDOR_IDENTIFY_TIMEOUT_MS = 30000;
|
|
24
27
|
/** Tried in order when -u/--url is omitted (and -e/--example-data isn't given) - first one that answers wins. */
|
|
25
28
|
exports.DEFAULT_SIGNALK_URLS = ["http://localhost", "http://localhost:3000", "https://localhost"];
|
|
@@ -35,6 +38,13 @@ function parseColours(code) {
|
|
|
35
38
|
}
|
|
36
39
|
return colours;
|
|
37
40
|
}
|
|
41
|
+
const REFRAME_MODES = ["fixed", "scale", "crop"];
|
|
42
|
+
function parseReframeMode(value) {
|
|
43
|
+
if (!REFRAME_MODES.includes(value)) {
|
|
44
|
+
throw new Error(`unknown --reframe value "${value}" - expected one of ${REFRAME_MODES.join(", ")}`);
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
38
48
|
/** Probes DEFAULT_SIGNALK_URLS in order and returns the first that answers a plain GET - used when -u/--url is omitted. */
|
|
39
49
|
async function resolveDefaultUrl() {
|
|
40
50
|
for (const candidate of exports.DEFAULT_SIGNALK_URLS) {
|
|
@@ -49,6 +59,14 @@ async function resolveDefaultUrl() {
|
|
|
49
59
|
}
|
|
50
60
|
throw new Error(`no -u/--url given and none of ${exports.DEFAULT_SIGNALK_URLS.join(", ")} answered - specify the server explicitly with -u/--url`);
|
|
51
61
|
}
|
|
62
|
+
const DEFAULT_RENDER_WIDTH = 416;
|
|
63
|
+
const DEFAULT_RENDER_HEIGHT = 240;
|
|
64
|
+
/** Shared by every command's -w/--width and --height: an explicit flag always wins; otherwise the first defined value in `sources` (in order); otherwise a generic last-resort default. */
|
|
65
|
+
function resolveDimension(explicit, sources, fallback) {
|
|
66
|
+
if (explicit !== undefined)
|
|
67
|
+
return Number(explicit);
|
|
68
|
+
return sources.find((value) => value !== undefined) ?? fallback;
|
|
69
|
+
}
|
|
52
70
|
/** Shared by every command that takes -u/--url and -e/--example-data - -e wins when both are present; when neither is given, probes DEFAULT_SIGNALK_URLS for a default. */
|
|
53
71
|
async function assembleContext(opts, bindings) {
|
|
54
72
|
if (opts.exampleData)
|
|
@@ -148,7 +166,7 @@ exports.program
|
|
|
148
166
|
exports.program
|
|
149
167
|
.command("scan")
|
|
150
168
|
.description("Scan for supported BLE ESL devices across all registered vendor drivers")
|
|
151
|
-
.option("-d, --duration <seconds>", "scan duration in seconds", "
|
|
169
|
+
.option("-d, --duration <seconds>", "scan duration in seconds", "30")
|
|
152
170
|
.option("-a, --all-devices", "list every nearby BLE device, not just ones a registered driver recognised - unmatched devices show address/name/mfr/rssi only, since there's no driver to do a vendor-specific read like battery")
|
|
153
171
|
.action(async (opts) => {
|
|
154
172
|
const durationMs = Number(opts.duration) * 1000;
|
|
@@ -203,10 +221,11 @@ exports.program
|
|
|
203
221
|
" in turn")
|
|
204
222
|
.option("-e, --example-data <dir>", "load vessels/resources from local example JSON files in <dir> (e.g. ./examples) instead of a live SignalK server - alternative to -u")
|
|
205
223
|
.option("-k, --aes-key <hex>", "AES-128 key for device authentication, as 32 hex characters - defaults to the vendor's stock key if omitted")
|
|
206
|
-
.option("-w, --width <px>", "render width
|
|
207
|
-
.option("--height <px>", "render height
|
|
224
|
+
.option("-w, --width <px>", "render width - defaults to the template's declared width/viewBox - see --reframe for fitting onto a differently-sized panel")
|
|
225
|
+
.option("--height <px>", "render height - defaults to the template's declared height/viewBox - see --reframe for fitting onto a differently-sized panel")
|
|
208
226
|
.option("--voffset <px>", "vertical pixel offset of the panel - overrides the looked-up model for unsupported hardware (requires --colours)", "0")
|
|
209
227
|
.option("--colours <code>", "device colour palette for unsupported hardware: BW, BWR, or BWRY - overrides the looked-up model (uses --width/--height/--voffset)")
|
|
228
|
+
.option("--reframe <mode>", "how to fit the rendered image onto the device's actual panel size when it doesn't match: fixed (default - reject the mismatch, as always), scale (stretch the template to the panel), crop (place at top-left, truncating or leaving the rest blank)", "fixed")
|
|
210
229
|
.option("--connect-timeout <seconds>", "BLE connect timeout before giving up on an attempt", "30")
|
|
211
230
|
.option("--retries <n>", "number of paint attempts (including the first) before giving up", "3")
|
|
212
231
|
.action(async (opts) => {
|
|
@@ -215,19 +234,30 @@ exports.program
|
|
|
215
234
|
if (!driver) {
|
|
216
235
|
throw new Error(`no driver registered for vendor "${vendor}"`);
|
|
217
236
|
}
|
|
237
|
+
const svgSource = await (0, promises_1.readFile)(opts.template, "utf-8");
|
|
238
|
+
// Defaults from the template's own declared size, same as `render` - not from the device's real
|
|
239
|
+
// panel size, which would need its own identify connect ahead of driver.paint()'s own connect;
|
|
240
|
+
// two back-to-back BLE connects on the same device is exactly the kind of churn that trips BlueZ
|
|
241
|
+
// (confirmed: identifyDevice() does a full connect+disconnect for zhsunyco, to read its PID off a
|
|
242
|
+
// GATT characteristic - immediately followed by paint()'s own connect crashed with a raw D-Bus
|
|
243
|
+
// "recipient disconnected from message bus without replying" on real hardware). --reframe below
|
|
244
|
+
// covers a mismatch against the real panel size within paint()'s own single connect instead.
|
|
245
|
+
const declared = (0, binding_1.readTemplateDimensions)(svgSource);
|
|
246
|
+
const width = resolveDimension(opts.width, [declared.width], DEFAULT_RENDER_WIDTH);
|
|
247
|
+
const height = resolveDimension(opts.height, [declared.height], DEFAULT_RENDER_HEIGHT);
|
|
218
248
|
const modelOverride = opts.colours
|
|
219
249
|
? {
|
|
220
250
|
label: "manual override",
|
|
221
|
-
width
|
|
222
|
-
height
|
|
251
|
+
width,
|
|
252
|
+
height,
|
|
223
253
|
voffset: Number(opts.voffset),
|
|
224
254
|
colours: parseColours(opts.colours),
|
|
225
255
|
}
|
|
226
256
|
: undefined;
|
|
227
|
-
const bindings = (0, binding_1.findBindings)(
|
|
257
|
+
const bindings = (0, binding_1.findBindings)(svgSource);
|
|
228
258
|
const context = await assembleContext(opts, bindings);
|
|
229
259
|
const renderer = new svgRenderer_1.SvgRenderer();
|
|
230
|
-
const bitmap = await renderer.render(opts.template, context,
|
|
260
|
+
const bitmap = await renderer.render(opts.template, context, width, height, (0, path_1.dirname)(opts.template), config_1.BUNDLED_TEMPLATES_DIR);
|
|
231
261
|
const connectTimeoutMs = Number(opts.connectTimeout) * 1000;
|
|
232
262
|
await (0, bleDiscovery_1.withRetries)(Number(opts.retries), async (attempt) => {
|
|
233
263
|
if (attempt > 1) {
|
|
@@ -238,6 +268,7 @@ exports.program
|
|
|
238
268
|
aesKey: opts.aesKey,
|
|
239
269
|
modelOverride,
|
|
240
270
|
connectTimeoutMs,
|
|
271
|
+
reframe: parseReframeMode(opts.reframe),
|
|
241
272
|
});
|
|
242
273
|
});
|
|
243
274
|
console.log(`painted ${opts.address} (${bitmap.width}x${bitmap.height}) ${opts.colours}`);
|
|
@@ -251,16 +282,20 @@ exports.program
|
|
|
251
282
|
exports.DEFAULT_SIGNALK_URLS.join(", ") +
|
|
252
283
|
" in turn")
|
|
253
284
|
.option("-e, --example-data <dir>", "load vessels/resources from local example JSON files in <dir> (e.g. ./examples) instead of a live SignalK server - alternative to -u")
|
|
254
|
-
.option("-w, --width <px>", "render width
|
|
255
|
-
.option("--height <px>", "render height
|
|
285
|
+
.option("-w, --width <px>", "render width - defaults to the template's declared width/viewBox")
|
|
286
|
+
.option("--height <px>", "render height - defaults to the template's declared height/viewBox")
|
|
256
287
|
.option("-f, --font <path>", "override a bundled font with this file (repeatable) - defaults to the bundled monospace/sans-serif/serif trio",
|
|
257
288
|
// See the -r/--require option above for why `| undefined` is needed here despite commander's typings.
|
|
258
289
|
(value, previous = []) => [...previous, value])
|
|
259
290
|
.action(async (opts) => {
|
|
260
|
-
const
|
|
291
|
+
const svgSource = await (0, promises_1.readFile)(opts.template, "utf-8");
|
|
292
|
+
const declared = (0, binding_1.readTemplateDimensions)(svgSource);
|
|
293
|
+
const width = resolveDimension(opts.width, [declared.width], DEFAULT_RENDER_WIDTH);
|
|
294
|
+
const height = resolveDimension(opts.height, [declared.height], DEFAULT_RENDER_HEIGHT);
|
|
295
|
+
const bindings = (0, binding_1.findBindings)(svgSource);
|
|
261
296
|
const context = await assembleContext(opts, bindings);
|
|
262
297
|
const renderer = opts.font ? new svgRenderer_1.SvgRenderer(opts.font) : new svgRenderer_1.SvgRenderer();
|
|
263
|
-
const bitmap = await renderer.render(opts.template, context,
|
|
298
|
+
const bitmap = await renderer.render(opts.template, context, width, height, (0, path_1.dirname)(opts.template), config_1.BUNDLED_TEMPLATES_DIR);
|
|
264
299
|
await (0, promises_1.writeFile)(opts.output, (0, png_1.bitmapToPng)(bitmap));
|
|
265
300
|
console.log(`wrote ${opts.output} (${bitmap.width}x${bitmap.height})`);
|
|
266
301
|
});
|
|
@@ -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
|
+
}
|