@rhizomatics/signalk-einklabel-plugin 1.2.1 → 1.2.2

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,11 @@
1
+ # 1.2.2
2
+
3
+ - Fully implemented the backoff and retry logic on failed paint, by default after 30s
4
+ - Auto reframe now happens in main plugin as well as CLI
5
+ - Auto reframe now defaults to `crop` for CLI, can be overidden back to `fixed`
6
+ - Handle Gicisky stale cache paint failures by prompting a re-scan when necessary
7
+ - Improve background default for `crop` reframing
8
+
1
9
  # 1.2.1
2
10
 
3
11
  - Additional tide template for 296x128
package/README.md CHANGED
@@ -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);
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");
@@ -1,11 +1,13 @@
1
1
  import { Bitmap } from "./types";
2
2
  /**
3
- * How a rendered bitmap is fitted onto a device's actual panel size when the two don't match -
4
- * `"fixed"` (the default, and the only behaviour before this existed) makes no change, leaving each
5
- * vendor driver's own exact-size check in `encodeBitmap` to reject the mismatch; `"scale"` scales
6
- * the source onto the target dimensions independently per axis (not preserving aspect ratio);
7
- * `"crop"` keeps source pixels 1:1, placing them from the top-left and either truncating whatever
8
- * doesn't fit (source bigger than target) or leaving the extra target space blank (source smaller).
3
+ * How a rendered bitmap is fitted onto a device's actual panel size when the two don't match - see
4
+ * `VendorDeviceConfig.reframe` for where the default (`"crop"`) actually lives; this type has none
5
+ * of its own. `"fixed"` (the only behaviour that existed before this type did) makes no change,
6
+ * leaving each vendor driver's own exact-size check in `encodeBitmap` to reject the mismatch;
7
+ * `"scale"` scales the source onto the target dimensions independently per axis (not preserving
8
+ * aspect ratio); `"crop"` keeps source pixels 1:1, placing them from the top-left and either
9
+ * truncating whatever doesn't fit (source bigger than target) or leaving the extra target space
10
+ * blank (source smaller).
9
11
  */
10
12
  export type ReframeMode = "fixed" | "scale" | "crop";
11
13
  /** Applies `mode` to fit `bitmap` onto a `targetWidth`x`targetHeight` panel - a no-op whenever the size already matches, regardless of mode. */
@@ -349,7 +349,7 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
349
349
  app.debug(`${label}: attempting paint ${attempt}/${config.paintRetries}`);
350
350
  }
351
351
  const startedAt = Date.now();
352
- await driver.paint(bitmap, { address, aesKey: device.aesKey, connectTimeoutMs });
352
+ await driver.paint(bitmap, { address, aesKey: device.aesKey, connectTimeoutMs, reframe: device.reframe });
353
353
  paintDurationMs = Date.now() - startedAt;
354
354
  });
355
355
  (0, discoveredDevicesStore_1.touchDiscoveredDevice)(app, { address, vendor: target.vendor, pid: target.pid, hwVersion: target.hwVersion, metadata });
package/package.json CHANGED
@@ -1,21 +1,21 @@
1
1
  {
2
2
  "name": "@rhizomatics/signalk-einklabel-plugin",
3
- "version": "1.2.1",
3
+ "version": "1.2.2",
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",
7
7
  "display",
8
8
  "eink",
9
9
  "esl",
10
+ "gicisky",
10
11
  "instrument",
11
12
  "signalk",
12
- "zhsunyco",
13
- "gicisky",
14
13
  "signalk-category-hardware",
15
14
  "signalk-category-instruments",
16
15
  "signalk-node-server-plugin",
17
16
  "tides",
18
- "watch-schedule"
17
+ "watch-schedule",
18
+ "zhsunyco"
19
19
  ],
20
20
  "homepage": "https://signalk-einklabel.rhizomatics.org.uk/",
21
21
  "bugs": {
@@ -66,7 +66,7 @@
66
66
  "devDependencies": {
67
67
  "@signalk/server-api": "^2.31.0",
68
68
  "@types/luxon": "^3.7.1",
69
- "@types/node": "^20.14.0",
69
+ "@types/node": "^26.4.0",
70
70
  "@types/pngjs": "^6.0.5",
71
71
  "oxfmt": "^0.66.0",
72
72
  "oxlint": "^1.81.0",