@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 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
  [![codecov](https://img.shields.io/codecov/c/github/rhizomatics/signalk-einklabel-plugin)](https://codecov.io/gh/rhizomatics/signalk-einklabel-plugin)
7
7
  [![code style: oxfmt](https://img.shields.io/badge/code_style-oxfmt-blue.svg)](https://github.com)
8
8
  [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://github.com/rhizomatics/signalk-einklabel-plugin/blob/main/LICENSE)
9
+ [![boat tech directory](https://boat-tech-directory.rhizomatics.org.uk/images/badge.svg)](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 since the devices are intended for indoor use.
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
- - Don't worry about the very latest Bluetooth versions, 4.0 is minimum for BLE, 5.0 is nice
38
- - Home Assistant is massively more popular than SignalK, and often also run on Raspberry Pi and similar, so good source of advice
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 label used for testing this is the [Zhsunyco 3.7" BWRY](https://www.aliexpress.com/item/1005010050104435.html)
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. This could be used to help you choose what size of label to buy, or to get an unsupported label working.
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
@@ -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", "10")
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", "416")
207
- .option("--height <px>", "render height", "240")
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: Number(opts.width),
222
- height: Number(opts.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)(await (0, promises_1.readFile)(opts.template, "utf-8"));
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, Number(opts.width), Number(opts.height), (0, path_1.dirname)(opts.template), config_1.BUNDLED_TEMPLATES_DIR);
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", "416")
255
- .option("--height <px>", "render height", "240")
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 bindings = (0, binding_1.findBindings)(await (0, promises_1.readFile)(opts.template, "utf-8"));
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, Number(opts.width), Number(opts.height), (0, path_1.dirname)(opts.template), config_1.BUNDLED_TEMPLATES_DIR);
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
+ }