@rhizomatics/signalk-einklabel-plugin 1.3.0-beta8 → 1.3.0-beta9
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 +19 -1
- package/README.md +34 -1
- package/dist/cli/index.d.ts +4 -1
- package/dist/cli/index.js +25 -1
- package/dist/config.d.ts +12 -0
- package/dist/config.js +25 -0
- package/dist/devices/gicisky/compression.d.ts +22 -9
- package/dist/devices/gicisky/compression.js +128 -13
- package/dist/devices/gicisky/encode.d.ts +1 -1
- package/dist/devices/gicisky/encode.js +2 -2
- package/dist/devices/gicisky/index.js +4 -3
- package/dist/devices/gicisky/layout.d.ts +13 -1
- package/dist/devices/gicisky/layout.js +21 -0
- package/dist/devices/types.d.ts +15 -0
- package/dist/devices/types.js +2 -0
- package/dist/devices/zhsunyco/compression.d.ts +1 -0
- package/dist/devices/zhsunyco/compression.js +30 -0
- package/dist/devices/zhsunyco/index.js +10 -4
- package/dist/devices/zhsunyco/protocol.d.ts +2 -0
- package/dist/devices/zhsunyco/protocol.js +2 -0
- package/dist/email/emailSender.d.ts +27 -0
- package/dist/email/emailSender.js +66 -0
- package/dist/render/fieldsTable.d.ts +15 -0
- package/dist/render/fieldsTable.js +52 -0
- package/dist/render/llmPrompt.d.ts +86 -0
- package/dist/render/llmPrompt.js +199 -0
- package/dist/render/mirror.d.ts +11 -0
- package/dist/render/mirror.js +24 -0
- package/dist/repaintScheduler.js +3 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
# 1.3.0
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
## BLE Manager
|
|
4
|
+
|
|
5
|
+
First implementation of using new SignalK BLE Manager rather than directly using the `bluez` services.
|
|
6
|
+
|
|
7
|
+
- Off by default until longer term stability demonstrated.
|
|
8
|
+
- It may be less reliable for some devices that can be reached with direct bluez access - try it and fall back to the direct mode if so.
|
|
9
|
+
- BLE Manager use reduces interference between plugins competing for same BLE devices
|
|
10
|
+
- Recommend using SignalK at least release v2.33.0.
|
|
11
|
+
|
|
12
|
+
## Compression
|
|
13
|
+
|
|
14
|
+
Images are now compressed before sending - no change to what's displayed, but send time is massively reduced for labels with lots of empty space, so less battery is used and sends are less likely to fail.
|
|
15
|
+
|
|
16
|
+
- On by default for Zhsunyco labels and Gicisky 7.5"/10.2" labels; can be turned off per label, or with `--no-compress` on the CLI
|
|
17
|
+
- Experimental opt-in _Wire format_ `chunked` sends a Gicisky 4.2" BWR compressed too (`--compression-format chunked` on the CLI)
|
|
18
|
+
|
|
19
|
+
## Mirroring
|
|
20
|
+
|
|
21
|
+
Some labels may need images flipped, so a per-label _Mirror_ option can flip the image horizontally, vertically, or both (rotate 180°, for a label mounted upside down). Also available as `--mirror` on the CLI `paint` and `render` commands.
|
|
4
22
|
|
|
5
23
|
# 1.2.3
|
|
6
24
|
|
package/README.md
CHANGED
|
@@ -178,6 +178,14 @@ Templates are simply SVG files, to which expressions can be added to use SignalK
|
|
|
178
178
|
|
|
179
179
|
There's some wiggle room with the `reframe` options to use a template that's a bit too small, or too large, for the label, although best results come from a template that's precisely matching the pixel height and width of the label. Next best is template that has the same aspect ratio, so it can be cleanly scaled. `crop` is the 2nd least worst, though if its only a handful of pixels its often not worth worring about a separate template and `crop` is just fine. `scale` is likely to look worst, since it will force an image in regardless of aspect ratio.
|
|
180
180
|
|
|
181
|
+
### Other Image Options
|
|
182
|
+
|
|
183
|
+
Each label has a few more settings for how the image is sent. The CLI `paint` command has matching options (see [Command Line Interface](#command-line-interface)), so you can try them on a label before changing the plugin config.
|
|
184
|
+
|
|
185
|
+
- _Compress upload_ - on by default. Sends much less data over Bluetooth, so painting is quicker, uses less of the label's battery and is less likely to time out. Works for Zhsunyco labels and Gicisky 7.5"/10.2" labels, and is ignored for others. Turn it off if a label stops updating.
|
|
186
|
+
- _Wire format (Gicisky, experimental)_ - `auto` by default. `chunked` sends a Gicisky 4.2" BWR label compressed, the same way as the 7.5"/10.2". The vendor's own app has been seen doing this, but it hasn't been tested on current firmware. Set it back to `auto` if the label stops updating.
|
|
187
|
+
- _Mirror_ - `none` by default. `horizontal` or `vertical` fixes a label model whose image comes out mirrored. `both` rotates the image 180°, for a label that has to be mounted upside down.
|
|
188
|
+
|
|
181
189
|
### Template Families (multiple panel sizes/colours)
|
|
182
190
|
|
|
183
191
|
A "Template" selection can either be one specific `.svg` file, or a _directory_ holding several versions of the same template for different panel sizes/colour-sets, e.g. `templates/tides/416x240-BWRY.svg` and `templates/tides/250x128-BWRY.svg` both implement the tide clock, just at different sizes.
|
|
@@ -309,6 +317,12 @@ Left unset, both `render` and `paint` default `-w/--width`/`--height` to the tem
|
|
|
309
317
|
|
|
310
318
|
The main SignalK plugin offers the same choice per device (defaulting to `crop` there too) in each device's own config - "If the render doesn't match the panel size".
|
|
311
319
|
|
|
320
|
+
`paint` has matching options for the other per-label image settings too (see [Other Image Options](#other-image-options)):
|
|
321
|
+
|
|
322
|
+
- `--mirror <mode>` - `none` (default), `horizontal`, `vertical`, or `both` (rotate 180°). `render` also takes `--mirror`, to preview the flip as a PNG without a label
|
|
323
|
+
- `--no-compress` - send the image uncompressed, to rule compression out if a label won't update
|
|
324
|
+
- `--compression-format <format>` - `auto` (default) or `chunked`, to try the experimental compressed format on a Gicisky 4.2" BWR
|
|
325
|
+
|
|
312
326
|
`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.
|
|
313
327
|
|
|
314
328
|
( The CLI can also be run from a checked out module, or by opening a terminal shell at `~/.signalk/node_modules/@rhizomatics/signalk-einklabel-plugin`, as `npx esl-cli command --args` )
|
|
@@ -377,12 +391,24 @@ The label address previously discovered via `esl-cli scan`
|
|
|
377
391
|
npx esl-cli paint -t templates/tides/250x128-BWRY.svg -a FF:FF:92:84:53:93
|
|
378
392
|
```
|
|
379
393
|
|
|
380
|
-
If the label turns out to be a different size than the template (e.g. it's a 250x128 template on a 416x240 panel),
|
|
394
|
+
If the label turns out to be a different size than the template (e.g. it's a 250x128 template on a 416x240 panel), it's cropped to fit by default - add `--reframe scale` to stretch it instead:
|
|
381
395
|
|
|
382
396
|
```bash
|
|
383
397
|
npx esl-cli paint -t templates/tides/250x128-BWRY.svg -a FF:FF:92:84:53:93 --reframe scale
|
|
384
398
|
```
|
|
385
399
|
|
|
400
|
+
If a label doesn't update, try sending it uncompressed to see whether compression is the cause:
|
|
401
|
+
|
|
402
|
+
```bash
|
|
403
|
+
npx esl-cli paint -t templates/tides/250x128-BWRY.svg -a FF:FF:92:84:53:93 --no-compress
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
If the image comes out mirrored, try each `--mirror` mode until it looks right, then set the same _Mirror_ option in the label's config:
|
|
407
|
+
|
|
408
|
+
```bash
|
|
409
|
+
npx esl-cli paint -t templates/tides/250x128-BWRY.svg -a FF:FF:92:84:53:93 --mirror horizontal
|
|
410
|
+
```
|
|
411
|
+
|
|
386
412
|
#### Test Template Without Updating Label
|
|
387
413
|
|
|
388
414
|
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).
|
|
@@ -537,6 +563,8 @@ USB_DENYLIST="0b05:190e"
|
|
|
537
563
|
|
|
538
564
|
## Other ESL and General eInk Resources
|
|
539
565
|
|
|
566
|
+
### Components
|
|
567
|
+
|
|
540
568
|
- [Open ePaper Link](https://openepaperlink.de) - Alternative open source firmware to flash onto eInk shelf labels, with Home Assistant integration.
|
|
541
569
|
- [zhsunyco-esl](https://github.com/roxburghm/zhsunyco-esl) - Python interface
|
|
542
570
|
- [WoLink](https://github.com/NickWaterton/Wolink) - Python interface and protocol analysis
|
|
@@ -545,9 +573,14 @@ USB_DENYLIST="0b05:190e"
|
|
|
545
573
|
- [esp32-esl-system](https://github.com/giobauermeister/esp32-esl-system) - Docker and ESP32 based system for updating ESLs.
|
|
546
574
|
- [hass-gicisky](https://github.com/eigger/hass-gicisky) - Home Assistant integration for Gicisky ESLs ( a similar vendor to Zhsunyco). Uses [imagespec](https://github.com/eigger/imagespec) for templating.
|
|
547
575
|
- [ha-panda](https://github.com/moryoav/ha-panda) - Home Assistant integration for Panda ESLs ( a similar vendor to Zhsunyco).
|
|
576
|
+
|
|
577
|
+
### Notes and Experiences
|
|
578
|
+
|
|
579
|
+
- [Cabalist Gicisky Image Notes](https://github.com/Cabalist/gicisky_image_notes)
|
|
548
580
|
- [Dmitry.gr](https://dmitry.gr/?r=05.Projects&proj=29.%20eInk%20Price%20Tags) - Personal site of an ESL hacker
|
|
549
581
|
- [Aaron Christobel](https://www.youtube.com/@atc1441) - YouTube channel of an ESL hacker.
|
|
550
582
|
- [rbaron.net](https://rbaron.net/blog/2022/07/29/Daisy-chaining-multiple-electronic-shelf-labels) - Blog of an early ESL hacker.
|
|
583
|
+
### Retail
|
|
551
584
|
- [Pimoroni](https://shop.pimoroni.com/collections/displays?tags=e-ink%20Displays) - All shapes and sizes of eInk displays, aimed at hackers, and with an [inky](https://github.com/pimoroni/inky) GitHub project to support them.
|
|
552
585
|
- [WaveShare](https://www.waveshare.com/product/displays/e-paper.htm) - Wide range of eInk displays for hardware projects, not limited to ESLs.
|
|
553
586
|
|
package/dist/cli/index.d.ts
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from "commander";
|
|
3
|
-
import { Colour } from "../devices/types";
|
|
3
|
+
import { Colour, CompressionFormat } from "../devices/types";
|
|
4
4
|
import { ReframeMode } from "../render/reframe";
|
|
5
|
+
import { MirrorMode } from "../render/mirror";
|
|
5
6
|
import { Binding } from "../render/binding";
|
|
6
7
|
import { TemplateContext } from "../render/types";
|
|
7
8
|
/** Tried in order when -u/--url is omitted (and -e/--example-data isn't given) - first one that answers wins. */
|
|
8
9
|
export declare const DEFAULT_SIGNALK_URLS: string[];
|
|
9
10
|
export declare function parseColours(code: string): Colour[];
|
|
10
11
|
export declare function parseReframeMode(value: string): ReframeMode;
|
|
12
|
+
export declare function parseMirrorMode(value: string): MirrorMode;
|
|
13
|
+
export declare function parseCompressionFormat(value: string): CompressionFormat;
|
|
11
14
|
/** 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. */
|
|
12
15
|
export declare function assembleContext(opts: {
|
|
13
16
|
url?: string;
|
package/dist/cli/index.js
CHANGED
|
@@ -4,6 +4,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
4
4
|
exports.program = exports.DEFAULT_SIGNALK_URLS = void 0;
|
|
5
5
|
exports.parseColours = parseColours;
|
|
6
6
|
exports.parseReframeMode = parseReframeMode;
|
|
7
|
+
exports.parseMirrorMode = parseMirrorMode;
|
|
8
|
+
exports.parseCompressionFormat = parseCompressionFormat;
|
|
7
9
|
exports.assembleContext = assembleContext;
|
|
8
10
|
const promises_1 = require("fs/promises");
|
|
9
11
|
const path_1 = require("path");
|
|
@@ -13,6 +15,8 @@ const registry_1 = require("../devices/registry");
|
|
|
13
15
|
const zhsunyco_1 = require("../devices/zhsunyco");
|
|
14
16
|
const gicisky_1 = require("../devices/gicisky");
|
|
15
17
|
const bleDiscovery_1 = require("../devices/bleDiscovery");
|
|
18
|
+
const types_1 = require("../devices/types");
|
|
19
|
+
const mirror_1 = require("../render/mirror");
|
|
16
20
|
const svgRenderer_1 = require("../render/svgRenderer");
|
|
17
21
|
const png_1 = require("../render/png");
|
|
18
22
|
const binding_1 = require("../render/binding");
|
|
@@ -45,6 +49,18 @@ function parseReframeMode(value) {
|
|
|
45
49
|
}
|
|
46
50
|
return value;
|
|
47
51
|
}
|
|
52
|
+
function parseMirrorMode(value) {
|
|
53
|
+
if (!mirror_1.MIRROR_MODES.includes(value)) {
|
|
54
|
+
throw new Error(`unknown --mirror value "${value}" - expected one of ${mirror_1.MIRROR_MODES.join(", ")}`);
|
|
55
|
+
}
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
function parseCompressionFormat(value) {
|
|
59
|
+
if (!types_1.COMPRESSION_FORMATS.includes(value)) {
|
|
60
|
+
throw new Error(`unknown --compression-format value "${value}" - expected one of ${types_1.COMPRESSION_FORMATS.join(", ")}`);
|
|
61
|
+
}
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
48
64
|
/** Probes DEFAULT_SIGNALK_URLS in order and returns the first that answers a plain GET - used when -u/--url is omitted. */
|
|
49
65
|
async function resolveDefaultUrl() {
|
|
50
66
|
for (const candidate of exports.DEFAULT_SIGNALK_URLS) {
|
|
@@ -226,6 +242,9 @@ exports.program
|
|
|
226
242
|
.option("--voffset <px>", "vertical pixel offset of the panel - overrides the looked-up model for unsupported hardware (requires --colours)", "0")
|
|
227
243
|
.option("--colours <code>", "device colour palette for unsupported hardware: BW, BWR, or BWRY - overrides the looked-up model (uses --width/--height/--voffset)")
|
|
228
244
|
.option("--reframe <mode>", "how to fit the rendered image onto the device's actual panel size when it doesn't match: crop (default - place at top-left, truncating or leaving the rest blank), scale (stretch the template to the panel), fixed (reject the mismatch instead)", "crop")
|
|
245
|
+
.option("--mirror <mode>", "flip the image before sending: none (default), horizontal, vertical, or both (rotate 180°)", "none")
|
|
246
|
+
.option("--no-compress", 'send the image uncompressed (zhsunyco; gicisky 7.5"/10.2" or --compression-format chunked - compression is on by default)')
|
|
247
|
+
.option("--compression-format <format>", 'gicisky wire format: auto (default - the model\'s usual format) or chunked (experimental - send a 4.2" BWR compressed like the 7.5"/10.2")', "auto")
|
|
229
248
|
.option("--connect-timeout <seconds>", "BLE connect timeout before giving up on an attempt", "30")
|
|
230
249
|
.option("--retries <n>", "number of paint attempts (including the first) before giving up", "3")
|
|
231
250
|
.action(async (opts) => {
|
|
@@ -269,6 +288,9 @@ exports.program
|
|
|
269
288
|
modelOverride,
|
|
270
289
|
connectTimeoutMs,
|
|
271
290
|
reframe: parseReframeMode(opts.reframe),
|
|
291
|
+
mirror: parseMirrorMode(opts.mirror),
|
|
292
|
+
compress: opts.compress,
|
|
293
|
+
compressionFormat: parseCompressionFormat(opts.compressionFormat),
|
|
272
294
|
});
|
|
273
295
|
});
|
|
274
296
|
console.log(`painted ${opts.address} (${bitmap.width}x${bitmap.height}) ${opts.colours}`);
|
|
@@ -287,7 +309,9 @@ exports.program
|
|
|
287
309
|
.option("-f, --font <path>", "override a bundled font with this file (repeatable) - defaults to the bundled monospace/sans-serif/serif trio",
|
|
288
310
|
// See the -r/--require option above for why `| undefined` is needed here despite commander's typings.
|
|
289
311
|
(value, previous = []) => [...previous, value])
|
|
312
|
+
.option("--mirror <mode>", "flip the PNG the same way paint --mirror would: none (default), horizontal, vertical, or both", "none")
|
|
290
313
|
.action(async (opts) => {
|
|
314
|
+
const mirror = parseMirrorMode(opts.mirror);
|
|
291
315
|
const svgSource = await (0, promises_1.readFile)(opts.template, "utf-8");
|
|
292
316
|
const declared = (0, binding_1.readTemplateDimensions)(svgSource);
|
|
293
317
|
const width = resolveDimension(opts.width, [declared.width], DEFAULT_RENDER_WIDTH);
|
|
@@ -295,7 +319,7 @@ exports.program
|
|
|
295
319
|
const bindings = (0, binding_1.findBindings)(svgSource);
|
|
296
320
|
const context = await assembleContext(opts, bindings);
|
|
297
321
|
const renderer = opts.font ? new svgRenderer_1.SvgRenderer(opts.font) : new svgRenderer_1.SvgRenderer();
|
|
298
|
-
const bitmap = await renderer.render(opts.template, context, width, height, (0, path_1.dirname)(opts.template), config_1.BUNDLED_TEMPLATES_DIR);
|
|
322
|
+
const bitmap = (0, mirror_1.mirrorBitmap)(await renderer.render(opts.template, context, width, height, (0, path_1.dirname)(opts.template), config_1.BUNDLED_TEMPLATES_DIR), mirror);
|
|
299
323
|
await (0, promises_1.writeFile)(opts.output, (0, png_1.bitmapToPng)(bitmap));
|
|
300
324
|
console.log(`wrote ${opts.output} (${bitmap.width}x${bitmap.height})`);
|
|
301
325
|
});
|
package/dist/config.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { ServerAPI } from "@signalk/server-api";
|
|
2
2
|
import { Colour, DiscoveredDevice } from "./devices/types";
|
|
3
3
|
import { ReframeMode } from "./render/reframe";
|
|
4
|
+
import { MirrorMode } from "./render/mirror";
|
|
5
|
+
import { CompressionFormat } from "./devices/types";
|
|
4
6
|
/**
|
|
5
7
|
* Special `device` value meaning "every currently-known discovered device" instead of one specific
|
|
6
8
|
* BLE address - lets a single `DeviceConfig` entry (one template, one trigger) broadcast to every
|
|
@@ -58,6 +60,16 @@ export interface DeviceConfig {
|
|
|
58
60
|
* showing *something*, even off-size, beats a repaint that just fails outright.
|
|
59
61
|
*/
|
|
60
62
|
reframe?: ReframeMode;
|
|
63
|
+
/** Flip the image before sending - for a panel whose layout is mirrored, or one mounted upside down (`"both"`). Unset means `"none"`. */
|
|
64
|
+
mirror?: MirrorMode;
|
|
65
|
+
/** Compress the upload (zhsunyco, and gicisky's chunked 7.5"/10.2" panels - ignored otherwise). Unset means on; turn off if a device fails to show compressed images. */
|
|
66
|
+
compress?: boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Experimental opt-in wire format (gicisky only) - `"chunked"` sends a 4.2" BWR (or another plain
|
|
69
|
+
* two-plane panel) QuickLZ-compressed like the 7.5"/10.2". Unset means `"auto"`, the model's own
|
|
70
|
+
* format. See `CompressionFormat`.
|
|
71
|
+
*/
|
|
72
|
+
compressionFormat?: CompressionFormat;
|
|
61
73
|
}
|
|
62
74
|
export interface PluginConfig {
|
|
63
75
|
/**
|
package/dist/config.js
CHANGED
|
@@ -12,6 +12,8 @@ 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 mirror_1 = require("./render/mirror");
|
|
16
|
+
const types_1 = require("./devices/types");
|
|
15
17
|
const templateProviders_1 = require("./render/templateProviders");
|
|
16
18
|
const resolveApiUrl_1 = require("./resolveApiUrl");
|
|
17
19
|
/**
|
|
@@ -433,6 +435,27 @@ function configSchema(app, discovered = []) {
|
|
|
433
435
|
enum: ["crop", "scale", "fixed"],
|
|
434
436
|
default: "crop",
|
|
435
437
|
},
|
|
438
|
+
mirror: {
|
|
439
|
+
type: "string",
|
|
440
|
+
title: "Mirror",
|
|
441
|
+
description: "Flip the image if it shows up mirrored on the label. Both = rotate 180°, e.g. for a label mounted upside down.",
|
|
442
|
+
enum: mirror_1.MIRROR_MODES,
|
|
443
|
+
default: "none",
|
|
444
|
+
},
|
|
445
|
+
compress: {
|
|
446
|
+
type: "boolean",
|
|
447
|
+
title: 'Compress upload (Zhsunyco, Gicisky 7.5"/10.2")',
|
|
448
|
+
description: "Sends far less data over BLE, so repaints are quicker. Turn off if a label stops updating.",
|
|
449
|
+
default: true,
|
|
450
|
+
},
|
|
451
|
+
compressionFormat: {
|
|
452
|
+
type: "string",
|
|
453
|
+
title: "Wire format (Gicisky, experimental)",
|
|
454
|
+
description: 'Auto: the model\'s usual format. Chunked: send compressed like the 7.5"/10.2" panels - may speed up a 4.2" BWR, ' +
|
|
455
|
+
"but untested on current firmware. Needs Compress upload on to actually compress. Switch back to Auto if the label stops updating.",
|
|
456
|
+
enum: types_1.COMPRESSION_FORMATS,
|
|
457
|
+
default: "auto",
|
|
458
|
+
},
|
|
436
459
|
},
|
|
437
460
|
},
|
|
438
461
|
},
|
|
@@ -446,6 +469,8 @@ function configUiSchema() {
|
|
|
446
469
|
description: { "ui:widget": "textarea" },
|
|
447
470
|
repaintTrigger: { "ui:widget": "radio" },
|
|
448
471
|
reframe: { "ui:widget": "radio" },
|
|
472
|
+
mirror: { "ui:widget": "radio" },
|
|
473
|
+
compressionFormat: { "ui:widget": "radio" },
|
|
449
474
|
},
|
|
450
475
|
},
|
|
451
476
|
};
|
|
@@ -1,16 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Wire framing for `packing: "chunked"` devices (the 7.5"/10.2" panels).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
4
|
+
* Each 64-byte chunk of a plane is sent either as a `0x75`-tagged QuickLZ-compressed block or a
|
|
5
|
+
* `0x74`-tagged raw one, whichever the compressor decides - a port of hass-gicisky's
|
|
6
|
+
* `gicisky_ble/compression.py`. That's QuickLZ Level 1, but with the vendor firmware's 6-bit
|
|
7
|
+
* (64-bucket) hash rather than stock QuickLZ's 12-bit one: match tokens carry a hash-table slot, not
|
|
8
|
+
* an offset, and the panel's decoder only ever fills 64 slots - a token naming a slot above that
|
|
9
|
+
* reads one that was never written and silently decodes garbage. So this must stay byte-compatible
|
|
10
|
+
* with the vendor's hash, not just any valid QuickLZ.
|
|
11
|
+
*
|
|
12
|
+
* One deliberate difference from hass-gicisky: `UNCONDITIONAL_MATCHLEN` is stock QuickLZ's 6, not
|
|
13
|
+
* its 12, so matches may start closer to a chunk's end. That's what the vendor's own app does - with
|
|
14
|
+
* 6 this reproduces 483 of the 486 distinct `0x75` chunks in Cabalist's BLE captures of the app
|
|
15
|
+
* (https://github.com/Cabalist/gicisky_image_notes) byte for byte, against 408 with 12. The other 3
|
|
16
|
+
* are chunks the app sends "compressed" despite them growing; this sends those raw (`0x74`) instead.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* QuickLZ L1 compression of one chunk, mirroring `_qlz_compress_core` step for step (including its
|
|
20
|
+
* quirks, e.g. the run-of-identical-bytes special case) so the output matches the vendor app's byte for
|
|
21
|
+
* byte. Returns `undefined` when compressing wouldn't save anything.
|
|
11
22
|
*/
|
|
23
|
+
export declare function qlzCompressChunk(source: Buffer): Buffer | undefined;
|
|
12
24
|
/**
|
|
13
25
|
* Frames two equal-length bit-planes (e.g. BW and red) as `[4-byte LE length of planeB]` followed
|
|
14
|
-
* by each plane's
|
|
26
|
+
* by each plane's chunked bytes, matching `compress()`'s output shape in the reference driver.
|
|
27
|
+
* `compress: false` sends every chunk raw (`0x74`), like hass-gicisky's `force_raw`.
|
|
15
28
|
*/
|
|
16
|
-
export declare function frameChunkedPlanes(planeA: Buffer, planeB: Buffer): Buffer;
|
|
29
|
+
export declare function frameChunkedPlanes(planeA: Buffer, planeB: Buffer, compress?: boolean): Buffer;
|
|
@@ -2,33 +2,148 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Wire framing for `packing: "chunked"` devices (the 7.5"/10.2" panels).
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
5
|
+
* Each 64-byte chunk of a plane is sent either as a `0x75`-tagged QuickLZ-compressed block or a
|
|
6
|
+
* `0x74`-tagged raw one, whichever the compressor decides - a port of hass-gicisky's
|
|
7
|
+
* `gicisky_ble/compression.py`. That's QuickLZ Level 1, but with the vendor firmware's 6-bit
|
|
8
|
+
* (64-bucket) hash rather than stock QuickLZ's 12-bit one: match tokens carry a hash-table slot, not
|
|
9
|
+
* an offset, and the panel's decoder only ever fills 64 slots - a token naming a slot above that
|
|
10
|
+
* reads one that was never written and silently decodes garbage. So this must stay byte-compatible
|
|
11
|
+
* with the vendor's hash, not just any valid QuickLZ.
|
|
12
|
+
*
|
|
13
|
+
* One deliberate difference from hass-gicisky: `UNCONDITIONAL_MATCHLEN` is stock QuickLZ's 6, not
|
|
14
|
+
* its 12, so matches may start closer to a chunk's end. That's what the vendor's own app does - with
|
|
15
|
+
* 6 this reproduces 483 of the 486 distinct `0x75` chunks in Cabalist's BLE captures of the app
|
|
16
|
+
* (https://github.com/Cabalist/gicisky_image_notes) byte for byte, against 408 with 12. The other 3
|
|
17
|
+
* are chunks the app sends "compressed" despite them growing; this sends those raw (`0x74`) instead.
|
|
12
18
|
*/
|
|
13
19
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
exports.qlzCompressChunk = qlzCompressChunk;
|
|
14
21
|
exports.frameChunkedPlanes = frameChunkedPlanes;
|
|
15
22
|
const CHUNK_SIZE = 64;
|
|
16
23
|
const RAW_CHUNK_TAG = 0x74;
|
|
17
|
-
|
|
24
|
+
const COMPRESSED_CHUNK_TAG = 0x75;
|
|
25
|
+
const CWORD_LEN = 4;
|
|
26
|
+
const HASH_VALUES = 64;
|
|
27
|
+
const NO_ENTRY = -1;
|
|
28
|
+
const MIN_OFFSET = 2;
|
|
29
|
+
const UNCONDITIONAL_MATCHLEN = 6;
|
|
30
|
+
const UNCOMPRESSED_END = 4;
|
|
31
|
+
/** Control-word sentinel: the top bit marks where the 31 flag bits below it run out. */
|
|
32
|
+
const CWORD_SENTINEL = 0x80000000;
|
|
33
|
+
function hashOf(fetch) {
|
|
34
|
+
return ((fetch >>> 12) ^ fetch) & (HASH_VALUES - 1);
|
|
35
|
+
}
|
|
36
|
+
function read3(data, pos) {
|
|
37
|
+
return pos + 3 > data.length ? 0 : data[pos] | (data[pos + 1] << 8) | (data[pos + 2] << 16);
|
|
38
|
+
}
|
|
39
|
+
/** Whether the `n + 1` bytes from `pos` are all equal. */
|
|
40
|
+
function allSame(data, pos, n) {
|
|
41
|
+
if (pos < 0 || pos + n >= data.length)
|
|
42
|
+
return false;
|
|
43
|
+
for (let i = 1; i <= n; i++) {
|
|
44
|
+
if (data[pos + i] !== data[pos])
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* QuickLZ L1 compression of one chunk, mirroring `_qlz_compress_core` step for step (including its
|
|
51
|
+
* quirks, e.g. the run-of-identical-bytes special case) so the output matches the vendor app's byte for
|
|
52
|
+
* byte. Returns `undefined` when compressing wouldn't save anything.
|
|
53
|
+
*/
|
|
54
|
+
function qlzCompressChunk(source) {
|
|
55
|
+
const size = source.length;
|
|
56
|
+
const lastByte = size - 1;
|
|
57
|
+
const lastMatchStart = lastByte - UNCONDITIONAL_MATCHLEN - UNCOMPRESSED_END;
|
|
58
|
+
if (lastMatchStart < 0)
|
|
59
|
+
return undefined;
|
|
60
|
+
const out = Buffer.alloc(size * 2 + 400);
|
|
61
|
+
let cwordPtr = 0;
|
|
62
|
+
let dst = CWORD_LEN;
|
|
63
|
+
let cword = CWORD_SENTINEL;
|
|
64
|
+
let src = 0;
|
|
65
|
+
let lits = 0;
|
|
66
|
+
const hashOffset = new Int32Array(HASH_VALUES).fill(NO_ENTRY);
|
|
67
|
+
const hashCache = new Int32Array(HASH_VALUES);
|
|
68
|
+
const flushCword = () => {
|
|
69
|
+
out.writeUInt32LE(((cword >>> 1) | CWORD_SENTINEL) >>> 0, cwordPtr);
|
|
70
|
+
cwordPtr = dst;
|
|
71
|
+
dst += CWORD_LEN;
|
|
72
|
+
cword = CWORD_SENTINEL;
|
|
73
|
+
};
|
|
74
|
+
while (src <= lastMatchStart) {
|
|
75
|
+
if ((cword & 1) === 1) {
|
|
76
|
+
if (src > size >> 1 && dst > src - (src >> 5))
|
|
77
|
+
return undefined;
|
|
78
|
+
flushCword();
|
|
79
|
+
}
|
|
80
|
+
const fetch = read3(source, src);
|
|
81
|
+
const h = hashOf(fetch);
|
|
82
|
+
const cached = fetch ^ hashCache[h];
|
|
83
|
+
hashCache[h] = fetch;
|
|
84
|
+
const o = hashOffset[h];
|
|
85
|
+
hashOffset[h] = src;
|
|
86
|
+
if ((cached & 0xffffff) === 0 &&
|
|
87
|
+
o !== NO_ENTRY &&
|
|
88
|
+
(src - o > MIN_OFFSET || (src === o + 1 && lits >= 3 && src > 3 && allSame(source, src - 3, 6)))) {
|
|
89
|
+
let matchLen = 3;
|
|
90
|
+
const remaining = Math.min(255, lastByte - UNCOMPRESSED_END - src + 1);
|
|
91
|
+
while (matchLen < remaining && source[src + matchLen] === source[o + matchLen])
|
|
92
|
+
matchLen++;
|
|
93
|
+
const hShifted = h << 4;
|
|
94
|
+
cword = ((cword >>> 1) | CWORD_SENTINEL) >>> 0;
|
|
95
|
+
if (matchLen < 18) {
|
|
96
|
+
out.writeUInt16LE((matchLen - 2) | hShifted, dst);
|
|
97
|
+
dst += 2;
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
out.writeUInt16LE(hShifted, dst);
|
|
101
|
+
out[dst + 2] = matchLen;
|
|
102
|
+
dst += 3;
|
|
103
|
+
}
|
|
104
|
+
src += matchLen;
|
|
105
|
+
lits = 0;
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
lits++;
|
|
109
|
+
out[dst++] = source[src++];
|
|
110
|
+
cword >>>= 1;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
while (src <= lastByte) {
|
|
114
|
+
if ((cword & 1) === 1)
|
|
115
|
+
flushCword();
|
|
116
|
+
if (src <= lastByte - 2) {
|
|
117
|
+
const f = read3(source, src);
|
|
118
|
+
const hh = hashOf(f);
|
|
119
|
+
hashCache[hh] = f;
|
|
120
|
+
hashOffset[hh] = src;
|
|
121
|
+
}
|
|
122
|
+
out[dst++] = source[src++];
|
|
123
|
+
cword >>>= 1;
|
|
124
|
+
}
|
|
125
|
+
while ((cword & 1) !== 1)
|
|
126
|
+
cword >>>= 1;
|
|
127
|
+
out.writeUInt32LE(((cword >>> 1) | CWORD_SENTINEL) >>> 0, cwordPtr);
|
|
128
|
+
return dst >= size ? undefined : out.subarray(0, dst);
|
|
129
|
+
}
|
|
130
|
+
function chunkPlane(data, compress) {
|
|
18
131
|
const chunks = [];
|
|
19
132
|
for (let offset = 0; offset < data.length; offset += CHUNK_SIZE) {
|
|
20
133
|
const chunk = data.subarray(offset, Math.min(offset + CHUNK_SIZE, data.length));
|
|
21
|
-
const
|
|
22
|
-
|
|
134
|
+
const compressed = compress ? qlzCompressChunk(chunk) : undefined;
|
|
135
|
+
const body = compressed ?? chunk;
|
|
136
|
+
chunks.push(Buffer.from([compressed ? COMPRESSED_CHUNK_TAG : RAW_CHUNK_TAG, 3 + body.length, chunk.length]), body);
|
|
23
137
|
}
|
|
24
138
|
return Buffer.concat(chunks);
|
|
25
139
|
}
|
|
26
140
|
/**
|
|
27
141
|
* Frames two equal-length bit-planes (e.g. BW and red) as `[4-byte LE length of planeB]` followed
|
|
28
|
-
* by each plane's
|
|
142
|
+
* by each plane's chunked bytes, matching `compress()`'s output shape in the reference driver.
|
|
143
|
+
* `compress: false` sends every chunk raw (`0x74`), like hass-gicisky's `force_raw`.
|
|
29
144
|
*/
|
|
30
|
-
function frameChunkedPlanes(planeA, planeB) {
|
|
145
|
+
function frameChunkedPlanes(planeA, planeB, compress = true) {
|
|
31
146
|
const header = Buffer.alloc(4);
|
|
32
147
|
header.writeUInt32LE(planeB.length, 0);
|
|
33
|
-
return Buffer.concat([header,
|
|
148
|
+
return Buffer.concat([header, chunkPlane(planeA, compress), chunkPlane(planeB, compress)]);
|
|
34
149
|
}
|
|
@@ -7,4 +7,4 @@ import { GiciskyLayout } from "./layout";
|
|
|
7
7
|
* colour selection uses palette-nearest classification (matching this codebase's `zhsunyco`
|
|
8
8
|
* driver) rather than the reference driver's raw-luminance thresholds.
|
|
9
9
|
*/
|
|
10
|
-
export declare function encodeBitmap(bitmap: Bitmap, metadata: DeviceMetadata, layout: GiciskyLayout): Buffer;
|
|
10
|
+
export declare function encodeBitmap(bitmap: Bitmap, metadata: DeviceMetadata, layout: GiciskyLayout, compress?: boolean): Buffer;
|
|
@@ -115,7 +115,7 @@ function packFourColour(bitmap, layout, supported) {
|
|
|
115
115
|
* colour selection uses palette-nearest classification (matching this codebase's `zhsunyco`
|
|
116
116
|
* driver) rather than the reference driver's raw-luminance thresholds.
|
|
117
117
|
*/
|
|
118
|
-
function encodeBitmap(bitmap, metadata, layout) {
|
|
118
|
+
function encodeBitmap(bitmap, metadata, layout, compress = true) {
|
|
119
119
|
if (layout.packing === "unsupported") {
|
|
120
120
|
throw new Error(`gicisky paint: device "${metadata.label}" isn't supported yet (needs compression/resize support this driver doesn't implement)`);
|
|
121
121
|
}
|
|
@@ -134,7 +134,7 @@ function encodeBitmap(bitmap, metadata, layout) {
|
|
|
134
134
|
}
|
|
135
135
|
const redPlane = packPlane(rotated, layout, supported, (colour) => colour === "red");
|
|
136
136
|
if (layout.packing === "chunked") {
|
|
137
|
-
return (0, compression_1.frameChunkedPlanes)(bwPlane, redPlane);
|
|
137
|
+
return (0, compression_1.frameChunkedPlanes)(bwPlane, redPlane, compress);
|
|
138
138
|
}
|
|
139
139
|
return Buffer.concat([bwPlane, redPlane]);
|
|
140
140
|
}
|
|
@@ -6,6 +6,7 @@ const metadata_1 = require("./metadata");
|
|
|
6
6
|
const layout_1 = require("./layout");
|
|
7
7
|
const encode_1 = require("./encode");
|
|
8
8
|
const reframe_1 = require("../../render/reframe");
|
|
9
|
+
const mirror_1 = require("../../render/mirror");
|
|
9
10
|
const protocol_1 = require("./protocol");
|
|
10
11
|
/** How long to actively rescan for a fresh advertisement when the cached one is missing/stale - see `BleBackend.waitForManufacturerData`. */
|
|
11
12
|
const MANUFACTURER_DATA_RESCAN_TIMEOUT_MS = 15000;
|
|
@@ -69,9 +70,9 @@ class GiciskyDriver {
|
|
|
69
70
|
: `gicisky device reports unrecognised deviceId 0x${pid.toString(16).padStart(4, "0")} - ` +
|
|
70
71
|
"pass --width/--height/--voffset/--colours to describe it manually");
|
|
71
72
|
}
|
|
72
|
-
const layout = (pid !== undefined && layout_1.GICISKY_PID_LAYOUT[pid]) || (0, layout_1.defaultLayoutFor)(metadata.colours);
|
|
73
|
-
const framed = (0, reframe_1.reframeBitmap)(bitmap, metadata.width, metadata.height, config.reframe ?? "crop");
|
|
74
|
-
const payload = (0, encode_1.encodeBitmap)(framed, metadata, layout);
|
|
73
|
+
const layout = (0, layout_1.withCompressionFormat)((pid !== undefined && layout_1.GICISKY_PID_LAYOUT[pid]) || (0, layout_1.defaultLayoutFor)(metadata.colours), config.compressionFormat ?? "auto", metadata.colours);
|
|
74
|
+
const framed = (0, mirror_1.mirrorBitmap)((0, reframe_1.reframeBitmap)(bitmap, metadata.width, metadata.height, config.reframe ?? "crop"), config.mirror ?? "none");
|
|
75
|
+
const payload = (0, encode_1.encodeBitmap)(framed, metadata, layout, config.compress ?? true);
|
|
75
76
|
const conn = await backend.connectGatt(config.address, config.connectTimeoutMs ?? DEFAULT_PAINT_CONNECT_TIMEOUT_MS);
|
|
76
77
|
try {
|
|
77
78
|
const { cmdServiceUuid, cmdUuid, imgServiceUuid, imgUuid } = await findCommandAndImageCharacteristics(conn);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CompressionFormat } from "../types";
|
|
1
2
|
/**
|
|
2
3
|
* Per-model wire-layout quirks, keyed the same way as `GICISKY_PID_METADATA` (by `deviceId`).
|
|
3
4
|
* These aren't part of the shared `DeviceMetadata` shape since no other vendor needs them -
|
|
@@ -9,7 +10,7 @@
|
|
|
9
10
|
export type GiciskyPacking =
|
|
10
11
|
/** Plain concatenated bit-planes, no framing - covers most panels. */
|
|
11
12
|
"plain"
|
|
12
|
-
/** Two bit-planes each split into
|
|
13
|
+
/** Two bit-planes each split into 64-byte chunks (QuickLZ-compressed or raw) framed per `compression.ts` - the 7.5"/10.2" panels. */
|
|
13
14
|
| "chunked"
|
|
14
15
|
/**
|
|
15
16
|
* A panel this driver can identify (for `scan`/discovery) but can't paint correctly yet - either
|
|
@@ -33,3 +34,14 @@ export interface GiciskyLayout {
|
|
|
33
34
|
export declare const GICISKY_PID_LAYOUT: Record<number, GiciskyLayout>;
|
|
34
35
|
/** Best-effort layout for a `modelOverride`d PID this table has no entry for - assumes the common case. */
|
|
35
36
|
export declare function defaultLayoutFor(colours: string[]): GiciskyLayout;
|
|
37
|
+
/**
|
|
38
|
+
* Applies a user's opt-in `compressionFormat: "chunked"` - switching a `"plain"` two-plane (BW + red)
|
|
39
|
+
* layout to the QuickLZ-chunked framing, with the flagged `writeScreen` command that goes with it.
|
|
40
|
+
* Opt-in rather than a model default because only one data point says a plain panel accepts it:
|
|
41
|
+
* Cabalist's 2023 BLE captures (https://github.com/Cabalist/gicisky_image_notes) of the vendor app
|
|
42
|
+
* sending a 4.2" BWR exactly this way, on firmware that may not match what's shipping now.
|
|
43
|
+
*
|
|
44
|
+
* Throws for layouts the framing can't carry, rather than quietly sending plain anyway - the user
|
|
45
|
+
* asked for chunked, so a repaint error says why they're not getting it.
|
|
46
|
+
*/
|
|
47
|
+
export declare function withCompressionFormat(layout: GiciskyLayout, format: CompressionFormat, colours: string[]): GiciskyLayout;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.GICISKY_PID_LAYOUT = void 0;
|
|
4
4
|
exports.defaultLayoutFor = defaultLayoutFor;
|
|
5
|
+
exports.withCompressionFormat = withCompressionFormat;
|
|
5
6
|
const DEFAULT_LAYOUT = {
|
|
6
7
|
rotation: 0,
|
|
7
8
|
mirrorX: false,
|
|
@@ -33,3 +34,23 @@ exports.GICISKY_PID_LAYOUT = {
|
|
|
33
34
|
function defaultLayoutFor(colours) {
|
|
34
35
|
return { ...DEFAULT_LAYOUT, fourColour: colours.includes("yellow") };
|
|
35
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* Applies a user's opt-in `compressionFormat: "chunked"` - switching a `"plain"` two-plane (BW + red)
|
|
39
|
+
* layout to the QuickLZ-chunked framing, with the flagged `writeScreen` command that goes with it.
|
|
40
|
+
* Opt-in rather than a model default because only one data point says a plain panel accepts it:
|
|
41
|
+
* Cabalist's 2023 BLE captures (https://github.com/Cabalist/gicisky_image_notes) of the vendor app
|
|
42
|
+
* sending a 4.2" BWR exactly this way, on firmware that may not match what's shipping now.
|
|
43
|
+
*
|
|
44
|
+
* Throws for layouts the framing can't carry, rather than quietly sending plain anyway - the user
|
|
45
|
+
* asked for chunked, so a repaint error says why they're not getting it.
|
|
46
|
+
*/
|
|
47
|
+
function withCompressionFormat(layout, format, colours) {
|
|
48
|
+
if (format === "auto" || layout.packing === "chunked") {
|
|
49
|
+
return layout;
|
|
50
|
+
}
|
|
51
|
+
if (layout.packing !== "plain" || layout.fourColour || !colours.includes("red")) {
|
|
52
|
+
throw new Error('gicisky paint: compressionFormat "chunked" needs a panel sent as separate black/white and red planes - ' +
|
|
53
|
+
'this model isn\'t (four-colour, black/white only, or unsupported) - set it back to "auto"');
|
|
54
|
+
}
|
|
55
|
+
return { ...layout, packing: "chunked" };
|
|
56
|
+
}
|
package/dist/devices/types.d.ts
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import { Bitmap } from "../render/types";
|
|
2
2
|
import { ReframeMode } from "../render/reframe";
|
|
3
|
+
import { MirrorMode } from "../render/mirror";
|
|
3
4
|
import { BleBackend } from "./bleBackend";
|
|
4
5
|
import { GattConnection } from "./gattConnection";
|
|
5
6
|
export type Colour = "black" | "white" | "red" | "yellow";
|
|
7
|
+
/**
|
|
8
|
+
* Which wire format to send in - `"auto"` uses whatever the driver's model table says;
|
|
9
|
+
* `"chunked"` opts a gicisky panel with separate BW/red planes (e.g. the 4.2" BWR, normally sent
|
|
10
|
+
* plain) into the QuickLZ-compressed chunk framing the 7.5"/10.2" use - see `withCompressionFormat`
|
|
11
|
+
* in `gicisky/layout.ts`. Ignored by other vendors.
|
|
12
|
+
*/
|
|
13
|
+
export type CompressionFormat = "auto" | "chunked";
|
|
14
|
+
export declare const COMPRESSION_FORMATS: CompressionFormat[];
|
|
6
15
|
/**
|
|
7
16
|
* Static facts about one device model, keyed by (vendor, pid) by the registry —
|
|
8
17
|
* PID alone is not assumed unique across vendors.
|
|
@@ -68,6 +77,12 @@ export interface VendorDeviceConfig {
|
|
|
68
77
|
connectTimeoutMs?: number;
|
|
69
78
|
/** How to fit the bitmap onto the panel when its size doesn't already match - see `ReframeMode`. Defaults to `"crop"` - a live label showing *something*, even off-size, beats a repaint that just fails outright; pass `"fixed"` explicitly to get the old reject-the-mismatch behaviour back. */
|
|
70
79
|
reframe?: ReframeMode;
|
|
80
|
+
/** Flips the image after reframing, before encoding - see `MirrorMode`. Defaults to `"none"`. */
|
|
81
|
+
mirror?: MirrorMode;
|
|
82
|
+
/** Compress the upload where the vendor protocol supports it (zhsunyco; gicisky's chunked 7.5"/10.2" panels); ignored otherwise. Defaults to `true`. */
|
|
83
|
+
compress?: boolean;
|
|
84
|
+
/** Overrides the model's own wire format - see `CompressionFormat`. Defaults to `"auto"`. */
|
|
85
|
+
compressionFormat?: CompressionFormat;
|
|
71
86
|
/**
|
|
72
87
|
* How `paint()` reaches the device's BLE hardware - omitted (always true for the CLI, which has no
|
|
73
88
|
* `ServerAPI`/`app.bleApi` to source one from) means direct BlueZ access via a fresh
|
package/dist/devices/types.js
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function compressWolinkBlocks(data: Buffer): Buffer;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.compressWolinkBlocks = compressWolinkBlocks;
|
|
4
|
+
const zlib_1 = require("zlib");
|
|
5
|
+
/**
|
|
6
|
+
* Wolink block-deflate payload format, ported from the `zhsunyco_esl` package bundled with the
|
|
7
|
+
* Home Assistant Wolink ESL integration (`zhsunyco_esl/protocol.py`):
|
|
8
|
+
*
|
|
9
|
+
* A5 A6 <blockCount u8> 02, then per block: <1-based index u8> <compressedLength u16le> <raw deflate>
|
|
10
|
+
*
|
|
11
|
+
* Each block holds up to 8 KB of the uncompressed 2bpp buffer. Sent in place of the raw buffer,
|
|
12
|
+
* followed by `COMMAND.refreshCompressed` carrying the compressed length.
|
|
13
|
+
*/
|
|
14
|
+
const WOLINK_BLOCK_SIZE = 8192;
|
|
15
|
+
const WOLINK_BLOCK_FORMAT = 0x02;
|
|
16
|
+
function compressWolinkBlocks(data) {
|
|
17
|
+
const blockCount = Math.ceil(data.length / WOLINK_BLOCK_SIZE);
|
|
18
|
+
if (blockCount > 0xff) {
|
|
19
|
+
throw new Error(`zhsunyco compression: ${data.length} bytes needs ${blockCount} blocks, format allows at most 255`);
|
|
20
|
+
}
|
|
21
|
+
const parts = [Buffer.from([0xa5, 0xa6, blockCount, WOLINK_BLOCK_FORMAT])];
|
|
22
|
+
for (let i = 0; i < blockCount; i++) {
|
|
23
|
+
const deflated = (0, zlib_1.deflateRawSync)(data.subarray(i * WOLINK_BLOCK_SIZE, (i + 1) * WOLINK_BLOCK_SIZE), { level: 9 });
|
|
24
|
+
const header = Buffer.alloc(3);
|
|
25
|
+
header[0] = i + 1;
|
|
26
|
+
header.writeUInt16LE(deflated.length, 1);
|
|
27
|
+
parts.push(header, deflated);
|
|
28
|
+
}
|
|
29
|
+
return Buffer.concat(parts);
|
|
30
|
+
}
|
|
@@ -7,6 +7,8 @@ const pluginVersion_1 = require("../../pluginVersion");
|
|
|
7
7
|
const metadata_1 = require("./metadata");
|
|
8
8
|
const encode_1 = require("./encode");
|
|
9
9
|
const reframe_1 = require("../../render/reframe");
|
|
10
|
+
const mirror_1 = require("../../render/mirror");
|
|
11
|
+
const compression_1 = require("./compression");
|
|
10
12
|
const protocol_1 = require("./protocol");
|
|
11
13
|
/** node-ble has no MTU API; this matches the reference driver's mtu(247)-9 default. */
|
|
12
14
|
const UPLOAD_CHUNK_SIZE = 238;
|
|
@@ -77,14 +79,18 @@ class ZhsunycoDriver {
|
|
|
77
79
|
const challenge = await conn.read(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.authenticate);
|
|
78
80
|
await conn.write(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.authenticate, (0, protocol_1.authResponse)(challenge, aesKey), false);
|
|
79
81
|
await (0, bleDiscovery_1.sleep)(AUTH_SETTLE_DELAY_MS);
|
|
80
|
-
const framed = (0, reframe_1.reframeBitmap)(bitmap, metadata.width, metadata.height - metadata.voffset, config.reframe ?? "crop");
|
|
82
|
+
const framed = (0, mirror_1.mirrorBitmap)((0, reframe_1.reframeBitmap)(bitmap, metadata.width, metadata.height - metadata.voffset, config.reframe ?? "crop"), config.mirror ?? "none");
|
|
81
83
|
const pixelData = (0, encode_1.encodeBitmap)(framed, metadata);
|
|
82
|
-
|
|
83
|
-
|
|
84
|
+
const compress = config.compress ?? true;
|
|
85
|
+
// Upload offsets and the refresh length both count bytes of whatever's actually sent - the
|
|
86
|
+
// compressed payload when compressing, not the raw buffer it inflates back to.
|
|
87
|
+
const payload = compress ? (0, compression_1.compressWolinkBlocks)(pixelData) : pixelData;
|
|
88
|
+
for (let offset = 0; offset < payload.length; offset += UPLOAD_CHUNK_SIZE) {
|
|
89
|
+
const chunk = payload.subarray(offset, offset + UPLOAD_CHUNK_SIZE);
|
|
84
90
|
await conn.write(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.data, Buffer.concat([(0, protocol_1.commandHeader)(protocol_1.COMMAND.uploadBlock, offset), chunk]), true);
|
|
85
91
|
await (0, bleDiscovery_1.sleep)(CHUNK_WRITE_DELAY_MS);
|
|
86
92
|
}
|
|
87
|
-
await conn.write(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.data, (0, protocol_1.commandHeader)(protocol_1.COMMAND.refreshUncompressed,
|
|
93
|
+
await conn.write(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.data, (0, protocol_1.commandHeader)(compress ? protocol_1.COMMAND.refreshCompressed : protocol_1.COMMAND.refreshUncompressed, payload.length), true);
|
|
88
94
|
// `Promise.race` can't cancel its loser, so once `statusReceived` settles (the common case)
|
|
89
95
|
// this timer would otherwise sit alive for the rest of its 60s regardless - see the identical
|
|
90
96
|
// reasoning on `readDeviceDetails`'s own race, below.
|
|
@@ -15,6 +15,8 @@ export declare const WOLINK_CHARACTERISTIC_UUIDS: {
|
|
|
15
15
|
export declare const COMMAND: {
|
|
16
16
|
readonly uploadBlock: 42240;
|
|
17
17
|
readonly refreshUncompressed: 42241;
|
|
18
|
+
/** Refresh after uploading a `compressWolinkBlocks` payload rather than the raw buffer. */
|
|
19
|
+
readonly refreshCompressed: 42242;
|
|
18
20
|
};
|
|
19
21
|
export interface AdvertisedDeviceInfo {
|
|
20
22
|
pid: number;
|
|
@@ -25,6 +25,8 @@ exports.WOLINK_CHARACTERISTIC_UUIDS = {
|
|
|
25
25
|
exports.COMMAND = {
|
|
26
26
|
uploadBlock: 0xa500,
|
|
27
27
|
refreshUncompressed: 0xa501,
|
|
28
|
+
/** Refresh after uploading a `compressWolinkBlocks` payload rather than the raw buffer. */
|
|
29
|
+
refreshCompressed: 0xa502,
|
|
28
30
|
};
|
|
29
31
|
/** Decodes the 8-byte header shared by the advertising mirror and config characteristic. */
|
|
30
32
|
function decodeAdvertisedInfo(data) {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { EmailConfig } from "../config";
|
|
2
|
+
import { Bitmap } from "../render/types";
|
|
3
|
+
import { FieldRow } from "../render/fieldsTable";
|
|
4
|
+
/** Either the SVG template's field-by-field table (`buildFieldsTable`), a `TemplateProvider`'s own rendered-content text (`describeContent`), or neither (a provider offered no `describeContent`). */
|
|
5
|
+
export type EmailContentBody = {
|
|
6
|
+
kind: "table";
|
|
7
|
+
rows: FieldRow[];
|
|
8
|
+
} | {
|
|
9
|
+
kind: "text";
|
|
10
|
+
text: string;
|
|
11
|
+
} | {
|
|
12
|
+
kind: "none";
|
|
13
|
+
};
|
|
14
|
+
export interface LabelEmailContent {
|
|
15
|
+
friendlyName: string;
|
|
16
|
+
description?: string;
|
|
17
|
+
bitmap: Bitmap;
|
|
18
|
+
body: EmailContentBody;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Renders and sends one device's repaint as an email - a PNG attachment (also shown inline via a `cid`
|
|
22
|
+
* reference), the same field-by-field table `esl-cli fields` shows for a hand-authored SVG template (or
|
|
23
|
+
* a `TemplateProvider`'s own rendered-content text for something like a GenAI prompt - see `body`), and a
|
|
24
|
+
* link back to the docs site. Called from `considerRepaint` (`../repaintScheduler.ts`) whenever a
|
|
25
|
+
* device's `emailTo` is set and its render succeeds.
|
|
26
|
+
*/
|
|
27
|
+
export declare function sendLabelEmail(emailConfig: EmailConfig, to: string, content: LabelEmailContent): Promise<void>;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.sendLabelEmail = sendLabelEmail;
|
|
7
|
+
const nodemailer_1 = __importDefault(require("nodemailer"));
|
|
8
|
+
const png_1 = require("../render/png");
|
|
9
|
+
const DOCS_URL = "https://rhizomatics.github.io/signalk-einklabel-plugin/";
|
|
10
|
+
function escapeHtml(text) {
|
|
11
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
12
|
+
}
|
|
13
|
+
function bodyHtml(body) {
|
|
14
|
+
if (body.kind === "table") {
|
|
15
|
+
if (body.rows.length === 0)
|
|
16
|
+
return "";
|
|
17
|
+
const rows = body.rows
|
|
18
|
+
.map((row) => `<tr><td>${escapeHtml(row.id)}</td><td>${escapeHtml(row.spec)}</td><td>${escapeHtml(row.value)}</td></tr>`)
|
|
19
|
+
.join("");
|
|
20
|
+
return ('<table border="1" cellpadding="4" cellspacing="0" style="border-collapse:collapse;font-family:monospace;font-size:13px">' +
|
|
21
|
+
`<thead><tr><th>id</th><th>spec</th><th>value</th></tr></thead><tbody>${rows}</tbody></table>`);
|
|
22
|
+
}
|
|
23
|
+
if (body.kind === "text") {
|
|
24
|
+
return `<pre style="white-space:pre-wrap;font-family:monospace;font-size:13px">${escapeHtml(body.text)}</pre>`;
|
|
25
|
+
}
|
|
26
|
+
return "";
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Renders and sends one device's repaint as an email - a PNG attachment (also shown inline via a `cid`
|
|
30
|
+
* reference), the same field-by-field table `esl-cli fields` shows for a hand-authored SVG template (or
|
|
31
|
+
* a `TemplateProvider`'s own rendered-content text for something like a GenAI prompt - see `body`), and a
|
|
32
|
+
* link back to the docs site. Called from `considerRepaint` (`../repaintScheduler.ts`) whenever a
|
|
33
|
+
* device's `emailTo` is set and its render succeeds.
|
|
34
|
+
*/
|
|
35
|
+
async function sendLabelEmail(emailConfig, to, content) {
|
|
36
|
+
const transporter = nodemailer_1.default.createTransport({
|
|
37
|
+
host: emailConfig.smtpHost,
|
|
38
|
+
port: emailConfig.smtpPort,
|
|
39
|
+
secure: emailConfig.smtpSecure,
|
|
40
|
+
auth: emailConfig.smtpUser ? { user: emailConfig.smtpUser, pass: emailConfig.smtpPassword } : undefined,
|
|
41
|
+
});
|
|
42
|
+
const png = (0, png_1.bitmapToPng)(content.bitmap);
|
|
43
|
+
const html = [
|
|
44
|
+
'<div style="font-family:sans-serif">',
|
|
45
|
+
`<h2>${escapeHtml(content.friendlyName)}</h2>`,
|
|
46
|
+
content.description ? `<p>${escapeHtml(content.description)}</p>` : "",
|
|
47
|
+
`<p><img src="cid:label-image" alt="${escapeHtml(content.friendlyName)}" style="border:1px solid #ccc" /></p>`,
|
|
48
|
+
bodyHtml(content.body),
|
|
49
|
+
`<p><a href="${DOCS_URL}">${DOCS_URL}</a></p>`,
|
|
50
|
+
"</div>",
|
|
51
|
+
].join("\n");
|
|
52
|
+
await transporter.sendMail({
|
|
53
|
+
from: emailConfig.fromAddress,
|
|
54
|
+
to,
|
|
55
|
+
subject: `eInk Label: ${content.friendlyName}`,
|
|
56
|
+
html,
|
|
57
|
+
attachments: [
|
|
58
|
+
{
|
|
59
|
+
filename: "label.png",
|
|
60
|
+
content: png,
|
|
61
|
+
contentType: "image/png",
|
|
62
|
+
cid: "label-image",
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
});
|
|
66
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { TemplateContext } from "./types";
|
|
2
|
+
export interface FieldRow {
|
|
3
|
+
id: string;
|
|
4
|
+
spec: string;
|
|
5
|
+
value: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Synchronous counterpart to the CLI's `fields` command (`../cli/index.ts`) - same three columns (id,
|
|
9
|
+
* spec, resolved value), but resolved against a single already-assembled `context` rather than
|
|
10
|
+
* re-fetching per binding, since a caller like `considerRepaint` (`../repaintScheduler.ts`) already has
|
|
11
|
+
* the full context its render used. Only meaningful for a hand-authored SVG template - a
|
|
12
|
+
* `TemplateProvider`-backed template (e.g. GenAI) has no `<desc>` bindings to walk; see
|
|
13
|
+
* `TemplateProvider.describeContent` (`./templateProviders.ts`) for that case's own text summary instead.
|
|
14
|
+
*/
|
|
15
|
+
export declare function buildFieldsTable(svgSource: string, context: TemplateContext, templatesDir: string, bundledTemplatesDir: string): FieldRow[];
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.buildFieldsTable = buildFieldsTable;
|
|
4
|
+
const path_1 = require("path");
|
|
5
|
+
const xmldom_1 = require("@xmldom/xmldom");
|
|
6
|
+
const assets_1 = require("./assets");
|
|
7
|
+
const binding_1 = require("./binding");
|
|
8
|
+
function resolveFieldValue(tag, desc, context, templatesDir, bundledTemplatesDir) {
|
|
9
|
+
try {
|
|
10
|
+
const binding = (0, binding_1.parseBinding)(desc);
|
|
11
|
+
if (tag !== "image") {
|
|
12
|
+
return (0, binding_1.renderBinding)(binding, context);
|
|
13
|
+
}
|
|
14
|
+
if (!binding.assets) {
|
|
15
|
+
return 'ERROR: an <image> binding requires an "assets" key';
|
|
16
|
+
}
|
|
17
|
+
const key = (0, assets_1.normalizeAssetKey)((0, binding_1.resolveBinding)(binding, context));
|
|
18
|
+
if (!key)
|
|
19
|
+
return "(no usable value - image would be omitted)";
|
|
20
|
+
const assetPath = (0, assets_1.resolveAssetPath)(templatesDir, bundledTemplatesDir, binding.assets, key);
|
|
21
|
+
return assetPath ? `"${key}" -> ${(0, path_1.basename)(assetPath)}` : `"${key}" -> no matching asset file (image would be omitted)`;
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
return `ERROR: ${err.message}`;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Synchronous counterpart to the CLI's `fields` command (`../cli/index.ts`) - same three columns (id,
|
|
29
|
+
* spec, resolved value), but resolved against a single already-assembled `context` rather than
|
|
30
|
+
* re-fetching per binding, since a caller like `considerRepaint` (`../repaintScheduler.ts`) already has
|
|
31
|
+
* the full context its render used. Only meaningful for a hand-authored SVG template - a
|
|
32
|
+
* `TemplateProvider`-backed template (e.g. GenAI) has no `<desc>` bindings to walk; see
|
|
33
|
+
* `TemplateProvider.describeContent` (`./templateProviders.ts`) for that case's own text summary instead.
|
|
34
|
+
*/
|
|
35
|
+
function buildFieldsTable(svgSource, context, templatesDir, bundledTemplatesDir) {
|
|
36
|
+
const doc = new xmldom_1.DOMParser().parseFromString(svgSource, "image/svg+xml");
|
|
37
|
+
const rows = [];
|
|
38
|
+
for (const tag of ["text", "image"]) {
|
|
39
|
+
const elements = doc.getElementsByTagName(tag);
|
|
40
|
+
for (let i = 0; i < elements.length; i++) {
|
|
41
|
+
const element = elements.item(i);
|
|
42
|
+
if (!element)
|
|
43
|
+
continue;
|
|
44
|
+
const id = element.getAttribute("id") ?? `${tag}#${i}`;
|
|
45
|
+
const desc = element.getElementsByTagName("desc").item(0);
|
|
46
|
+
if (!desc?.textContent)
|
|
47
|
+
continue;
|
|
48
|
+
rows.push({ id, spec: desc.textContent, value: resolveFieldValue(tag, desc.textContent, context, templatesDir, bundledTemplatesDir) });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return rows;
|
|
52
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { PluginConfig } from "../config";
|
|
2
|
+
import { Colour } from "../devices/types";
|
|
3
|
+
import { Binding } from "./binding";
|
|
4
|
+
import { TemplateContext } from "./types";
|
|
5
|
+
/** Facts about one physical label a prompt's `source=label,path=...` placeholders can reference - see `buildLabelContext`. */
|
|
6
|
+
export interface LabelMeta {
|
|
7
|
+
manufacturer: string;
|
|
8
|
+
/** The physical panel's own size label, e.g. `'3.7"'` - `DeviceMetadata.label` verbatim, see `../devices/types.ts`. */
|
|
9
|
+
label: string;
|
|
10
|
+
width: number;
|
|
11
|
+
height: number;
|
|
12
|
+
colours: Colour[];
|
|
13
|
+
description?: string;
|
|
14
|
+
position?: {
|
|
15
|
+
latitude: number;
|
|
16
|
+
longitude: number;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Builds the `context.label` object a prompt/target-guidance fragment addresses via
|
|
21
|
+
* `source=label,path=...` bindings (e.g. `{source=label,path=width}`, or the bare-path shorthand
|
|
22
|
+
* `{width}` is *not* supported here deliberately - see `./binding.ts`'s `parseBinding`, every `{...}`
|
|
23
|
+
* placeholder is a real binding, so a `label` path always needs the explicit `source=label,path=`
|
|
24
|
+
* form to disambiguate it from a `signalk` self path). `colours`/`fonts` are left as arrays (each
|
|
25
|
+
* colour entry pre-annotated with its hex code, e.g. `"black (#000000)"`) rather than joined into a
|
|
26
|
+
* single string, so a prompt author can either use them bare (renders as JSON, e.g. for a model that
|
|
27
|
+
* parses structured hints) or add `format=csv` (see `./formatters.ts`) for a plain comma-separated list.
|
|
28
|
+
*/
|
|
29
|
+
export declare function buildLabelContext(meta: LabelMeta): Record<string, unknown>;
|
|
30
|
+
/**
|
|
31
|
+
* Every binding referenced across one or more prompt fragments - every `{...}` placeholder, deduplicated
|
|
32
|
+
* across all of `texts` combined, parsed exactly the way a template's `<desc>` binding is
|
|
33
|
+
* (`parseBinding`, see `./binding.ts`), so a bare path (`{design.length}`, `source=signalk,context=self`
|
|
34
|
+
* shorthand), the full binding grammar (`{source=signalk,path=navigation.position,format=position}`),
|
|
35
|
+
* and a `source=label,path=...` binding all work uniformly. Pass the result to `assembleRawContext`
|
|
36
|
+
* (repaintScheduler.ts) exactly as a template's own bindings are - it fetches the `signalk`/
|
|
37
|
+
* `resources`-sourced ones and silently ignores `label`/`einklabel`-sourced ones, which resolve directly
|
|
38
|
+
* against `context.label`/`context.meta` instead (built by the caller, not fetched).
|
|
39
|
+
*
|
|
40
|
+
* A placeholder that isn't valid binding grammar (e.g. a typo like `{source=taheight}`) is silently
|
|
41
|
+
* skipped here rather than thrown - the same per-field isolation `SvgRenderer` gives a bad `<desc>`
|
|
42
|
+
* binding, so one malformed placeholder doesn't take down the whole prompt. `substitutePlaceholders`
|
|
43
|
+
* hits the identical parse error at substitution time and turns it into "???" for just that field.
|
|
44
|
+
*/
|
|
45
|
+
export declare function findPromptBindings(...texts: string[]): Binding[];
|
|
46
|
+
/**
|
|
47
|
+
* Substitutes every `{...}` placeholder in `text` with its resolved binding value against `context`
|
|
48
|
+
* (built by `assembleRawContext` plus `context.label` from `buildLabelContext` - see
|
|
49
|
+
* `findPromptBindings`). Mirrors `renderBinding`'s per-field isolation, but substitutes "???" for
|
|
50
|
+
* anything that resolves to no value at all (missing path, invalid binding grammar) rather than "" -
|
|
51
|
+
* prose with a silently-blank word reads as a fact ("for the sailor of a m vessel"), not as a gap the
|
|
52
|
+
* reader would notice. Two things override that "???": a binding that resolves successfully to a
|
|
53
|
+
* legitimately empty string (e.g. an unset `{source=label,path=description}`) is left as empty, since
|
|
54
|
+
* that's a real answer, not a miss; and a binding with an explicit `default=` (see `./binding.ts`) uses
|
|
55
|
+
* that default instead, since the prompt author has already said what a missing value should read as.
|
|
56
|
+
*/
|
|
57
|
+
export declare function substitutePlaceholders(text: string, context: TemplateContext): string;
|
|
58
|
+
/**
|
|
59
|
+
* Strips everything outside the first `<svg`...last `</svg>` span - a chat model asked for "only raw
|
|
60
|
+
* SVG markup" still often wraps it in a markdown code fence or adds a sentence of commentary either
|
|
61
|
+
* side, despite the target-guidance fragment telling it not to.
|
|
62
|
+
*/
|
|
63
|
+
export declare function extractSvg(raw: string): string;
|
|
64
|
+
export type LlmSettings = Pick<PluginConfig, "llmProvider" | "llmApiKey" | "llmModel" | "llmBaseUrl"> & {
|
|
65
|
+
llmTimeoutSeconds?: number;
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* `ai` and every `@ai-sdk/*` provider package are `optionalDependencies` (see package.json), not plain
|
|
69
|
+
* `dependencies` - most installs (every device using `renderMode: "svg-template"` only) never touch an
|
|
70
|
+
* LLM at all, so they shouldn't have to pull in this whole gateway stack, and an install that fails to
|
|
71
|
+
* fetch one of these (network hiccup, `npm install --omit=optional`, an unsupported platform) must not
|
|
72
|
+
* break BLE painting/templates, which have nothing to do with it. That means every reference to one of
|
|
73
|
+
* these packages has to be a `require()` reached only when a `renderMode: "llm-prompt"` device actually
|
|
74
|
+
* calls `callLlm` - a top-level `import` would be resolved eagerly the moment this module loads (which
|
|
75
|
+
* is every plugin start, via `repaintScheduler.ts`), throwing before any config is even read. Types are
|
|
76
|
+
* still fully checked via `typeof import(...)` below, which - unlike a value `import` - is erased
|
|
77
|
+
* entirely at compile time and leaves no runtime trace for `tsc` to eagerly require.
|
|
78
|
+
*/
|
|
79
|
+
export declare function loadOptional<M>(moduleName: string): M;
|
|
80
|
+
/**
|
|
81
|
+
* Calls the configured LLM gateway with `prompt`, returning its raw text response - not yet extracted/
|
|
82
|
+
* validated as SVG, see `extractSvg`. This is the first network call in the codebase that needs its own
|
|
83
|
+
* timeout (`fetchJson` in `../httpJson.ts` has none - every existing call is to the local SignalK
|
|
84
|
+
* server's own REST API).
|
|
85
|
+
*/
|
|
86
|
+
export declare function callLlm(settings: LlmSettings, prompt: string): Promise<string>;
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.buildLabelContext = buildLabelContext;
|
|
4
|
+
exports.findPromptBindings = findPromptBindings;
|
|
5
|
+
exports.substitutePlaceholders = substitutePlaceholders;
|
|
6
|
+
exports.extractSvg = extractSvg;
|
|
7
|
+
exports.loadOptional = loadOptional;
|
|
8
|
+
exports.callLlm = callLlm;
|
|
9
|
+
const binding_1 = require("./binding");
|
|
10
|
+
const formatters_1 = require("./formatters");
|
|
11
|
+
const COLOUR_HEX = { black: "#000000", white: "#FFFFFF", red: "#FF0000", yellow: "#FFFF00" };
|
|
12
|
+
/** The only `font-family` values `SvgRenderer` is guaranteed to render - see `expandGenericFontFamilies` in `./svgRenderer.ts`. */
|
|
13
|
+
const SAFE_FONT_FAMILIES = ["serif", "sans-serif", "monospace"];
|
|
14
|
+
/**
|
|
15
|
+
* Builds the `context.label` object a prompt/target-guidance fragment addresses via
|
|
16
|
+
* `source=label,path=...` bindings (e.g. `{source=label,path=width}`, or the bare-path shorthand
|
|
17
|
+
* `{width}` is *not* supported here deliberately - see `./binding.ts`'s `parseBinding`, every `{...}`
|
|
18
|
+
* placeholder is a real binding, so a `label` path always needs the explicit `source=label,path=`
|
|
19
|
+
* form to disambiguate it from a `signalk` self path). `colours`/`fonts` are left as arrays (each
|
|
20
|
+
* colour entry pre-annotated with its hex code, e.g. `"black (#000000)"`) rather than joined into a
|
|
21
|
+
* single string, so a prompt author can either use them bare (renders as JSON, e.g. for a model that
|
|
22
|
+
* parses structured hints) or add `format=csv` (see `./formatters.ts`) for a plain comma-separated list.
|
|
23
|
+
*/
|
|
24
|
+
function buildLabelContext(meta) {
|
|
25
|
+
return {
|
|
26
|
+
manufacturer: meta.manufacturer,
|
|
27
|
+
label: meta.label,
|
|
28
|
+
width: meta.width,
|
|
29
|
+
height: meta.height,
|
|
30
|
+
colours: meta.colours.map((colour) => `${colour} (${COLOUR_HEX[colour]})`),
|
|
31
|
+
fonts: SAFE_FONT_FAMILIES,
|
|
32
|
+
description: meta.description ?? "",
|
|
33
|
+
position: meta.position ? (0, formatters_1.applyFormat)("position", meta.position, {}, 3) : undefined,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Every binding referenced across one or more prompt fragments - every `{...}` placeholder, deduplicated
|
|
38
|
+
* across all of `texts` combined, parsed exactly the way a template's `<desc>` binding is
|
|
39
|
+
* (`parseBinding`, see `./binding.ts`), so a bare path (`{design.length}`, `source=signalk,context=self`
|
|
40
|
+
* shorthand), the full binding grammar (`{source=signalk,path=navigation.position,format=position}`),
|
|
41
|
+
* and a `source=label,path=...` binding all work uniformly. Pass the result to `assembleRawContext`
|
|
42
|
+
* (repaintScheduler.ts) exactly as a template's own bindings are - it fetches the `signalk`/
|
|
43
|
+
* `resources`-sourced ones and silently ignores `label`/`einklabel`-sourced ones, which resolve directly
|
|
44
|
+
* against `context.label`/`context.meta` instead (built by the caller, not fetched).
|
|
45
|
+
*
|
|
46
|
+
* A placeholder that isn't valid binding grammar (e.g. a typo like `{source=taheight}`) is silently
|
|
47
|
+
* skipped here rather than thrown - the same per-field isolation `SvgRenderer` gives a bad `<desc>`
|
|
48
|
+
* binding, so one malformed placeholder doesn't take down the whole prompt. `substitutePlaceholders`
|
|
49
|
+
* hits the identical parse error at substitution time and turns it into "???" for just that field.
|
|
50
|
+
*/
|
|
51
|
+
function findPromptBindings(...texts) {
|
|
52
|
+
const seen = new Set();
|
|
53
|
+
const bindings = [];
|
|
54
|
+
for (const text of texts) {
|
|
55
|
+
for (const match of text.matchAll(/\{([^{}]+)\}/g)) {
|
|
56
|
+
const key = match[1].trim();
|
|
57
|
+
if (seen.has(key))
|
|
58
|
+
continue;
|
|
59
|
+
seen.add(key);
|
|
60
|
+
try {
|
|
61
|
+
bindings.push((0, binding_1.parseBinding)(key));
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// see doc comment above - left for `substitutePlaceholders` to turn into "???"
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return bindings;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Substitutes every `{...}` placeholder in `text` with its resolved binding value against `context`
|
|
72
|
+
* (built by `assembleRawContext` plus `context.label` from `buildLabelContext` - see
|
|
73
|
+
* `findPromptBindings`). Mirrors `renderBinding`'s per-field isolation, but substitutes "???" for
|
|
74
|
+
* anything that resolves to no value at all (missing path, invalid binding grammar) rather than "" -
|
|
75
|
+
* prose with a silently-blank word reads as a fact ("for the sailor of a m vessel"), not as a gap the
|
|
76
|
+
* reader would notice. Two things override that "???": a binding that resolves successfully to a
|
|
77
|
+
* legitimately empty string (e.g. an unset `{source=label,path=description}`) is left as empty, since
|
|
78
|
+
* that's a real answer, not a miss; and a binding with an explicit `default=` (see `./binding.ts`) uses
|
|
79
|
+
* that default instead, since the prompt author has already said what a missing value should read as.
|
|
80
|
+
*/
|
|
81
|
+
function substitutePlaceholders(text, context) {
|
|
82
|
+
return text.replace(/\{([^{}]+)\}/g, (_match, raw) => {
|
|
83
|
+
const key = raw.trim();
|
|
84
|
+
try {
|
|
85
|
+
const binding = (0, binding_1.parseBinding)(key);
|
|
86
|
+
const value = (0, binding_1.resolveBinding)(binding, context);
|
|
87
|
+
if ((value === undefined || value === null) && binding.default === undefined)
|
|
88
|
+
return "???";
|
|
89
|
+
return (0, binding_1.renderBinding)(binding, context);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return "???";
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Strips everything outside the first `<svg`...last `</svg>` span - a chat model asked for "only raw
|
|
98
|
+
* SVG markup" still often wraps it in a markdown code fence or adds a sentence of commentary either
|
|
99
|
+
* side, despite the target-guidance fragment telling it not to.
|
|
100
|
+
*/
|
|
101
|
+
function extractSvg(raw) {
|
|
102
|
+
const start = raw.indexOf("<svg");
|
|
103
|
+
const end = raw.lastIndexOf("</svg>");
|
|
104
|
+
if (start === -1 || end === -1 || end < start) {
|
|
105
|
+
throw new Error('LLM response did not contain a "<svg>...</svg>" document');
|
|
106
|
+
}
|
|
107
|
+
return raw.slice(start, end + "</svg>".length);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* `ai` and every `@ai-sdk/*` provider package are `optionalDependencies` (see package.json), not plain
|
|
111
|
+
* `dependencies` - most installs (every device using `renderMode: "svg-template"` only) never touch an
|
|
112
|
+
* LLM at all, so they shouldn't have to pull in this whole gateway stack, and an install that fails to
|
|
113
|
+
* fetch one of these (network hiccup, `npm install --omit=optional`, an unsupported platform) must not
|
|
114
|
+
* break BLE painting/templates, which have nothing to do with it. That means every reference to one of
|
|
115
|
+
* these packages has to be a `require()` reached only when a `renderMode: "llm-prompt"` device actually
|
|
116
|
+
* calls `callLlm` - a top-level `import` would be resolved eagerly the moment this module loads (which
|
|
117
|
+
* is every plugin start, via `repaintScheduler.ts`), throwing before any config is even read. Types are
|
|
118
|
+
* still fully checked via `typeof import(...)` below, which - unlike a value `import` - is erased
|
|
119
|
+
* entirely at compile time and leaves no runtime trace for `tsc` to eagerly require.
|
|
120
|
+
*/
|
|
121
|
+
function loadOptional(moduleName) {
|
|
122
|
+
try {
|
|
123
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
124
|
+
return require(moduleName);
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
throw new Error(`LLM provider support needs the optional dependency "${moduleName}", which isn't installed - run "npm install ${moduleName}" (${err.message})`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** Ollama's own default local listen address - see the `"ollama"` case in `resolveModel` below. */
|
|
131
|
+
const DEFAULT_OLLAMA_BASE_URL = "http://localhost:11434/v1";
|
|
132
|
+
/**
|
|
133
|
+
* Resolves `settings` to a Vercel AI SDK model handle. `"ollama"` and `"local"` both go through the
|
|
134
|
+
* generic `@ai-sdk/openai-compatible` provider (Ollama/LM Studio/vLLM all expose an OpenAI-compatible
|
|
135
|
+
* endpoint, and none of them has - or needs - its own dedicated `@ai-sdk/*` package): `"ollama"`
|
|
136
|
+
* defaults `llmBaseUrl` to Ollama's own standard local address so it works with no further config
|
|
137
|
+
* (override it only if Ollama is running elsewhere, e.g. on the SignalK server's own host reached over
|
|
138
|
+
* the network); `"local"` is for anything else OpenAI-compatible, where there's no sensible universal
|
|
139
|
+
* default, so `llmBaseUrl` is required.
|
|
140
|
+
*/
|
|
141
|
+
function resolveModel(settings) {
|
|
142
|
+
const model = settings.llmModel;
|
|
143
|
+
if (!model) {
|
|
144
|
+
throw new Error("no LLM model configured (PluginConfig.llmModel)");
|
|
145
|
+
}
|
|
146
|
+
switch (settings.llmProvider ?? "openai") {
|
|
147
|
+
case "anthropic": {
|
|
148
|
+
const { createAnthropic } = loadOptional("@ai-sdk/anthropic");
|
|
149
|
+
return createAnthropic({ apiKey: settings.llmApiKey })(model);
|
|
150
|
+
}
|
|
151
|
+
case "google": {
|
|
152
|
+
const { createGoogleGenerativeAI } = loadOptional("@ai-sdk/google");
|
|
153
|
+
return createGoogleGenerativeAI({ apiKey: settings.llmApiKey })(model);
|
|
154
|
+
}
|
|
155
|
+
case "xai": {
|
|
156
|
+
const { createXai } = loadOptional("@ai-sdk/xai");
|
|
157
|
+
return createXai({ apiKey: settings.llmApiKey })(model);
|
|
158
|
+
}
|
|
159
|
+
case "ollama": {
|
|
160
|
+
const { createOpenAICompatible } = loadOptional("@ai-sdk/openai-compatible");
|
|
161
|
+
return createOpenAICompatible({
|
|
162
|
+
name: "ollama",
|
|
163
|
+
baseURL: settings.llmBaseUrl || DEFAULT_OLLAMA_BASE_URL,
|
|
164
|
+
apiKey: settings.llmApiKey,
|
|
165
|
+
})(model);
|
|
166
|
+
}
|
|
167
|
+
case "local": {
|
|
168
|
+
if (!settings.llmBaseUrl) {
|
|
169
|
+
throw new Error('llmBaseUrl is required when llmProvider is "local"');
|
|
170
|
+
}
|
|
171
|
+
const { createOpenAICompatible } = loadOptional("@ai-sdk/openai-compatible");
|
|
172
|
+
return createOpenAICompatible({ name: "local", baseURL: settings.llmBaseUrl, apiKey: settings.llmApiKey })(model);
|
|
173
|
+
}
|
|
174
|
+
case "openai":
|
|
175
|
+
default: {
|
|
176
|
+
const { createOpenAI } = loadOptional("@ai-sdk/openai");
|
|
177
|
+
return createOpenAI({ apiKey: settings.llmApiKey })(model);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Calls the configured LLM gateway with `prompt`, returning its raw text response - not yet extracted/
|
|
183
|
+
* validated as SVG, see `extractSvg`. This is the first network call in the codebase that needs its own
|
|
184
|
+
* timeout (`fetchJson` in `../httpJson.ts` has none - every existing call is to the local SignalK
|
|
185
|
+
* server's own REST API).
|
|
186
|
+
*/
|
|
187
|
+
async function callLlm(settings, prompt) {
|
|
188
|
+
const model = resolveModel(settings);
|
|
189
|
+
const { generateText } = loadOptional("ai");
|
|
190
|
+
const controller = new AbortController();
|
|
191
|
+
const timer = setTimeout(() => controller.abort(), (settings.llmTimeoutSeconds ?? 30) * 1000);
|
|
192
|
+
try {
|
|
193
|
+
const { text } = await generateText({ model, prompt, abortSignal: controller.signal });
|
|
194
|
+
return text;
|
|
195
|
+
}
|
|
196
|
+
finally {
|
|
197
|
+
clearTimeout(timer);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Bitmap } from "./types";
|
|
2
|
+
/**
|
|
3
|
+
* Flips the rendered image before it's encoded for the panel - for hardware whose RAM layout is
|
|
4
|
+
* mirrored relative to what a driver's encoder assumes (the Wolink HA integration found the 2.9"
|
|
5
|
+
* and 3.5" panels differ from the 3.7" this plugin's zhsunyco encoder was confirmed against), or a
|
|
6
|
+
* label that's simply mounted upside down (`"both"` is a 180° rotation).
|
|
7
|
+
*/
|
|
8
|
+
export type MirrorMode = "none" | "horizontal" | "vertical" | "both";
|
|
9
|
+
export declare const MIRROR_MODES: MirrorMode[];
|
|
10
|
+
/** Returns `bitmap` unchanged for `"none"`, otherwise a flipped copy. */
|
|
11
|
+
export declare function mirrorBitmap(bitmap: Bitmap, mode: MirrorMode): Bitmap;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MIRROR_MODES = void 0;
|
|
4
|
+
exports.mirrorBitmap = mirrorBitmap;
|
|
5
|
+
exports.MIRROR_MODES = ["none", "horizontal", "vertical", "both"];
|
|
6
|
+
/** Returns `bitmap` unchanged for `"none"`, otherwise a flipped copy. */
|
|
7
|
+
function mirrorBitmap(bitmap, mode) {
|
|
8
|
+
if (mode === "none") {
|
|
9
|
+
return bitmap;
|
|
10
|
+
}
|
|
11
|
+
const { width, height } = bitmap;
|
|
12
|
+
const flipX = mode === "horizontal" || mode === "both";
|
|
13
|
+
const flipY = mode === "vertical" || mode === "both";
|
|
14
|
+
const data = new Uint8Array(bitmap.data.length);
|
|
15
|
+
for (let y = 0; y < height; y++) {
|
|
16
|
+
const srcY = flipY ? height - 1 - y : y;
|
|
17
|
+
for (let x = 0; x < width; x++) {
|
|
18
|
+
const srcX = flipX ? width - 1 - x : x;
|
|
19
|
+
const srcOffset = (srcY * width + srcX) * 4;
|
|
20
|
+
data.set(bitmap.data.subarray(srcOffset, srcOffset + 4), (y * width + x) * 4);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return { width, height, data };
|
|
24
|
+
}
|
package/dist/repaintScheduler.js
CHANGED
|
@@ -358,6 +358,9 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
|
|
|
358
358
|
aesKey: device.aesKey,
|
|
359
359
|
connectTimeoutMs,
|
|
360
360
|
reframe: device.reframe,
|
|
361
|
+
mirror: device.mirror,
|
|
362
|
+
compress: device.compress,
|
|
363
|
+
compressionFormat: device.compressionFormat,
|
|
361
364
|
gattBackend,
|
|
362
365
|
});
|
|
363
366
|
paintDurationMs = Date.now() - startedAt;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rhizomatics/signalk-einklabel-plugin",
|
|
3
|
-
"version": "1.3.0-
|
|
3
|
+
"version": "1.3.0-beta9",
|
|
4
4
|
"description": "Display SignalK data on eInk Electronic Shelf Labels, includes working examples for tide clock and watch schedule.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ble",
|