@rhizomatics/signalk-einklabel-plugin 1.2.1 → 1.2.3

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,15 @@
1
+ # 1.2.3
2
+
3
+ - Improved example tide template for 2.9" Gicisky, and added blank and error templates
4
+
5
+ # 1.2.2
6
+
7
+ - Fully implemented the backoff and retry logic on failed paint, by default after 30s
8
+ - Auto reframe now happens in main plugin as well as CLI
9
+ - Auto reframe now defaults to `crop` for CLI, can be overidden back to `fixed`
10
+ - Handle Gicisky stale cache paint failures by prompting a re-scan when necessary
11
+ - Improve background default for `crop` reframing
12
+
1
13
  # 1.2.1
2
14
 
3
15
  - Additional tide template for 296x128
package/README.md CHANGED
@@ -8,7 +8,7 @@
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
9
  [![boat tech directory](https://boat-tech-directory.rhizomatics.org.uk/images/badge.svg)](https://boat-tech-directory.rhizomatics.org.uk)
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
+ 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. Supports ESLs from two of the major Chinese manufacturers, and requires no firmware or hardware modifications, switch on and go.
12
12
 
13
13
  ![Companionway Tidal Clock](docs/assets/images/real_tidal_clock.jpg)
14
14
 
@@ -161,6 +161,10 @@ Known by other names, e.g. 'Picksmart', and with white label brands
161
161
 
162
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.
163
163
 
164
+ ### Reframing
165
+
166
+ 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.
167
+
164
168
  ### Template Families (multiple panel sizes/colours)
165
169
 
166
170
  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.
@@ -284,11 +288,13 @@ The width, height, vertical offset and colour palette for the device are taken f
284
288
 
285
289
  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
290
 
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:
291
+ `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 (see [Reframing](#reframing) above):
288
292
 
289
- - `fixed` (default) - no adjustment; a size mismatch is rejected with an error, as it always has been
293
+ - `crop` (default) - 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
290
294
  - `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
295
+ - `fixed` - no adjustment; rejects a size mismatch with an error instead
296
+
297
+ 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".
292
298
 
293
299
  `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.
294
300
 
@@ -378,8 +384,6 @@ and this version will work even without a running SignalK server, using some pre
378
384
  npx esl-cli render -t templates/tides/250x128-BWRY.svg -o example.png -e examples
379
385
  ```
380
386
 
381
-
382
-
383
387
  #### List all Fields and Rendered Values
384
388
 
385
389
  ```bash
package/dist/cli/index.js CHANGED
@@ -38,7 +38,7 @@ function parseColours(code) {
38
38
  }
39
39
  return colours;
40
40
  }
41
- const REFRAME_MODES = ["fixed", "scale", "crop"];
41
+ const REFRAME_MODES = ["crop", "scale", "fixed"];
42
42
  function parseReframeMode(value) {
43
43
  if (!REFRAME_MODES.includes(value)) {
44
44
  throw new Error(`unknown --reframe value "${value}" - expected one of ${REFRAME_MODES.join(", ")}`);
@@ -225,7 +225,7 @@ exports.program
225
225
  .option("--height <px>", "render height - defaults to the template's declared height/viewBox - see --reframe for fitting onto a differently-sized panel")
226
226
  .option("--voffset <px>", "vertical pixel offset of the panel - overrides the looked-up model for unsupported hardware (requires --colours)", "0")
227
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")
228
+ .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")
229
229
  .option("--connect-timeout <seconds>", "BLE connect timeout before giving up on an attempt", "30")
230
230
  .option("--retries <n>", "number of paint attempts (including the first) before giving up", "3")
231
231
  .action(async (opts) => {
package/dist/config.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { ServerAPI } from "@signalk/server-api";
2
2
  import { Colour, DiscoveredDevice } from "./devices/types";
3
+ import { ReframeMode } from "./render/reframe";
3
4
  /**
4
5
  * Special `device` value meaning "every currently-known discovered device" instead of one specific
5
6
  * BLE address - lets a single `DeviceConfig` entry (one template, one trigger) broadcast to every
@@ -49,6 +50,14 @@ export interface DeviceConfig {
49
50
  intervalMinute?: number;
50
51
  /** One-shot override to repaint even if the data is unchanged; cleared automatically once that repaint completes. */
51
52
  forceRepaint?: boolean;
53
+ /**
54
+ * How to fit the rendered image onto the device's actual panel size when it doesn't match (see
55
+ * `ReframeMode`) - e.g. a template family with no variant sized for this particular label. Left
56
+ * unset, `driver.paint()` defaults to `"crop"` itself (see `VendorDeviceConfig.reframe`'s doc
57
+ * comment) - place from the top-left, truncating or leaving the rest blank - since a live label
58
+ * showing *something*, even off-size, beats a repaint that just fails outright.
59
+ */
60
+ reframe?: ReframeMode;
52
61
  }
53
62
  export interface PluginConfig {
54
63
  /**
package/dist/config.js CHANGED
@@ -411,6 +411,13 @@ function configSchema(app, discovered = []) {
411
411
  description: "Repaint even if the data is unchanged - clears itself automatically once that repaint completes",
412
412
  default: false,
413
413
  },
414
+ reframe: {
415
+ type: "string",
416
+ title: "If the render doesn't match the panel size",
417
+ description: "Crop: place at the top-left, truncating anything too big or leaving the rest blank if too small. Scale: stretch to fit exactly (may distort). Fixed: fail the repaint instead of showing an off-size image.",
418
+ enum: ["crop", "scale", "fixed"],
419
+ default: "crop",
420
+ },
414
421
  },
415
422
  },
416
423
  },
@@ -423,6 +430,7 @@ function configUiSchema() {
423
430
  items: {
424
431
  description: { "ui:widget": "textarea" },
425
432
  repaintTrigger: { "ui:widget": "radio" },
433
+ reframe: { "ui:widget": "radio" },
426
434
  },
427
435
  },
428
436
  };
@@ -52,3 +52,29 @@ export declare function withDiscovery<T>(durationMs: number, fn: (adapter: Adapt
52
52
  export declare function connectWithTimeout(device: Device, timeoutMs: number): Promise<void>;
53
53
  /** Uses an already-known device if BlueZ has one cached, otherwise scans until it appears. */
54
54
  export declare function getOrDiscoverDevice(adapter: Adapter, address: string, timeoutMs: number): Promise<Device>;
55
+ /**
56
+ * Returns `device`'s manufacturer data under `manufacturerId` if BlueZ already has it cached, else
57
+ * actively (re)scans for it - covers a device BlueZ already knows about (so `getOrDiscoverDevice`'s
58
+ * cheap `adapter.getDevice()` path returns it immediately, never triggering a scan) but whose
59
+ * advertisement cache is empty or stale, e.g. it hasn't actually been seen since `bluetoothd`
60
+ * restarted. BlueZ updates the *same* `Device` object's properties in place as fresh adverts arrive
61
+ * during a discovery window, so this just starts one (unless already running) and polls the existing
62
+ * `device` handle rather than re-resolving it. Bounded by `timeoutMs`; returns undefined rather than
63
+ * throwing if nothing arrives in time, so a caller can fall back to its own error/manual-override path.
64
+ */
65
+ export declare function waitForManufacturerData(adapter: Adapter, device: Device, manufacturerId: number, timeoutMs: number): Promise<Buffer | undefined>;
66
+ /**
67
+ * Blocks until `bluetooth.defaultAdapter()` actually succeeds, retrying with exponential backoff
68
+ * (2s, doubling, capped at 30s) rather than failing once and giving up - covers the boot-time race
69
+ * where SignalK starts before bluetoothd/D-Bus is up (see the README's "SignalK starts before the
70
+ * Bluetooth daemon" FAQ), and equally a later transient loss (e.g. `bluetoothd` restarting) if a
71
+ * caller invokes this again rather than just once at startup.
72
+ *
73
+ * A non-Linux platform is `createBluetooth`'s own hard incompatibility, not a transient readiness
74
+ * race, so it returns `false` immediately there instead of retrying forever - preserves this
75
+ * plugin's "everything except scan/paint still works" story for template development off a real
76
+ * device (see README's Pre-requisites). `cancelled` is polled between backoff waits so a caller
77
+ * with its own lifecycle (e.g. the plugin's own `stop()`) can abandon an in-progress wait instead of
78
+ * leaving it to retry forever in the background after the thing it was waiting to unblock is gone.
79
+ */
80
+ export declare function waitForAdapter(logDebug: (message: string) => void, cancelled?: () => boolean): Promise<boolean>;
@@ -8,6 +8,8 @@ exports.withRetries = withRetries;
8
8
  exports.withDiscovery = withDiscovery;
9
9
  exports.connectWithTimeout = connectWithTimeout;
10
10
  exports.getOrDiscoverDevice = getOrDiscoverDevice;
11
+ exports.waitForManufacturerData = waitForManufacturerData;
12
+ exports.waitForAdapter = waitForAdapter;
11
13
  const node_ble_1 = require("@naugehyde/node-ble");
12
14
  function sleep(ms) {
13
15
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -157,3 +159,80 @@ async function getOrDiscoverDevice(adapter, address, timeoutMs) {
157
159
  }
158
160
  }
159
161
  }
162
+ const MANUFACTURER_DATA_POLL_MS = 500;
163
+ /**
164
+ * Returns `device`'s manufacturer data under `manufacturerId` if BlueZ already has it cached, else
165
+ * actively (re)scans for it - covers a device BlueZ already knows about (so `getOrDiscoverDevice`'s
166
+ * cheap `adapter.getDevice()` path returns it immediately, never triggering a scan) but whose
167
+ * advertisement cache is empty or stale, e.g. it hasn't actually been seen since `bluetoothd`
168
+ * restarted. BlueZ updates the *same* `Device` object's properties in place as fresh adverts arrive
169
+ * during a discovery window, so this just starts one (unless already running) and polls the existing
170
+ * `device` handle rather than re-resolving it. Bounded by `timeoutMs`; returns undefined rather than
171
+ * throwing if nothing arrives in time, so a caller can fall back to its own error/manual-override path.
172
+ */
173
+ async function waitForManufacturerData(adapter, device, manufacturerId, timeoutMs) {
174
+ const key = manufacturerId.toString();
175
+ const existing = await device.getManufacturerData().catch(() => undefined);
176
+ if (existing?.[key]) {
177
+ return existing[key];
178
+ }
179
+ const wasDiscovering = await adapter.isDiscovering();
180
+ if (!wasDiscovering) {
181
+ await adapter.startDiscovery();
182
+ }
183
+ try {
184
+ const deadline = Date.now() + timeoutMs;
185
+ while (Date.now() < deadline) {
186
+ const data = await device.getManufacturerData().catch(() => undefined);
187
+ if (data?.[key]) {
188
+ return data[key];
189
+ }
190
+ await sleep(MANUFACTURER_DATA_POLL_MS);
191
+ }
192
+ return undefined;
193
+ }
194
+ finally {
195
+ if (!wasDiscovering) {
196
+ await adapter.stopDiscovery();
197
+ }
198
+ }
199
+ }
200
+ const ADAPTER_WAIT_BASE_DELAY_MS = 2000;
201
+ const ADAPTER_WAIT_MAX_DELAY_MS = 30000;
202
+ /**
203
+ * Blocks until `bluetooth.defaultAdapter()` actually succeeds, retrying with exponential backoff
204
+ * (2s, doubling, capped at 30s) rather than failing once and giving up - covers the boot-time race
205
+ * where SignalK starts before bluetoothd/D-Bus is up (see the README's "SignalK starts before the
206
+ * Bluetooth daemon" FAQ), and equally a later transient loss (e.g. `bluetoothd` restarting) if a
207
+ * caller invokes this again rather than just once at startup.
208
+ *
209
+ * A non-Linux platform is `createBluetooth`'s own hard incompatibility, not a transient readiness
210
+ * race, so it returns `false` immediately there instead of retrying forever - preserves this
211
+ * plugin's "everything except scan/paint still works" story for template development off a real
212
+ * device (see README's Pre-requisites). `cancelled` is polled between backoff waits so a caller
213
+ * with its own lifecycle (e.g. the plugin's own `stop()`) can abandon an in-progress wait instead of
214
+ * leaving it to retry forever in the background after the thing it was waiting to unblock is gone.
215
+ */
216
+ async function waitForAdapter(logDebug, cancelled = () => false) {
217
+ if (process.platform !== "linux")
218
+ return false;
219
+ let delayMs = ADAPTER_WAIT_BASE_DELAY_MS;
220
+ while (!cancelled()) {
221
+ let destroy;
222
+ try {
223
+ const created = createBluetooth();
224
+ destroy = created.destroy;
225
+ await created.bluetooth.defaultAdapter();
226
+ return true;
227
+ }
228
+ catch (err) {
229
+ logDebug(`BLE adapter not ready (${err.message}) - retrying in ${delayMs / 1000}s...`);
230
+ await sleep(delayMs);
231
+ delayMs = Math.min(delayMs * 2, ADAPTER_WAIT_MAX_DELAY_MS);
232
+ }
233
+ finally {
234
+ destroy?.();
235
+ }
236
+ }
237
+ return false;
238
+ }
@@ -12,9 +12,19 @@ const FALLBACK = {
12
12
  yellow: "red",
13
13
  red: "black",
14
14
  };
15
- function classifyColour(r, g, b, supported) {
15
+ /** Below this, a pixel counts as "blank" rather than whatever colour its (possibly meaningless, for a fully transparent pixel) RGB happens to hold. */
16
+ const TRANSPARENT_ALPHA_THRESHOLD = 128;
17
+ /**
18
+ * A mostly-transparent pixel - undrawn SVG canvas, e.g. a template resized without its background
19
+ * rect following - classifies as white (blank paper), not the RGB-thresholds' own default of black:
20
+ * resvg-wasm leaves transparent pixels at RGB (0,0,0), and without this check that reads as
21
+ * ink-black rather than the blank label surface it actually represents.
22
+ */
23
+ function classifyColour(r, g, b, a, supported) {
16
24
  let colour = "black";
17
- if (r > 150 && g > 150 && b > 150)
25
+ if (a < TRANSPARENT_ALPHA_THRESHOLD)
26
+ colour = "white";
27
+ else if (r > 150 && g > 150 && b > 150)
18
28
  colour = "white";
19
29
  else if (r > 150 && g > 100 && b < 80)
20
30
  colour = "yellow";
@@ -69,7 +79,7 @@ function packPlane(bitmap, layout, supported, predicate) {
69
79
  const sx = layout.mirrorX ? width - 1 - x : x;
70
80
  const sy = layout.mirrorY ? height - 1 - y : y;
71
81
  const offset = (sy * width + sx) * 4;
72
- const colour = classifyColour(bitmap.data[offset], bitmap.data[offset + 1], bitmap.data[offset + 2], supported);
82
+ const colour = classifyColour(bitmap.data[offset], bitmap.data[offset + 1], bitmap.data[offset + 2], bitmap.data[offset + 3], supported);
73
83
  if (predicate(colour)) {
74
84
  const byteIndex = y * bytesPerRow + (x >> 3);
75
85
  plane[byteIndex] |= 0x80 >> (x % 8);
@@ -90,7 +100,7 @@ function packFourColour(bitmap, layout, supported) {
90
100
  const sx = layout.mirrorX ? width - 1 - x : x;
91
101
  const sy = layout.mirrorY ? height - 1 - y : y;
92
102
  const offset = (sy * width + sx) * 4;
93
- const colour = classifyColour(bitmap.data[offset], bitmap.data[offset + 1], bitmap.data[offset + 2], supported);
103
+ const colour = classifyColour(bitmap.data[offset], bitmap.data[offset + 1], bitmap.data[offset + 2], bitmap.data[offset + 3], supported);
94
104
  const code = FOUR_COLOUR_CODE[colour];
95
105
  const byteIndex = y * bytesPerRow + Math.floor(x / pixelsPerByte);
96
106
  const shift = 6 - (x % pixelsPerByte) * 2;
@@ -8,6 +8,8 @@ const encode_1 = require("./encode");
8
8
  const reframe_1 = require("../../render/reframe");
9
9
  const protocol_1 = require("./protocol");
10
10
  const DEVICE_DISCOVERY_TIMEOUT_MS = 30000;
11
+ /** How long to actively rescan for a fresh advertisement when the cached one is missing/stale - see `waitForManufacturerData`. */
12
+ const MANUFACTURER_DATA_RESCAN_TIMEOUT_MS = 15000;
11
13
  const DEFAULT_PAINT_CONNECT_TIMEOUT_MS = 60000;
12
14
  const ACK_TIMEOUT_MS = 15000;
13
15
  /** Fallback if a device's `requestBlockSize` ack doesn't decode - matches the block size both reference drivers assume. */
@@ -53,12 +55,12 @@ class GiciskyDriver {
53
55
  * Unlike zhsunyco, there's no GATT characteristic that reports the device's PID on demand -
54
56
  * the only source for it is the advertisement, cached on the `Device` object by BlueZ from
55
57
  * the last time it was seen (the same cache `identifyDevice`/a scan reads, just without
56
- * connecting first - see `forEachAdvertisedDevice` in `bleDiscovery.ts`).
58
+ * connecting first - see `forEachAdvertisedDevice` in `bleDiscovery.ts`). That cache can be
59
+ * empty or stale (e.g. nothing has actively scanned since `bluetoothd` last restarted) even
60
+ * though `getOrDiscoverDevice` above found the device instantly via BlueZ's own cache of
61
+ * *devices* - so rescan for a fresh advertisement here rather than failing on the first read.
57
62
  */
58
- const manufacturerData = await device
59
- .getManufacturerData()
60
- .then((data) => data[protocol_1.GICISKY_MANUFACTURER_ID.toString()])
61
- .catch(() => undefined);
63
+ const manufacturerData = await (0, bleDiscovery_1.waitForManufacturerData)(adapter, device, protocol_1.GICISKY_MANUFACTURER_ID, MANUFACTURER_DATA_RESCAN_TIMEOUT_MS);
62
64
  const info = manufacturerData ? (0, protocol_1.decodeAdvertisedInfo)(manufacturerData) : undefined;
63
65
  const metadata = config.modelOverride
64
66
  ? { pid: info?.deviceId ?? 0, ...config.modelOverride }
@@ -67,12 +69,13 @@ class GiciskyDriver {
67
69
  : undefined;
68
70
  if (!metadata) {
69
71
  throw new Error(info === undefined
70
- ? "gicisky device has no cached advertisement to identify it from - scan for it first, or pass --width/--height/--voffset/--colours to describe it manually"
72
+ ? "gicisky device isn't advertising - rescanned but got nothing back (out of range, asleep, or already connected " +
73
+ "elsewhere) - pass --width/--height/--voffset/--colours to describe it manually"
71
74
  : `gicisky device reports unrecognised deviceId 0x${info.deviceId.toString(16).padStart(4, "0")} - ` +
72
75
  "pass --width/--height/--voffset/--colours to describe it manually");
73
76
  }
74
77
  const layout = (info && layout_1.GICISKY_PID_LAYOUT[info.deviceId]) || (0, layout_1.defaultLayoutFor)(metadata.colours);
75
- const framed = (0, reframe_1.reframeBitmap)(bitmap, metadata.width, metadata.height, config.reframe ?? "fixed");
78
+ const framed = (0, reframe_1.reframeBitmap)(bitmap, metadata.width, metadata.height, config.reframe ?? "crop");
76
79
  const payload = (0, encode_1.encodeBitmap)(framed, metadata, layout);
77
80
  await (0, bleDiscovery_1.connectWithTimeout)(device, config.connectTimeoutMs ?? DEFAULT_PAINT_CONNECT_TIMEOUT_MS);
78
81
  try {
@@ -58,7 +58,7 @@ export interface VendorDeviceConfig {
58
58
  modelOverride?: DeviceModelOverride;
59
59
  /** How long to wait for the BLE connect step before giving up - if omitted, the driver picks its own default. */
60
60
  connectTimeoutMs?: number;
61
- /** How to fit the bitmap onto the panel when its size doesn't already match - see `ReframeMode`. Defaults to `"fixed"`, i.e. unchanged (encoding then rejects the mismatch, as it always has). */
61
+ /** 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. */
62
62
  reframe?: ReframeMode;
63
63
  }
64
64
  export interface VendorDriver {
@@ -16,10 +16,21 @@ const FALLBACK = {
16
16
  yellow: "red",
17
17
  red: "black",
18
18
  };
19
- /** Mirrors the reference driver's `from_pillow` nearest-colour decision tree, then maps the result down onto whatever colours this particular device's panel actually supports (see `DeviceMetadata.colours`). */
20
- function nearestColour(r, g, b, supported) {
19
+ /** Below this, a pixel counts as "blank" rather than whatever colour its (possibly meaningless, for a fully transparent pixel) RGB happens to hold. */
20
+ const TRANSPARENT_ALPHA_THRESHOLD = 128;
21
+ /**
22
+ * Mirrors the reference driver's `from_pillow` nearest-colour decision tree, then maps the result
23
+ * down onto whatever colours this particular device's panel actually supports (see
24
+ * `DeviceMetadata.colours`). A mostly-transparent pixel - undrawn SVG canvas, e.g. a template resized
25
+ * without its background rect following - classifies as white (blank paper), not the RGB-thresholds'
26
+ * own default of black: resvg-wasm leaves transparent pixels at RGB (0,0,0), and without this check
27
+ * that reads as ink-black rather than the blank label surface it actually represents.
28
+ */
29
+ function nearestColour(r, g, b, a, supported) {
21
30
  let colour = "black";
22
- if (r > 150 && g > 150 && b > 150)
31
+ if (a < TRANSPARENT_ALPHA_THRESHOLD)
32
+ colour = "white";
33
+ else if (r > 150 && g > 150 && b > 150)
23
34
  colour = "white";
24
35
  else if (r > 150 && g > 100 && b < 80)
25
36
  colour = "yellow";
@@ -65,5 +76,5 @@ function encodeBitmap(bitmap, metadata) {
65
76
  }
66
77
  function samplePixel(bitmap, x, y, supported) {
67
78
  const offset = (y * bitmap.width + x) * 4;
68
- return nearestColour(bitmap.data[offset], bitmap.data[offset + 1], bitmap.data[offset + 2], supported);
79
+ return nearestColour(bitmap.data[offset], bitmap.data[offset + 1], bitmap.data[offset + 2], bitmap.data[offset + 3], supported);
69
80
  }
@@ -87,7 +87,7 @@ class ZhsunycoDriver {
87
87
  const challenge = await authChar.readValue();
88
88
  await authChar.writeValueWithoutResponse((0, protocol_1.authResponse)(challenge, aesKey));
89
89
  await (0, bleDiscovery_1.sleep)(AUTH_SETTLE_DELAY_MS);
90
- const framed = (0, reframe_1.reframeBitmap)(bitmap, metadata.width, metadata.height - metadata.voffset, config.reframe ?? "fixed");
90
+ const framed = (0, reframe_1.reframeBitmap)(bitmap, metadata.width, metadata.height - metadata.voffset, config.reframe ?? "crop");
91
91
  const pixelData = (0, encode_1.encodeBitmap)(framed, metadata);
92
92
  for (let offset = 0; offset < pixelData.length; offset += UPLOAD_CHUNK_SIZE) {
93
93
  const chunk = pixelData.subarray(offset, offset + UPLOAD_CHUNK_SIZE);
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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
+ }
package/dist/plugin.js CHANGED
@@ -6,6 +6,7 @@ const registry_1 = require("./devices/registry");
6
6
  const zhsunyco_1 = require("./devices/zhsunyco");
7
7
  const gicisky_1 = require("./devices/gicisky");
8
8
  const discoveryCoordinator_1 = require("./devices/discoveryCoordinator");
9
+ const bleDiscovery_1 = require("./devices/bleDiscovery");
9
10
  const discoveredDevicesStore_1 = require("./devices/discoveredDevicesStore");
10
11
  const repaintScheduler_1 = require("./repaintScheduler");
11
12
  /** Mirrors signalk-bluetti-plugin's convention: scan briefly, report finds via plugin status for the user to copy-paste. */
@@ -36,6 +37,7 @@ function createPlugin(app) {
36
37
  (0, registry_1.registerDriver)(new zhsunyco_1.ZhsunycoDriver());
37
38
  (0, registry_1.registerDriver)(new gicisky_1.GiciskyDriver());
38
39
  let scheduler;
40
+ let stopped = false;
39
41
  const plugin = {
40
42
  id: "signalk-einklabel-plugin",
41
43
  name: "eInk ESL (Electronic Shelf Label)",
@@ -52,12 +54,22 @@ function createPlugin(app) {
52
54
  };
53
55
  app.debug(`starting with ${pluginConfig.devices.length} configured device(s)`);
54
56
  (0, config_1.healNestedConfig)(app);
55
- if (pluginConfig.scanOnStart) {
56
- void runStartupScan(app, pluginConfig.scanDurationSeconds);
57
- }
58
- scheduler = (0, repaintScheduler_1.startRepaintScheduler)(app, pluginConfig);
57
+ stopped = false;
58
+ // Waits (with backoff, indefinitely on Linux) for a BLE adapter before the startup scan or the
59
+ // repaint scheduler touch BLE at all - see `waitForAdapter`'s doc comment on the boot-time race
60
+ // this covers. `stopped` is checked after, not just passed as `cancelled`, since the wait can
61
+ // also resolve `true` on its own right as `stop()` runs.
62
+ void (0, bleDiscovery_1.waitForAdapter)((message) => app.debug(message), () => stopped).then(() => {
63
+ if (stopped)
64
+ return;
65
+ if (pluginConfig.scanOnStart) {
66
+ void runStartupScan(app, pluginConfig.scanDurationSeconds);
67
+ }
68
+ scheduler = (0, repaintScheduler_1.startRepaintScheduler)(app, pluginConfig);
69
+ });
59
70
  },
60
71
  stop() {
72
+ stopped = true;
61
73
  scheduler?.stop();
62
74
  scheduler = undefined;
63
75
  app.debug("stopped");
@@ -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
+ }