@rhizomatics/signalk-einklabel-plugin 1.2.3 → 1.3.0-beta10

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.
Files changed (36) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +97 -9
  3. package/dist/cli/index.d.ts +4 -1
  4. package/dist/cli/index.js +30 -6
  5. package/dist/config.d.ts +53 -14
  6. package/dist/config.js +172 -41
  7. package/dist/devices/bleBackend.d.ts +49 -0
  8. package/dist/devices/bleBackend.js +268 -0
  9. package/dist/devices/bleDiscovery.d.ts +20 -2
  10. package/dist/devices/bleDiscovery.js +101 -3
  11. package/dist/devices/discoveryCoordinator.d.ts +25 -6
  12. package/dist/devices/discoveryCoordinator.js +148 -9
  13. package/dist/devices/gattConnection.d.ts +9 -0
  14. package/dist/devices/gattConnection.js +2 -0
  15. package/dist/devices/gicisky/compression.d.ts +22 -9
  16. package/dist/devices/gicisky/compression.js +128 -13
  17. package/dist/devices/gicisky/encode.d.ts +1 -1
  18. package/dist/devices/gicisky/encode.js +2 -2
  19. package/dist/devices/gicisky/index.d.ts +7 -2
  20. package/dist/devices/gicisky/index.js +118 -96
  21. package/dist/devices/gicisky/layout.d.ts +13 -1
  22. package/dist/devices/gicisky/layout.js +21 -0
  23. package/dist/devices/types.d.ts +48 -6
  24. package/dist/devices/types.js +2 -0
  25. package/dist/devices/zhsunyco/compression.d.ts +1 -0
  26. package/dist/devices/zhsunyco/compression.js +30 -0
  27. package/dist/devices/zhsunyco/index.d.ts +8 -2
  28. package/dist/devices/zhsunyco/index.js +92 -82
  29. package/dist/devices/zhsunyco/protocol.d.ts +2 -0
  30. package/dist/devices/zhsunyco/protocol.js +2 -0
  31. package/dist/plugin.js +38 -11
  32. package/dist/render/mirror.d.ts +11 -0
  33. package/dist/render/mirror.js +24 -0
  34. package/dist/repaintScheduler.js +29 -12
  35. package/docs/assets/images/mini_tidal_clock.png +0 -0
  36. package/package.json +3 -3
@@ -2,19 +2,21 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ZhsunycoDriver = void 0;
4
4
  const bleDiscovery_1 = require("../bleDiscovery");
5
+ const bleBackend_1 = require("../bleBackend");
5
6
  const pluginVersion_1 = require("../../pluginVersion");
6
7
  const metadata_1 = require("./metadata");
7
8
  const encode_1 = require("./encode");
8
9
  const reframe_1 = require("../../render/reframe");
10
+ const mirror_1 = require("../../render/mirror");
11
+ const compression_1 = require("./compression");
9
12
  const protocol_1 = require("./protocol");
10
13
  /** node-ble has no MTU API; this matches the reference driver's mtu(247)-9 default. */
11
14
  const UPLOAD_CHUNK_SIZE = 238;
12
15
  const CHUNK_WRITE_DELAY_MS = 20;
13
16
  const AUTH_SETTLE_DELAY_MS = 500;
14
17
  const STATUS_WAIT_TIMEOUT_MS = 60000;
15
- const DEVICE_DISCOVERY_TIMEOUT_MS = 30000;
16
- /** Used while identifying a device during a scan - kept short since a scan may be enumerating several devices. */
17
- const SCAN_CONNECT_TIMEOUT_MS = 10000;
18
+ /** Bounds `readDeviceDetails`' whole connect+read attempt during a scan - see its doc comment. */
19
+ const IDENTIFY_READ_TIMEOUT_MS = 10000;
18
20
  /** Fallback when `VendorDeviceConfig.connectTimeoutMs` is omitted (e.g. a bare CLI `paint` call) - matches `defaultConfig().paintConnectTimeoutSeconds`. */
19
21
  const DEFAULT_PAINT_CONNECT_TIMEOUT_MS = 60000;
20
22
  class ZhsunycoDriver {
@@ -31,9 +33,9 @@ class ZhsunycoDriver {
31
33
  supportedDevices() {
32
34
  return metadata_1.ZHSUNYCO_PID_METADATA;
33
35
  }
34
- async identifyDevice(device, address, name, manufacturerId, manufacturerData) {
36
+ async identifyDevice({ address, name, manufacturerId, manufacturerData, rssi, }, connect) {
35
37
  const advertisedInfo = manufacturerData ? (0, protocol_1.decodeAdvertisedInfo)(manufacturerData) : undefined;
36
- const { info, batteryMv } = await readDeviceDetails(device, address, advertisedInfo);
38
+ const { info, batteryMv } = await readDeviceDetails(address, advertisedInfo, connect);
37
39
  return {
38
40
  address,
39
41
  name,
@@ -43,99 +45,96 @@ class ZhsunycoDriver {
43
45
  metadata: info ? this.metadataForPid(info.pid, info.hwVersion) : undefined,
44
46
  manufacturerId,
45
47
  batteryMv,
46
- rssi: await device
47
- .getRSSI()
48
- .then((value) => (value === undefined ? undefined : Number(value)))
49
- .catch(() => undefined),
48
+ rssi,
50
49
  };
51
50
  }
52
51
  async paint(bitmap, config) {
53
52
  const aesKey = (0, protocol_1.resolveAesKey)(config.aesKey);
54
- const { bluetooth, destroy } = (0, bleDiscovery_1.createBluetooth)();
53
+ const backend = config.gattBackend ?? (0, bleBackend_1.nodeBleBackend)();
54
+ const conn = await backend.connectGatt(config.address, config.connectTimeoutMs ?? DEFAULT_PAINT_CONNECT_TIMEOUT_MS);
55
55
  try {
56
- const adapter = await bluetooth.defaultAdapter();
57
- const device = await (0, bleDiscovery_1.getOrDiscoverDevice)(adapter, config.address, DEVICE_DISCOVERY_TIMEOUT_MS);
58
- await (0, bleDiscovery_1.connectWithTimeout)(device, config.connectTimeoutMs ?? DEFAULT_PAINT_CONNECT_TIMEOUT_MS);
56
+ const info = (0, protocol_1.decodeAdvertisedInfo)(await conn.read(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.config));
57
+ if (!info) {
58
+ throw new Error("zhsunyco device did not return valid config data");
59
+ }
60
+ const metadata = config.modelOverride ? { pid: info.pid, ...config.modelOverride } : this.metadataForPid(info.pid, info.hwVersion);
61
+ if (!metadata) {
62
+ throw new Error(`zhsunyco device reports unrecognised PID 0x${info.pid.toString(16).padStart(4, "0")} - ` +
63
+ "pass --width/--height/--voffset/--colours to describe it manually");
64
+ }
65
+ const statusReceived = new Promise((resolve, reject) => {
66
+ conn
67
+ .startNotifications(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.status, (data) => {
68
+ const { errorCode } = (0, protocol_1.decodeStatus)(data);
69
+ if (errorCode === 0) {
70
+ resolve();
71
+ }
72
+ else {
73
+ reject(new Error(`zhsunyco device reported error 0x${errorCode.toString(16).padStart(2, "0")} after refresh`));
74
+ }
75
+ })
76
+ .catch(reject);
77
+ });
59
78
  try {
60
- const gatt = await device.gatt();
61
- const service = await gatt.getPrimaryService(protocol_1.WOLINK_SERVICE_UUID);
62
- const dataChar = await service.getCharacteristic(protocol_1.WOLINK_CHARACTERISTIC_UUIDS.data);
63
- const configChar = await service.getCharacteristic(protocol_1.WOLINK_CHARACTERISTIC_UUIDS.config);
64
- const authChar = await service.getCharacteristic(protocol_1.WOLINK_CHARACTERISTIC_UUIDS.authenticate);
65
- const statusChar = await service.getCharacteristic(protocol_1.WOLINK_CHARACTERISTIC_UUIDS.status);
66
- const info = (0, protocol_1.decodeAdvertisedInfo)(await configChar.readValue());
67
- if (!info) {
68
- throw new Error("zhsunyco device did not return valid config data");
69
- }
70
- const metadata = config.modelOverride ? { pid: info.pid, ...config.modelOverride } : this.metadataForPid(info.pid, info.hwVersion);
71
- if (!metadata) {
72
- throw new Error(`zhsunyco device reports unrecognised PID 0x${info.pid.toString(16).padStart(4, "0")} - ` +
73
- "pass --width/--height/--voffset/--colours to describe it manually");
74
- }
75
- const statusReceived = new Promise((resolve, reject) => {
76
- statusChar.once("valuechanged", (data) => {
77
- const { errorCode } = (0, protocol_1.decodeStatus)(data);
78
- if (errorCode === 0) {
79
- resolve();
80
- }
81
- else {
82
- reject(new Error(`zhsunyco device reported error 0x${errorCode.toString(16).padStart(2, "0")} after refresh`));
83
- }
84
- });
85
- });
86
- await statusChar.startNotifications();
87
- const challenge = await authChar.readValue();
88
- await authChar.writeValueWithoutResponse((0, protocol_1.authResponse)(challenge, aesKey));
79
+ const challenge = await conn.read(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.authenticate);
80
+ await conn.write(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.authenticate, (0, protocol_1.authResponse)(challenge, aesKey), false);
89
81
  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 ?? "crop");
82
+ const framed = (0, mirror_1.mirrorBitmap)((0, reframe_1.reframeBitmap)(bitmap, metadata.width, metadata.height - metadata.voffset, config.reframe ?? "crop"), config.mirror ?? "none");
91
83
  const pixelData = (0, encode_1.encodeBitmap)(framed, metadata);
92
- for (let offset = 0; offset < pixelData.length; offset += UPLOAD_CHUNK_SIZE) {
93
- const chunk = pixelData.subarray(offset, offset + UPLOAD_CHUNK_SIZE);
94
- await dataChar.writeValueWithResponse(Buffer.concat([(0, protocol_1.commandHeader)(protocol_1.COMMAND.uploadBlock, offset), chunk]));
84
+ const compress = config.compress ?? true;
85
+ // Upload offsets and the refresh length both count bytes of whatever's actually sent - the
86
+ // compressed payload when compressing, not the raw buffer it inflates back to.
87
+ const payload = compress ? (0, compression_1.compressWolinkBlocks)(pixelData) : pixelData;
88
+ for (let offset = 0; offset < payload.length; offset += UPLOAD_CHUNK_SIZE) {
89
+ const chunk = payload.subarray(offset, offset + UPLOAD_CHUNK_SIZE);
90
+ await conn.write(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.data, Buffer.concat([(0, protocol_1.commandHeader)(protocol_1.COMMAND.uploadBlock, offset), chunk]), true);
95
91
  await (0, bleDiscovery_1.sleep)(CHUNK_WRITE_DELAY_MS);
96
92
  }
97
- await dataChar.writeValueWithResponse((0, protocol_1.commandHeader)(protocol_1.COMMAND.refreshUncompressed, pixelData.length));
98
- await Promise.race([statusReceived, (0, bleDiscovery_1.sleep)(STATUS_WAIT_TIMEOUT_MS)]);
93
+ await conn.write(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.data, (0, protocol_1.commandHeader)(compress ? protocol_1.COMMAND.refreshCompressed : protocol_1.COMMAND.refreshUncompressed, payload.length), true);
94
+ // `Promise.race` can't cancel its loser, so once `statusReceived` settles (the common case)
95
+ // this timer would otherwise sit alive for the rest of its 60s regardless - see the identical
96
+ // reasoning on `readDeviceDetails`'s own race, below.
97
+ let statusTimer;
98
+ const statusTimeout = new Promise((resolve) => {
99
+ statusTimer = setTimeout(resolve, STATUS_WAIT_TIMEOUT_MS);
100
+ });
101
+ try {
102
+ await Promise.race([statusReceived, statusTimeout]);
103
+ }
104
+ finally {
105
+ clearTimeout(statusTimer);
106
+ }
99
107
  }
100
108
  finally {
101
- await device.disconnect();
109
+ await conn.stopNotifications(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.status).catch(() => { });
102
110
  }
103
111
  }
104
112
  finally {
105
- destroy();
113
+ await conn.disconnect();
106
114
  }
107
115
  }
108
116
  }
109
117
  exports.ZhsunycoDriver = ZhsunycoDriver;
110
118
  /**
111
119
  * Battery level needs a connection regardless, so reuse it to also fill in the PID/hwVersion
112
- * when the advertisement didn't carry decodable manufacturer data - BlueZ's cached
113
- * advertisement for a device matched purely by its name prefix can lack that, which would
114
- * otherwise leave a real, nearby device's model (and so its entry in the config UI's
115
- * device picker - see `deviceOptions()` in `config.ts`) silently missing. Reads the same
116
- * config characteristic `paint()` reads, just to identify the device rather than to size a
120
+ * when the advertisement didn't carry decodable manufacturer data - a scan matched purely by name
121
+ * prefix can lack that, which would otherwise leave a real, nearby device's model (and so its entry
122
+ * in the config UI's device picker - see `deviceOptions()` in `config.ts`) silently missing. Reads
123
+ * the same config characteristic `paint()` reads, just to identify the device rather than to size a
117
124
  * render.
118
125
  */
119
- async function readDeviceDetails(device, address, advertisedInfo) {
126
+ async function readDeviceDetails(address, advertisedInfo, connect) {
120
127
  const fallback = { info: advertisedInfo, batteryMv: undefined };
121
128
  const read = async () => {
129
+ let conn;
122
130
  try {
123
- await (0, bleDiscovery_1.connectWithTimeout)(device, SCAN_CONNECT_TIMEOUT_MS);
124
- try {
125
- const gatt = await device.gatt();
126
- const service = await gatt.getPrimaryService(protocol_1.WOLINK_SERVICE_UUID);
127
- const batteryChar = await service.getCharacteristic(protocol_1.WOLINK_CHARACTERISTIC_UUIDS.battery);
128
- const batteryMv = (0, protocol_1.decodeBatteryMv)(await batteryChar.readValue());
129
- let info = advertisedInfo;
130
- if (!info) {
131
- const configChar = await service.getCharacteristic(protocol_1.WOLINK_CHARACTERISTIC_UUIDS.config);
132
- info = (0, protocol_1.decodeAdvertisedInfo)(await configChar.readValue());
133
- }
134
- return { info, batteryMv };
135
- }
136
- finally {
137
- await device.disconnect();
131
+ conn = await connect();
132
+ const batteryMv = (0, protocol_1.decodeBatteryMv)(await conn.read(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.battery));
133
+ let info = advertisedInfo;
134
+ if (!info) {
135
+ info = (0, protocol_1.decodeAdvertisedInfo)(await conn.read(protocol_1.WOLINK_SERVICE_UUID, protocol_1.WOLINK_CHARACTERISTIC_UUIDS.config));
138
136
  }
137
+ return { info, batteryMv };
139
138
  }
140
139
  catch (err) {
141
140
  // Swallowed rather than thrown - a device that refuses this connect (e.g. busy elsewhere,
@@ -145,16 +144,27 @@ async function readDeviceDetails(device, address, advertisedInfo) {
145
144
  console.error(`${pluginVersion_1.PLUGIN_NAME}: zhsunyco [${address}]: battery/config read failed: ${err.message}`);
146
145
  return fallback;
147
146
  }
147
+ finally {
148
+ await conn?.disconnect().catch(() => { });
149
+ }
148
150
  };
149
- // `connectWithTimeout` bounds the connect step itself, but a GATT call past that point (e.g.
150
- // `getPrimaryService`/`readValue`) has no timeout of its own either - race the whole read so
151
- // one unresponsive device can't stall the rest of the scan (see `plugin.ts`'s `scanInProgress`,
152
- // which otherwise stays set forever and silently skips every later scan).
153
- return Promise.race([
154
- read(),
155
- (0, bleDiscovery_1.sleep)(SCAN_CONNECT_TIMEOUT_MS * 3).then(() => {
156
- console.error(`${pluginVersion_1.PLUGIN_NAME}: zhsunyco [${address}]: battery/config read timed out after ${SCAN_CONNECT_TIMEOUT_MS * 2}ms`);
157
- return fallback;
158
- }),
159
- ]);
151
+ // A GATT call has no timeout of its own - race the whole read so one unresponsive device can't
152
+ // stall the rest of the scan (see `plugin.ts`'s `scanInProgress`, which otherwise stays set forever
153
+ // and silently skips every later scan). `Promise.race` alone can't cancel its loser, so a `read()`
154
+ // that settles fast (e.g. `connect` rejecting immediately because the device is busy elsewhere -
155
+ // exactly the case this function's own doc comment covers) would otherwise leave this timer running
156
+ // for the rest of its 30s regardless - harmless in the end, but needless to hold onto that long.
157
+ let timer;
158
+ const timeout = new Promise((resolve) => {
159
+ timer = setTimeout(() => {
160
+ console.error(`${pluginVersion_1.PLUGIN_NAME}: zhsunyco [${address}]: battery/config read timed out after ${IDENTIFY_READ_TIMEOUT_MS * 2}ms`);
161
+ resolve(fallback);
162
+ }, IDENTIFY_READ_TIMEOUT_MS * 3);
163
+ });
164
+ try {
165
+ return await Promise.race([read(), timeout]);
166
+ }
167
+ finally {
168
+ clearTimeout(timer);
169
+ }
160
170
  }
@@ -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,8 +9,9 @@ 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
- async function runStartupScan(app, durationSeconds) {
14
+ async function runStartupScan(app, durationSeconds, useBleApi) {
14
15
  const alreadyRunning = (0, discoveryCoordinator_1.scanInProgressSince)();
15
16
  if (alreadyRunning !== undefined) {
16
17
  const elapsedSeconds = ((Date.now() - alreadyRunning) / 1000).toFixed(0);
@@ -21,7 +22,7 @@ async function runStartupScan(app, durationSeconds) {
21
22
  app.setPluginStatus(`Scanning for ESL devices for ${durationSeconds}s...`);
22
23
  }
23
24
  const startedAt = Date.now();
24
- const { foundThisScan } = await (0, discoveryCoordinator_1.ensureScan)(app, durationSeconds).catch((err) => {
25
+ const { foundThisScan } = await (0, discoveryCoordinator_1.ensureScan)(app, durationSeconds, useBleApi).catch((err) => {
25
26
  app.debug(`startup scan failed: ${err.message}`);
26
27
  return { foundThisScan: [] };
27
28
  });
@@ -36,7 +37,12 @@ async function runStartupScan(app, durationSeconds) {
36
37
  function createPlugin(app) {
37
38
  (0, registry_1.registerDriver)(new zhsunyco_1.ZhsunycoDriver());
38
39
  (0, registry_1.registerDriver)(new gicisky_1.GiciskyDriver());
40
+ // Present on SignalK server >= 2.32.0 (the BLE Provider/Consumer API - "BLE Manager" in the admin
41
+ // UI); `ServerAPI`'s own type says this is always defined, but that's only true on servers new
42
+ // enough to have added it - checked at runtime rather than trusted from the type.
43
+ const bleApiAvailable = !!app.bleApi;
39
44
  let scheduler;
45
+ let stopDiscoveryListener;
40
46
  let stopped = false;
41
47
  const plugin = {
42
48
  id: "signalk-einklabel-plugin",
@@ -50,28 +56,49 @@ function createPlugin(app) {
50
56
  start(config) {
51
57
  const pluginConfig = {
52
58
  ...(0, config_1.defaultConfig)(),
53
- ...config,
59
+ ...(0, config_1.migrateConfig)(config).config,
54
60
  };
55
61
  app.debug(`starting with ${pluginConfig.devices.length} configured device(s)`);
56
- (0, config_1.healNestedConfig)(app);
62
+ (0, config_1.healStoredConfig)(app);
57
63
  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(() => {
64
+ const useBleApi = pluginConfig.useBleApi && bleApiAvailable;
65
+ if (pluginConfig.useBleApi && !bleApiAvailable) {
66
+ app.debug("useBleApi is enabled but this SignalK server has no BLE Manager API (requires >= 2.32.0) - falling back to direct BlueZ access");
67
+ }
68
+ app.debug(useBleApi ? "using the SignalK BLE Manager API for Bluetooth access" : "using direct BlueZ access for Bluetooth");
69
+ const start = () => {
63
70
  if (stopped)
64
71
  return;
65
72
  if (pluginConfig.scanOnStart) {
66
- void runStartupScan(app, pluginConfig.scanDurationSeconds);
73
+ void runStartupScan(app, pluginConfig.scanDurationSeconds, useBleApi);
67
74
  }
68
75
  scheduler = (0, repaintScheduler_1.startRepaintScheduler)(app, pluginConfig);
69
- });
76
+ };
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);
83
+ // The server/provider governs its own local-adapter readiness once BLE Manager mode owns
84
+ // `hci0` (or has none at all, behind a remote gateway) - waiting on a *local* BlueZ adapter
85
+ // here would be waiting on something this mode may never even need.
86
+ start();
87
+ }
88
+ else {
89
+ // Waits (with backoff, indefinitely on Linux) for a BLE adapter before the startup scan or
90
+ // the repaint scheduler touch BLE at all - see `waitForAdapter`'s doc comment on the
91
+ // boot-time race this covers. `stopped` is checked again inside `start()`, not just passed
92
+ // as `cancelled`, since the wait can also resolve `true` on its own right as `stop()` runs.
93
+ void (0, bleDiscovery_1.waitForAdapter)((message) => app.debug(message), () => stopped).then(start);
94
+ }
70
95
  },
71
96
  stop() {
72
97
  stopped = true;
73
98
  scheduler?.stop();
74
99
  scheduler = undefined;
100
+ stopDiscoveryListener?.();
101
+ stopDiscoveryListener = undefined;
75
102
  app.debug("stopped");
76
103
  },
77
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
+ }
@@ -7,6 +7,7 @@ const path_1 = require("path");
7
7
  const fs_1 = require("fs");
8
8
  const config_1 = require("./config");
9
9
  const bleDiscovery_1 = require("./devices/bleDiscovery");
10
+ const bleBackend_1 = require("./devices/bleBackend");
10
11
  const discoveredDevicesStore_1 = require("./devices/discoveredDevicesStore");
11
12
  const discoveryCoordinator_1 = require("./devices/discoveryCoordinator");
12
13
  const registry_1 = require("./devices/registry");
@@ -22,6 +23,8 @@ const pluginVersion_1 = require("./pluginVersion");
22
23
  const httpJson_1 = require("./httpJson");
23
24
  const INTERVAL_POLL_MS = 60000;
24
25
  const SUBSCRIPTION_DEBOUNCE_MS = 2000;
26
+ /** Pause between paint attempts - see `withRetries`' `delayMs`. */
27
+ const PAINT_RETRY_DELAY_MS = 5000;
25
28
  const RESOURCES_API_PATH = "/signalk/v2/api/resources";
26
29
  /**
27
30
  * The most recent wall-clock instant at or before `now` that an `interval`-triggered device's own
@@ -172,7 +175,7 @@ async function assembleRawContext(app, apiUrl, bindings) {
172
175
  }
173
176
  function clearForceRepaint(app, friendlyName) {
174
177
  const current = (0, config_1.readCurrentConfig)(app);
175
- 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);
176
179
  app.savePluginOptions({ ...current, devices }, (err) => {
177
180
  if (err)
178
181
  app.debug(`failed to clear forceRepaint for "${friendlyName}": ${err.message}`);
@@ -200,7 +203,7 @@ async function resolveTargets(app, config, device) {
200
203
  let records = (0, discoveredDevicesStore_1.loadDiscoveredDevices)(app);
201
204
  if (Object.keys(records).length === 0) {
202
205
  app.debug(`"${device.friendlyName}": device is "${config_1.ALL_DEVICES}" and nothing discovered yet - scanning for ${config.scanDurationSeconds}s`);
203
- records = (await (0, discoveryCoordinator_1.ensureScan)(app, config.scanDurationSeconds)).merged;
206
+ records = (await (0, discoveryCoordinator_1.ensureScan)(app, config.scanDurationSeconds, config.useBleApi && !!app.bleApi)).merged;
204
207
  }
205
208
  const targets = [];
206
209
  for (const record of Object.values(records)) {
@@ -306,7 +309,7 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
306
309
  // scheduled tick" (e.g. a regenerated forecast) is the whole point of one - which is exactly why a
307
310
  // provider-backed device should use `repaintTrigger: "interval"`, not `subscription`, for
308
311
  // cost/battery reasons (each repaint may be a paid API call on the provider's side).
309
- if (!provider && !templateChanged && !dataChanged && !device.forceRepaint) {
312
+ if (!provider && !templateChanged && !dataChanged && !device.advanced?.forceRepaint) {
310
313
  app.debug(`${label}: data unchanged, skipping repaint`);
311
314
  return;
312
315
  }
@@ -324,7 +327,7 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
324
327
  ? await provider.render({ templateName: device.templateName, context: renderContext, width, height, colours: metadata.colours })
325
328
  : await renderer.render(templatePath, renderContext, width, height, templatesDir, config_1.BUNDLED_TEMPLATES_DIR);
326
329
  succeeded = true;
327
- repaintReason = device.forceRepaint
330
+ repaintReason = device.advanced?.forceRepaint
328
331
  ? "forced"
329
332
  : provider
330
333
  ? "provider-rendered"
@@ -343,15 +346,29 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
343
346
  });
344
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);
345
348
  }
346
- const connectTimeoutMs = config.paintConnectTimeoutSeconds * 1000;
347
- await (0, bleDiscovery_1.withRetries)(config.paintRetries, async (attempt) => {
348
- if (attempt > 1) {
349
- app.debug(`${label}: attempting paint ${attempt}/${config.paintRetries}`);
350
- }
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}`);
351
354
  const startedAt = Date.now();
352
- await driver.paint(bitmap, { address, aesKey: device.aesKey, connectTimeoutMs, reframe: device.reframe });
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
+ });
353
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}`),
354
370
  });
371
+ await (gattBackend ? (0, bleBackend_1.exclusiveBleManagerAccess)(paintWithRetries) : paintWithRetries());
355
372
  (0, discoveredDevicesStore_1.touchDiscoveredDevice)(app, { address, vendor: target.vendor, pid: target.pid, hwVersion: target.hwVersion, metadata });
356
373
  // Only a successful render counts as "repainted" for dedup/catch-up purposes - a failure must not be
357
374
  // recorded here (see this function's doc comment on why).
@@ -391,7 +408,7 @@ function startRepaintScheduler(app, config) {
391
408
  // A single `forceRepaint` flag covers every target under `ALL_DEVICES` too - only clear it once
392
409
  // every target has actually succeeded, so a target that failed still gets forced again next time
393
410
  // instead of quietly reverting to ordinary hash-based dedup.
394
- if (device.forceRepaint && results.every((result) => result.status === "fulfilled")) {
411
+ if (device.advanced?.forceRepaint && results.every((result) => result.status === "fulfilled")) {
395
412
  clearForceRepaint(app, device.friendlyName);
396
413
  }
397
414
  };
@@ -438,7 +455,7 @@ function startRepaintScheduler(app, config) {
438
455
  // still avoids a redundant paint once targets are resolved.
439
456
  const startupCheckTimer = setTimeout(() => {
440
457
  for (const device of config.devices) {
441
- 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) {
442
459
  const hours = device.intervalHours ?? 1;
443
460
  const minute = device.intervalMinute ?? 0;
444
461
  const dueSlot = mostRecentScheduledSlot(new Date(), hours, minute);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rhizomatics/signalk-einklabel-plugin",
3
- "version": "1.2.3",
3
+ "version": "1.3.0-beta10",
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",
@@ -64,7 +64,7 @@
64
64
  "pngjs": "^7.0.0"
65
65
  },
66
66
  "devDependencies": {
67
- "@signalk/server-api": "^2.31.0",
67
+ "@signalk/server-api": "^2.32.0",
68
68
  "@types/luxon": "^3.7.1",
69
69
  "@types/node": "^26.4.0",
70
70
  "@types/pngjs": "^6.0.5",
@@ -75,7 +75,7 @@
75
75
  "typescript": "^5.5.0"
76
76
  },
77
77
  "engines": {
78
- "node": ">=18"
78
+ "node": ">=22"
79
79
  },
80
80
  "signalk": {
81
81
  "displayName": "eInk Label Displays",