@rhizomatics/signalk-einklabel-plugin 1.3.0-beta1 → 1.3.0-beta11

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.
@@ -15,6 +15,8 @@ export declare const WOLINK_CHARACTERISTIC_UUIDS: {
15
15
  export declare const COMMAND: {
16
16
  readonly uploadBlock: 42240;
17
17
  readonly refreshUncompressed: 42241;
18
+ /** Refresh after uploading a `compressWolinkBlocks` payload rather than the raw buffer. */
19
+ readonly refreshCompressed: 42242;
18
20
  };
19
21
  export interface AdvertisedDeviceInfo {
20
22
  pid: number;
@@ -25,6 +25,8 @@ exports.WOLINK_CHARACTERISTIC_UUIDS = {
25
25
  exports.COMMAND = {
26
26
  uploadBlock: 0xa500,
27
27
  refreshUncompressed: 0xa501,
28
+ /** Refresh after uploading a `compressWolinkBlocks` payload rather than the raw buffer. */
29
+ refreshCompressed: 0xa502,
28
30
  };
29
31
  /** Decodes the 8-byte header shared by the advertising mirror and config characteristic. */
30
32
  function decodeAdvertisedInfo(data) {
package/dist/plugin.js CHANGED
@@ -9,6 +9,7 @@ const discoveryCoordinator_1 = require("./devices/discoveryCoordinator");
9
9
  const bleDiscovery_1 = require("./devices/bleDiscovery");
10
10
  const discoveredDevicesStore_1 = require("./devices/discoveredDevicesStore");
11
11
  const repaintScheduler_1 = require("./repaintScheduler");
12
+ const pluginVersion_1 = require("./pluginVersion");
12
13
  /** Mirrors signalk-bluetti-plugin's convention: scan briefly, report finds via plugin status for the user to copy-paste. */
13
14
  async function runStartupScan(app, durationSeconds, useBleApi) {
14
15
  const alreadyRunning = (0, discoveryCoordinator_1.scanInProgressSince)();
@@ -41,6 +42,7 @@ function createPlugin(app) {
41
42
  // enough to have added it - checked at runtime rather than trusted from the type.
42
43
  const bleApiAvailable = !!app.bleApi;
43
44
  let scheduler;
45
+ let stopDiscoveryListener;
44
46
  let stopped = false;
45
47
  const plugin = {
46
48
  id: "signalk-einklabel-plugin",
@@ -54,10 +56,10 @@ function createPlugin(app) {
54
56
  start(config) {
55
57
  const pluginConfig = {
56
58
  ...(0, config_1.defaultConfig)(),
57
- ...config,
59
+ ...(0, config_1.migrateConfig)(config).config,
58
60
  };
59
61
  app.debug(`starting with ${pluginConfig.devices.length} configured device(s)`);
60
- (0, config_1.healNestedConfig)(app);
62
+ (0, config_1.healStoredConfig)(app);
61
63
  stopped = false;
62
64
  const useBleApi = pluginConfig.useBleApi && bleApiAvailable;
63
65
  if (pluginConfig.useBleApi && !bleApiAvailable) {
@@ -73,6 +75,11 @@ function createPlugin(app) {
73
75
  scheduler = (0, repaintScheduler_1.startRepaintScheduler)(app, pluginConfig);
74
76
  };
75
77
  if (useBleApi) {
78
+ // BLE Manager already runs its own continuous scan server-side (that's what populates its own
79
+ // admin UI device list) - unlike direct BlueZ access below, discovery here doesn't need a
80
+ // bounded scan window at all, so this listens for as long as the plugin runs rather than only
81
+ // during `scanOnStart`/an on-demand `ALL_DEVICES` scan. See its own doc comment.
82
+ stopDiscoveryListener = (0, discoveryCoordinator_1.startBleApiDiscoveryListener)(app, pluginVersion_1.PLUGIN_NAME);
76
83
  // The server/provider governs its own local-adapter readiness once BLE Manager mode owns
77
84
  // `hci0` (or has none at all, behind a remote gateway) - waiting on a *local* BlueZ adapter
78
85
  // here would be waiting on something this mode may never even need.
@@ -90,6 +97,8 @@ function createPlugin(app) {
90
97
  stopped = true;
91
98
  scheduler?.stop();
92
99
  scheduler = undefined;
100
+ stopDiscoveryListener?.();
101
+ stopDiscoveryListener = undefined;
93
102
  app.debug("stopped");
94
103
  },
95
104
  };
@@ -0,0 +1,11 @@
1
+ import { Bitmap } from "./types";
2
+ /**
3
+ * Flips the rendered image before it's encoded for the panel - for hardware whose RAM layout is
4
+ * mirrored relative to what a driver's encoder assumes (the Wolink HA integration found the 2.9"
5
+ * and 3.5" panels differ from the 3.7" this plugin's zhsunyco encoder was confirmed against), or a
6
+ * label that's simply mounted upside down (`"both"` is a 180° rotation).
7
+ */
8
+ export type MirrorMode = "none" | "horizontal" | "vertical" | "both";
9
+ export declare const MIRROR_MODES: MirrorMode[];
10
+ /** Returns `bitmap` unchanged for `"none"`, otherwise a flipped copy. */
11
+ export declare function mirrorBitmap(bitmap: Bitmap, mode: MirrorMode): Bitmap;
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MIRROR_MODES = void 0;
4
+ exports.mirrorBitmap = mirrorBitmap;
5
+ exports.MIRROR_MODES = ["none", "horizontal", "vertical", "both"];
6
+ /** Returns `bitmap` unchanged for `"none"`, otherwise a flipped copy. */
7
+ function mirrorBitmap(bitmap, mode) {
8
+ if (mode === "none") {
9
+ return bitmap;
10
+ }
11
+ const { width, height } = bitmap;
12
+ const flipX = mode === "horizontal" || mode === "both";
13
+ const flipY = mode === "vertical" || mode === "both";
14
+ const data = new Uint8Array(bitmap.data.length);
15
+ for (let y = 0; y < height; y++) {
16
+ const srcY = flipY ? height - 1 - y : y;
17
+ for (let x = 0; x < width; x++) {
18
+ const srcX = flipX ? width - 1 - x : x;
19
+ const srcOffset = (srcY * width + srcX) * 4;
20
+ data.set(bitmap.data.subarray(srcOffset, srcOffset + 4), (y * width + x) * 4);
21
+ }
22
+ }
23
+ return { width, height, data };
24
+ }
@@ -23,6 +23,8 @@ const pluginVersion_1 = require("./pluginVersion");
23
23
  const httpJson_1 = require("./httpJson");
24
24
  const INTERVAL_POLL_MS = 60000;
25
25
  const SUBSCRIPTION_DEBOUNCE_MS = 2000;
26
+ /** Pause between paint attempts - see `withRetries`' `delayMs`. */
27
+ const PAINT_RETRY_DELAY_MS = 5000;
26
28
  const RESOURCES_API_PATH = "/signalk/v2/api/resources";
27
29
  /**
28
30
  * The most recent wall-clock instant at or before `now` that an `interval`-triggered device's own
@@ -173,7 +175,7 @@ async function assembleRawContext(app, apiUrl, bindings) {
173
175
  }
174
176
  function clearForceRepaint(app, friendlyName) {
175
177
  const current = (0, config_1.readCurrentConfig)(app);
176
- const devices = (current.devices ?? []).map((device) => device.friendlyName === friendlyName ? { ...device, forceRepaint: false } : device);
178
+ const devices = (current.devices ?? []).map((device) => device.friendlyName === friendlyName ? { ...device, advanced: { ...device.advanced, forceRepaint: false } } : device);
177
179
  app.savePluginOptions({ ...current, devices }, (err) => {
178
180
  if (err)
179
181
  app.debug(`failed to clear forceRepaint for "${friendlyName}": ${err.message}`);
@@ -307,7 +309,7 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
307
309
  // scheduled tick" (e.g. a regenerated forecast) is the whole point of one - which is exactly why a
308
310
  // provider-backed device should use `repaintTrigger: "interval"`, not `subscription`, for
309
311
  // cost/battery reasons (each repaint may be a paid API call on the provider's side).
310
- if (!provider && !templateChanged && !dataChanged && !device.forceRepaint) {
312
+ if (!provider && !templateChanged && !dataChanged && !device.advanced?.forceRepaint) {
311
313
  app.debug(`${label}: data unchanged, skipping repaint`);
312
314
  return;
313
315
  }
@@ -325,7 +327,7 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
325
327
  ? await provider.render({ templateName: device.templateName, context: renderContext, width, height, colours: metadata.colours })
326
328
  : await renderer.render(templatePath, renderContext, width, height, templatesDir, config_1.BUNDLED_TEMPLATES_DIR);
327
329
  succeeded = true;
328
- repaintReason = device.forceRepaint
330
+ repaintReason = device.advanced?.forceRepaint
329
331
  ? "forced"
330
332
  : provider
331
333
  ? "provider-rendered"
@@ -344,16 +346,29 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
344
346
  });
345
347
  bitmap = await renderer.render(fallbackPath, { meta: { repainted: new Date().toISOString(), plugin_version: pluginVersion_1.PLUGIN_VERSION, description: device.description ?? "" } }, width, height, templatesDir, config_1.BUNDLED_TEMPLATES_DIR);
346
348
  }
347
- const connectTimeoutMs = config.paintConnectTimeoutSeconds * 1000;
348
- await (0, bleDiscovery_1.withRetries)(config.paintRetries, async (attempt) => {
349
- if (attempt > 1) {
350
- app.debug(`${label}: attempting paint ${attempt}/${config.paintRetries}`);
351
- }
349
+ const connectTimeoutMs = (device.advanced?.paintConnectTimeoutSeconds ?? config.paintConnectTimeoutSeconds) * 1000;
350
+ const gattBackend = config.useBleApi && app.bleApi ? (0, bleBackend_1.bleApiBackend)(app.bleApi, pluginVersion_1.PLUGIN_NAME) : undefined;
351
+ const attempts = Math.max(1, device.advanced?.paintRetries ?? config.paintRetries);
352
+ const paintWithRetries = () => (0, bleDiscovery_1.withRetries)(attempts, async (attempt) => {
353
+ app.debug(`${label}: attempting paint ${attempt}/${attempts}`);
352
354
  const startedAt = Date.now();
353
- const gattBackend = config.useBleApi && app.bleApi ? (0, bleBackend_1.bleApiBackend)(app.bleApi, pluginVersion_1.PLUGIN_NAME) : undefined;
354
- await driver.paint(bitmap, { address, aesKey: device.aesKey, connectTimeoutMs, reframe: device.reframe, gattBackend });
355
+ await driver.paint(bitmap, {
356
+ address,
357
+ pid: target.pid,
358
+ aesKey: device.advanced?.aesKey,
359
+ connectTimeoutMs,
360
+ reframe: device.reframe,
361
+ mirror: device.advanced?.mirror,
362
+ compress: device.advanced?.compress,
363
+ compressionFormat: device.advanced?.compressionFormat,
364
+ gattBackend,
365
+ });
355
366
  paintDurationMs = Date.now() - startedAt;
367
+ }, {
368
+ delayMs: PAINT_RETRY_DELAY_MS,
369
+ onError: (err, attempt) => app.debug(`${label}: paint attempt ${attempt}/${attempts} failed: ${err.message}`),
356
370
  });
371
+ await (gattBackend ? (0, bleBackend_1.exclusiveBleManagerAccess)(paintWithRetries) : paintWithRetries());
357
372
  (0, discoveredDevicesStore_1.touchDiscoveredDevice)(app, { address, vendor: target.vendor, pid: target.pid, hwVersion: target.hwVersion, metadata });
358
373
  // Only a successful render counts as "repainted" for dedup/catch-up purposes - a failure must not be
359
374
  // recorded here (see this function's doc comment on why).
@@ -393,7 +408,7 @@ function startRepaintScheduler(app, config) {
393
408
  // A single `forceRepaint` flag covers every target under `ALL_DEVICES` too - only clear it once
394
409
  // every target has actually succeeded, so a target that failed still gets forced again next time
395
410
  // instead of quietly reverting to ordinary hash-based dedup.
396
- if (device.forceRepaint && results.every((result) => result.status === "fulfilled")) {
411
+ if (device.advanced?.forceRepaint && results.every((result) => result.status === "fulfilled")) {
397
412
  clearForceRepaint(app, device.friendlyName);
398
413
  }
399
414
  };
@@ -440,7 +455,7 @@ function startRepaintScheduler(app, config) {
440
455
  // still avoids a redundant paint once targets are resolved.
441
456
  const startupCheckTimer = setTimeout(() => {
442
457
  for (const device of config.devices) {
443
- if (device.repaintTrigger === "interval" && !device.forceRepaint && device.device !== config_1.ALL_DEVICES) {
458
+ if (device.repaintTrigger === "interval" && !device.advanced?.forceRepaint && device.device !== config_1.ALL_DEVICES) {
444
459
  const hours = device.intervalHours ?? 1;
445
460
  const minute = device.intervalMinute ?? 0;
446
461
  const dueSlot = mostRecentScheduledSlot(new Date(), hours, minute);
package/package.json CHANGED
@@ -1,20 +1,26 @@
1
1
  {
2
2
  "name": "@rhizomatics/signalk-einklabel-plugin",
3
- "version": "1.3.0-beta1",
3
+ "version": "1.3.0-beta11",
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
+ "e-ink",
9
+ "e-paper",
8
10
  "eink",
11
+ "epaper",
9
12
  "esl",
10
13
  "gicisky",
11
14
  "instrument",
15
+ "marine",
16
+ "shelf-label",
12
17
  "signalk",
13
18
  "signalk-category-hardware",
14
19
  "signalk-category-instruments",
15
20
  "signalk-node-server-plugin",
16
21
  "tides",
17
22
  "watch-schedule",
23
+ "wolink",
18
24
  "zhsunyco"
19
25
  ],
20
26
  "homepage": "https://signalk-einklabel.rhizomatics.org.uk/",
@@ -56,7 +62,7 @@
56
62
  "@fontsource/roboto-serif": "^5.3.0",
57
63
  "@naugehyde/node-ble": "^1.13.5",
58
64
  "@resvg/resvg-wasm": "^2.6.2",
59
- "@xmldom/xmldom": "^0.9.10",
65
+ "@xmldom/xmldom": "^0.9.12",
60
66
  "commander": "^15.0.0",
61
67
  "form-data": "^4.0.6",
62
68
  "luxon": "^3.7.2",
@@ -65,17 +71,17 @@
65
71
  },
66
72
  "devDependencies": {
67
73
  "@signalk/server-api": "^2.32.0",
68
- "@types/luxon": "^3.7.1",
69
- "@types/node": "^26.4.0",
74
+ "@types/luxon": "^3.7.5",
75
+ "@types/node": "^26.6.0",
70
76
  "@types/pngjs": "^6.0.5",
71
- "oxfmt": "^0.66.0",
72
- "oxlint": "^1.81.0",
77
+ "oxfmt": "^0.70.0",
78
+ "oxlint": "^1.85.0",
73
79
  "oxlint-tsgolint": "^7.0.2001",
74
80
  "ts-node": "^10.9.2",
75
81
  "typescript": "^5.5.0"
76
82
  },
77
83
  "engines": {
78
- "node": ">=18"
84
+ "node": ">=22"
79
85
  },
80
86
  "signalk": {
81
87
  "displayName": "eInk Label Displays",
@@ -87,9 +93,9 @@
87
93
  "docs/assets/screenshots/label_config.png"
88
94
  ],
89
95
  "recommends": [
96
+ "@rhizomatics/signalk-einklabel-genai-plugin",
90
97
  "signalk-watch-schedule",
91
- "signalk-tides",
92
- "@rhizomatics/signalk-einklabel-genai-plugin"
98
+ "signalk-tides"
93
99
  ]
94
100
  }
95
101
  }