@rhizomatics/signalk-einklabel-plugin 1.3.0-beta9 → 1.3.0

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.
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.mostRecentScheduledSlot = mostRecentScheduledSlot;
4
+ exports.oneAtATimePerKey = oneAtATimePerKey;
4
5
  exports.startRepaintScheduler = startRepaintScheduler;
5
6
  const crypto_1 = require("crypto");
6
7
  const path_1 = require("path");
@@ -25,6 +26,13 @@ const INTERVAL_POLL_MS = 60000;
25
26
  const SUBSCRIPTION_DEBOUNCE_MS = 2000;
26
27
  /** Pause between paint attempts - see `withRetries`' `delayMs`. */
27
28
  const PAINT_RETRY_DELAY_MS = 5000;
29
+ /**
30
+ * Last-resort limit on one paint attempt, on top of its connect timeout - so an attempt stuck on
31
+ * anything, anywhere, still fails and gets retried instead of blocking this label (and, via
32
+ * `exclusiveBleManagerAccess`, every other label) forever. Longer than every inner limit it backs up,
33
+ * the longest being the 5-minute GATT session watchdog in `bleBackend.ts`.
34
+ */
35
+ const PAINT_ATTEMPT_BACKSTOP_MS = 7 * 60000;
28
36
  const RESOURCES_API_PATH = "/signalk/v2/api/resources";
29
37
  /**
30
38
  * The most recent wall-clock instant at or before `now` that an `interval`-triggered device's own
@@ -175,7 +183,7 @@ async function assembleRawContext(app, apiUrl, bindings) {
175
183
  }
176
184
  function clearForceRepaint(app, friendlyName) {
177
185
  const current = (0, config_1.readCurrentConfig)(app);
178
- const devices = (current.devices ?? []).map((device) => device.friendlyName === friendlyName ? { ...device, forceRepaint: false } : device);
186
+ const devices = (current.devices ?? []).map((device) => device.friendlyName === friendlyName ? { ...device, advanced: { ...device.advanced, forceRepaint: false } } : device);
179
187
  app.savePluginOptions({ ...current, devices }, (err) => {
180
188
  if (err)
181
189
  app.debug(`failed to clear forceRepaint for "${friendlyName}": ${err.message}`);
@@ -260,6 +268,23 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
260
268
  let dataHash = "";
261
269
  let paintDurationMs = 0;
262
270
  let repaintReason = "render failed";
271
+ // Built up front, outside the try below, so the fallback warning render gets it too - it's all local
272
+ // facts (no network or template involved), so there's nothing here that can fail a render.
273
+ const rawPosition = (0, unwrapSignalkTree_1.unwrapSignalkTree)(app.getSelfPath("navigation.position"));
274
+ const position = typeof rawPosition?.latitude === "number" && typeof rawPosition?.longitude === "number"
275
+ ? { latitude: rawPosition.latitude, longitude: rawPosition.longitude }
276
+ : undefined;
277
+ const labelContext = (0, binding_1.buildLabelContext)({
278
+ // Falls back to the driver's own internal vendor key (e.g. "zhsunyco") when a device model has no
279
+ // explicit `manufacturer` of its own - see `DeviceMetadata.manufacturer`'s doc comment.
280
+ manufacturer: metadata.manufacturer ?? target.vendor,
281
+ label: metadata.label,
282
+ width,
283
+ height,
284
+ colours: metadata.colours,
285
+ description: device.description,
286
+ position,
287
+ });
263
288
  try {
264
289
  const apiUrl = await getApiUrl().catch((err) => {
265
290
  app.debug(`${label}: ${err.message}`);
@@ -281,21 +306,6 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
281
306
  bindings = (0, binding_1.findBindings)((0, fs_1.readFileSync)(templatePath, "utf-8"));
282
307
  }
283
308
  const rawContext = await assembleRawContext(app, apiUrl, bindings);
284
- const rawPosition = (0, unwrapSignalkTree_1.unwrapSignalkTree)(app.getSelfPath("navigation.position"));
285
- const position = typeof rawPosition?.latitude === "number" && typeof rawPosition?.longitude === "number"
286
- ? { latitude: rawPosition.latitude, longitude: rawPosition.longitude }
287
- : undefined;
288
- const labelContext = (0, binding_1.buildLabelContext)({
289
- // Falls back to the driver's own internal vendor key (e.g. "zhsunyco") when a device model has no
290
- // explicit `manufacturer` of its own - see `DeviceMetadata.manufacturer`'s doc comment.
291
- manufacturer: metadata.manufacturer ?? target.vendor,
292
- label: metadata.label,
293
- width,
294
- height,
295
- colours: metadata.colours,
296
- description: device.description,
297
- position,
298
- });
299
309
  // Hashed before `meta` is merged in below, deliberately, so a template merely *displaying* the
300
310
  // repaint timestamp doesn't perpetually invalidate its own dedup and force a repaint every check. A
301
311
  // full paint flashes the whole panel several times, and there's no confirmed partial-refresh path
@@ -309,7 +319,7 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
309
319
  // scheduled tick" (e.g. a regenerated forecast) is the whole point of one - which is exactly why a
310
320
  // provider-backed device should use `repaintTrigger: "interval"`, not `subscription`, for
311
321
  // cost/battery reasons (each repaint may be a paid API call on the provider's side).
312
- if (!provider && !templateChanged && !dataChanged && !device.forceRepaint) {
322
+ if (!provider && !templateChanged && !dataChanged && !device.advanced?.forceRepaint) {
313
323
  app.debug(`${label}: data unchanged, skipping repaint`);
314
324
  return;
315
325
  }
@@ -320,6 +330,7 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
320
330
  repainted: new Date().toISOString(),
321
331
  local_zone: (0, formatters_1.resolveLocalZoneAbbreviation)(rawContext),
322
332
  plugin_version: pluginVersion_1.PLUGIN_VERSION,
333
+ // Undocumented legacy alias of `label.description` - kept so templates already using it keep working.
323
334
  description: device.description ?? "",
324
335
  },
325
336
  };
@@ -327,7 +338,7 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
327
338
  ? await provider.render({ templateName: device.templateName, context: renderContext, width, height, colours: metadata.colours })
328
339
  : await renderer.render(templatePath, renderContext, width, height, templatesDir, config_1.BUNDLED_TEMPLATES_DIR);
329
340
  succeeded = true;
330
- repaintReason = device.forceRepaint
341
+ repaintReason = device.advanced?.forceRepaint
331
342
  ? "forced"
332
343
  : provider
333
344
  ? "provider-rendered"
@@ -344,25 +355,31 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
344
355
  height: metadata.height,
345
356
  colours: metadata.colours,
346
357
  });
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);
358
+ bitmap = await renderer.render(fallbackPath, {
359
+ label: labelContext,
360
+ meta: { repainted: new Date().toISOString(), plugin_version: pluginVersion_1.PLUGIN_VERSION, description: device.description ?? "" },
361
+ }, width, height, templatesDir, config_1.BUNDLED_TEMPLATES_DIR);
348
362
  }
349
- const connectTimeoutMs = config.paintConnectTimeoutSeconds * 1000;
363
+ const connectTimeoutMs = (device.advanced?.paintConnectTimeoutSeconds ?? config.paintConnectTimeoutSeconds) * 1000;
350
364
  const gattBackend = config.useBleApi && app.bleApi ? (0, bleBackend_1.bleApiBackend)(app.bleApi, pluginVersion_1.PLUGIN_NAME) : undefined;
351
- const attempts = Math.max(1, config.paintRetries);
365
+ const attempts = Math.max(1, device.advanced?.paintRetries ?? config.paintRetries);
352
366
  const paintWithRetries = () => (0, bleDiscovery_1.withRetries)(attempts, async (attempt) => {
353
367
  app.debug(`${label}: attempting paint ${attempt}/${attempts}`);
354
368
  const startedAt = Date.now();
355
- await driver.paint(bitmap, {
369
+ const paint = driver.paint(bitmap, {
356
370
  address,
357
371
  pid: target.pid,
358
- aesKey: device.aesKey,
372
+ aesKey: device.advanced?.aesKey,
359
373
  connectTimeoutMs,
360
- reframe: device.reframe,
361
- mirror: device.mirror,
362
- compress: device.compress,
363
- compressionFormat: device.compressionFormat,
374
+ reframe: device.advanced?.reframe,
375
+ mirror: device.advanced?.mirror,
376
+ compress: device.advanced?.compress,
377
+ compressionFormat: device.advanced?.compressionFormat,
378
+ writeWithoutResponse: device.advanced?.writeWithoutResponse,
364
379
  gattBackend,
380
+ log: (message) => app.debug(`${label}: ${message}`),
365
381
  });
382
+ await (0, bleDiscovery_1.withDeadline)(paint, connectTimeoutMs + PAINT_ATTEMPT_BACKSTOP_MS, `paint attempt ${attempt}/${attempts}`);
366
383
  paintDurationMs = Date.now() - startedAt;
367
384
  }, {
368
385
  delayMs: PAINT_RETRY_DELAY_MS,
@@ -378,6 +395,39 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
378
395
  }
379
396
  app.debug(succeeded ? `${label}: repainted (${repaintReason}, paint took ${paintDurationMs}ms)` : `${label}: repainted fallback warning`);
380
397
  }
398
+ /**
399
+ * Wraps `run` so only one call per key is in progress at a time. A call made while that key is busy
400
+ * is folded into a single follow-up (with the latest arguments), run once the current one finishes -
401
+ * but only if it succeeded (`run` resolved `true`). For repaints: newer data still gets shown promptly,
402
+ * but a label that's failing isn't hammered with a fresh round of retries straight after the last
403
+ * one gave up - the next scheduled trigger tries again instead.
404
+ */
405
+ function oneAtATimePerKey(keyOf, run, onBusy = () => { }) {
406
+ const inProgress = new Set();
407
+ const followUps = new Map();
408
+ const call = async (arg) => {
409
+ const key = keyOf(arg);
410
+ if (inProgress.has(key)) {
411
+ followUps.set(key, arg);
412
+ onBusy(arg);
413
+ return;
414
+ }
415
+ inProgress.add(key);
416
+ let succeeded = false;
417
+ try {
418
+ succeeded = await run(arg);
419
+ }
420
+ finally {
421
+ inProgress.delete(key);
422
+ }
423
+ const followUp = followUps.get(key);
424
+ followUps.delete(key);
425
+ if (followUp !== undefined && succeeded) {
426
+ await call(followUp);
427
+ }
428
+ };
429
+ return call;
430
+ }
381
431
  function startRepaintScheduler(app, config) {
382
432
  const state = loadState(app);
383
433
  const unsubscribes = [];
@@ -389,15 +439,17 @@ function startRepaintScheduler(app, config) {
389
439
  // (startup check, interval, subscription alike) funnels through this one gate.
390
440
  const startedAt = Date.now();
391
441
  const settleMs = (config.settleSeconds ?? 120) * 1000;
392
- const repaint = async (device) => {
442
+ const repaint = oneAtATimePerKey((device) => device.friendlyName, (device) => repaintOnce(device), (device) => app.debug(`"${device.friendlyName}": already repainting - will check again once it's done`));
443
+ /** Returns whether every target was repainted (or was already up to date). */
444
+ const repaintOnce = async (device) => {
393
445
  const elapsedMs = Date.now() - startedAt;
394
446
  if (elapsedMs < settleMs) {
395
447
  app.debug(`"${device.friendlyName}": still settling (${Math.round(elapsedMs / 1000)}s/${Math.round(settleMs / 1000)}s) - skipping repaint`);
396
- return;
448
+ return false;
397
449
  }
398
450
  const targets = await resolveTargets(app, config, device);
399
451
  if (targets.length === 0) {
400
- return;
452
+ return false;
401
453
  }
402
454
  const results = await Promise.allSettled(targets.map((target) => considerRepaint(app, config, device, target, state, getApiUrl)));
403
455
  results.forEach((result, i) => {
@@ -408,9 +460,11 @@ function startRepaintScheduler(app, config) {
408
460
  // A single `forceRepaint` flag covers every target under `ALL_DEVICES` too - only clear it once
409
461
  // every target has actually succeeded, so a target that failed still gets forced again next time
410
462
  // instead of quietly reverting to ordinary hash-based dedup.
411
- if (device.forceRepaint && results.every((result) => result.status === "fulfilled")) {
463
+ const allSucceeded = results.every((result) => result.status === "fulfilled");
464
+ if (device.advanced?.forceRepaint && allSucceeded) {
412
465
  clearForceRepaint(app, device.friendlyName);
413
466
  }
467
+ return allSucceeded;
414
468
  };
415
469
  const intervalDevices = config.devices.filter((device) => device.repaintTrigger === "interval");
416
470
  if (intervalDevices.length > 0) {
@@ -455,7 +509,7 @@ function startRepaintScheduler(app, config) {
455
509
  // still avoids a redundant paint once targets are resolved.
456
510
  const startupCheckTimer = setTimeout(() => {
457
511
  for (const device of config.devices) {
458
- if (device.repaintTrigger === "interval" && !device.forceRepaint && device.device !== config_1.ALL_DEVICES) {
512
+ if (device.repaintTrigger === "interval" && !device.advanced?.forceRepaint && device.device !== config_1.ALL_DEVICES) {
459
513
  const hours = device.intervalHours ?? 1;
460
514
  const minute = device.intervalMinute ?? 0;
461
515
  const dueSlot = mostRecentScheduledSlot(new Date(), hours, minute);
@@ -37,6 +37,9 @@ async function probe(url) {
37
37
  * and uses the first that responds.
38
38
  */
39
39
  async function resolveSignalkApiUrl(configuredUrl) {
40
+ // Typed free-form in the config UI, so tolerate stray whitespace and a trailing slash, which would
41
+ // otherwise double up with the leading slash of every API path appended to it.
42
+ configuredUrl = configuredUrl?.trim().replace(/\/+$/, "") || undefined;
40
43
  const candidates = configuredUrl ? [configuredUrl] : exports.SIGNALK_API_URL_OPTIONS;
41
44
  for (const url of candidates) {
42
45
  if (await probe(url))
@@ -0,0 +1,162 @@
1
+ # Bluetooth
2
+
3
+ Advice for getting a reliable Bluetooth Low Energy (BLE) connection between the SignalK server and your labels.
4
+
5
+ Bluetooth can be used in of three ways by a plugin:
6
+
7
+ - Direct access to dongle (usually `hci0` device). Not recommended
8
+ - Access via `bluez` and `dbus` services. Better but not ideal
9
+ - Using the BLE Manager added to SignalK in 2026. Recommended with caveats.
10
+ - "Use the SignalK BLE Manager API" setting is enabled, the adapter and its lifecycle are managed by the SignalK server, under its own Bluetooth admin settings, once for every BLE-consuming plugin.
11
+ - Under the hood, this uses `bluez` and `dbus` however manages them so that plugins are controlled in how they can impact each other
12
+ - It also has a very useful GUI for seeing all scanned devices, and all GATT claims (GATT being the protocol for directly connecting to BLE devices).
13
+ - This is still new and settling down, so not yet the default for this plugin
14
+
15
+ The advice here is general to Bluetooth on Linux, whichever way its being used.
16
+
17
+ ## Choosing a Bluetooth Adapter
18
+
19
+ First step is having a Bluetooth Low Energy (BLE) compatible bluetooth adapter available.
20
+
21
+ - Bluetooth adapters for Linux can be tricky
22
+ - TP-Link UB400 and Asus USB-BT500 are two well-known and available ones, though the ASUS USB-BT500 one can have problems with some Pi type boards (see [Adapter stops responding](#adapter-stops-responding-no-gpio-to-reset))
23
+ - CSR4.0 dongles (CSR8510 chip) have had kernel support for years, and there are well known work arounds for some of them, including in the Linux kernel since v5.17
24
+ - Some Raspberry Pi models come with suitable Bluetooth built in
25
+ - See advice at [Recommended Bluetooth Adapters for Linux](https://github.com/morrownr/USB-WiFi/blob/main/home/Recommended_Bluetooth_Adapters_for_Linux.md)
26
+ - Bluetooth adapters typically prefer being in USB2.0 ports rather than USB3.0 ports, since often the USB3.0 implementation leaks radio energy on the same 2.4Ghz spectrum as Bluetooth. If no USB2.0 port available, try a shielded USB2.0 extension lead to distance the dongle from the port. Some dongle manufacturees seems to do a better job at shielding for this than others.
27
+
28
+ > - Don't worry about the very latest Bluetooth versions, 4.0 is minimum for BLE, 5.0 is nice
29
+ > - Home Assistant is massively more popular than SignalK, and often also run on Raspberry Pi and similar, so good source of advice
30
+
31
+ SignalK BLE Manager also supports BLE Gateways, which could be an MQTT topic or an ESP-32 device. The [espos-ble-gateway](https://github.com/dirkwa/espos-ble-gateway) can be used with a cheap ESP32 device (see the list of supported hardware), which allows positioning of the gateway closer to devices, or having multiple gateways on a big boat.
32
+
33
+ ## BLE Manager Readiness
34
+
35
+ v2.31.0 is the minumum version of SignalK possible for BLE Manager. Several fixes went in to v2.33.0 so this is the practical minimum version for using the plugin.
36
+
37
+ Gicisky labels have been painted successfully using BLE Manager, however Zhsunyco have some different interactions that are waiting other fixes.
38
+
39
+ - [PR#3082](https://github.com/SignalK/signalk-server/pull/3082) - hung connections locking up device
40
+ - [PR#3088](https://github.com/SignalK/signalk-server/pull/3088) - Support plain GATT write requests
41
+ - [PR#3089](https://github.com/SignalK/signalk-server/pull/3089) - Pause scanning while GATT operation in progress
42
+
43
+ There's a workaround for **PR#3088** available in the _Advanced Options_, and **PR#3082** isn't a problem if operations don't fail, however **PR#3089** is a blocker for using Zhsunyco labels - they'll fail with a `0x0e` error code.
44
+
45
+ ## Weak Signal
46
+
47
+ If the label is too far from the SignalK server's adapter, try a BLE proxy device - ESP32 is popular for this - or, with the BLE Manager API, a remote BLE gateway.
48
+
49
+ If your dongle is plugged into a USB3 port (usually blue-highlighted), then there's a good chance the [infamous USB3 interference on the 2.4Ghz spectrum](https://www.usb.org/sites/default/files/327216.pdf) is impacting your adapter. Switch to a USB2 port if you have one, or better, use a USB extension cable to position the dongle far away.
50
+
51
+ ## Bluetooth Plugins Impacting Each Other
52
+
53
+ Bluetooth plugins can kick off scanning, and otherwise interfere with each other. Worst case is when plugins attempt to connect directly to Bluetooth adapters. Better is when they connect using `bluez` and `dbus` Linux components, and best of all when they use the SignalK BLE Manager added in 2026.
54
+
55
+ If you're having problems with Bluetooth connections, make sure other plugins are well behaved, using BLE Manager where they can, and consider temporarily switching them off if needed to debug label connections.
56
+
57
+ ## SignalK in Docker
58
+
59
+ Check for the `bluetooth` service working at both host level and inside the SignalK container. If there are stability issues, stop and disable the host level service (for example `sudo systemctl stop bluetooth` on a systemd controlled host).
60
+
61
+ ## Stuck Bluetooth Adapters
62
+
63
+ Sometime Bluetooth adapters, and/or the Linux services that use them, can get into a 'stuck' state, where the only solution is to reboot the server (although unplugging and plugging the dongle may help). The best way to avoid this is using a known good dongle, and using BLE Manager in SignalK wherever possible.
64
+
65
+ ## Zhsunyco Labels and the BLE Manager
66
+
67
+ With the "Use the SignalK BLE Manager API" setting on, Zhsunyco labels can connect and authenticate, then fail as soon as the image upload starts, with `Operation failed with ATT error: 0x0e` in the log. SignalK's BLE Manager (2.33 and earlier) sends every write that waits for an acknowledgement as a Bluetooth "reliable" write, which these labels don't support.
68
+
69
+ Either:
70
+
71
+ - turn on _Send image without waiting for each write (Zhsunyco)_ in the label's _Advanced settings_ - the image is sent with a small gap between writes instead, and the label still confirms once the whole image has arrived; or
72
+ - switch the BLE Manager setting off, if no other plugins need to share Bluetooth with this one.
73
+
74
+ Gicisky labels aren't affected, since they don't use acknowledged writes.
75
+
76
+ ## Stuck Labels
77
+
78
+ A label can itself get into a stuck state - usually after several connections were cut off part way through a repaint - where it still advertises and accepts connections, but no longer lists its services. Every repaint then connects and fails a couple of seconds later, with `Service not available` in this plugin's log, or `Characteristic … was not found` from other software such as Home Assistant.
79
+
80
+ You can confirm it's the label rather than your server: `bluetoothctl info <label address>` shows no `UUID:` lines even after a connection, and the same failure happens from a different adapter or computer.
81
+
82
+ The fix is to power-cycle the label - take the battery out for 10-20 seconds, put it back, then trigger a repaint (for example with _Force repaint_). If it still fails, the manufacturer's own app may be needed to reset it.
83
+
84
+ ## Tuning Bluetooth Connections
85
+
86
+ Labels spend most of their time asleep, so they can be slow to accept a connection, and slow to answer while an image is being sent to them. Linux's Bluetooth defaults are set with phones, headphones and sensors in mind, so if connections to labels regularly time out, or drop partway through a repaint (for example with GATT or connection-abort errors in the log), some Bluetooth settings on the server may need adjusting:
87
+
88
+ - **The plugin's own timeouts** - the _Paint connect timeout_ and _Paint retries_, set plugin-wide or per label (see [Setting up a Label](getting-started.md#setting-up-a-label)). Try these first, since they only affect this plugin.
89
+ - **BlueZ's connection settings**, in the `[LE]` section of `/etc/bluetooth/main.conf` - the connection interval range (`MinConnectionInterval`/`MaxConnectionInterval`), how long a quiet connection is kept before it's dropped (`ConnectionSupervisionTimeout`), and how long a connection attempt waits (`Autoconnecttimeout`). The file's own comments describe each one. Restart the Bluetooth service after changing it.
90
+ - **The kernel's Bluetooth settings** for the adapter, under `/sys/kernel/debug/bluetooth/hci0/` - such as `supervision_timeout`, `conn_min_interval` and `conn_max_interval`. Values written here are lost on reboot, unless something re-applies them at startup.
91
+
92
+ These apply whenever the server's Bluetooth goes through BlueZ, including the SignalK BLE Manager with a local adapter. They affect every Bluetooth device the server talks to, not just labels, so change one thing at a time and check that your other Bluetooth equipment still works.
93
+
94
+ No particular values are recommended here - what works depends on the adapter, the labels and whatever else is using Bluetooth. Get advice before changing them, for example from the [SignalK community](https://signalk.org) or Home Assistant's Bluetooth community, where many of the same adapters and Linux setups are used.
95
+
96
+ ## SignalK starts before the Bluetooth daemon
97
+
98
+ This and the next section are about direct BlueZ mode only - if the "Use the SignalK BLE Manager API" setting is enabled, adapter/dongle lifecycle is the SignalK server's problem to manage once, for every BLE-consuming plugin, not this plugin's.
99
+
100
+ The plugin retries BLE adapter initialisation with backoff (starting at 2s, capping at 30s) if `bluetoothd`/D-Bus isn't up yet when the plugin starts, so a slow-starting Bluetooth stack on boot will no longer strand it — it keeps retrying until the adapter appears rather than failing once and giving up. You'll see `BLE adapter not ready … — retrying in Ns …` in the SignalK logs in the meantime.
101
+
102
+ That said, it's cleaner to fix the boot ordering at the systemd level so the plugin finds the adapter ready on its first attempt. If SignalK runs as a systemd service (`systemctl status signalk`) and its unit file has no `[Unit]` section (check with `systemctl cat signalk`), add one:
103
+
104
+ ```bash
105
+ sudo systemctl edit signalk.service
106
+ ```
107
+
108
+ This opens an override file — add:
109
+
110
+ ```ini
111
+ [Unit]
112
+ After=bluetooth.target
113
+ Wants=bluetooth.target
114
+ ```
115
+
116
+ Save and exit, then:
117
+
118
+ ```bash
119
+ sudo systemctl daemon-reload
120
+ sudo systemctl restart signalk
121
+ ```
122
+
123
+ This tells systemd to start `bluetoothd` first and wait for it before starting SignalK, rather than relying on both racing to start in parallel at boot.
124
+
125
+ ## Adapter stops responding: "No gpio to reset"
126
+
127
+ Example log:
128
+
129
+ ```
130
+ Bluetooth: hci0: No gpio to reset Realtek device, ignoring
131
+ Bluetooth: hci0: Unable to disable scanning: -110
132
+ Bluetooth: hci0: command 0x2042 tx timeout
133
+ Bluetooth: hci0: Opcode 0x2042 failed: -110
134
+ ```
135
+
136
+ Adapters like the popular ASUS USB-500 lack a GPIO to allow reset when suspended and it gets stuck, spamming the logs. See [Adapter Goes to Sleep](#adapter-goes-to-sleep) for stopping the auto-suspend happening.
137
+
138
+ ## Adapter Goes to Sleep
139
+
140
+ This happens when USB autosuspend cycles the dongle in and out of low-power suspend while idle. When bluetoothd sends an HCI command while the device is suspended or mid-resume, it never gets answered
141
+
142
+ ### Example udev rule fix
143
+
144
+ > [!Note]
145
+ > In these examples the dongle is for vendor `0b05` and product `190e`, adapt for your own devices, use `lsusb` to find out, and if there's no `lsusb` command, install the `usbutils` package.
146
+
147
+ Following file created at `/etc/udev/rules.d/99-bt500-no-autosuspend.rules`
148
+
149
+ ```
150
+ # Disable USB autosuspend for the ASUS USB-BT500 (RTL8761BU, 0b05:190e).
151
+ ACTION=="add", SUBSYSTEM=="usb", ATTR{idVendor}=="0b05", ATTR{idProduct}=="190e", TEST=="power/control", ATTR{power/control}="on"
152
+ ```
153
+
154
+ ### Example tlp fix
155
+
156
+ If `tlp` running to minimize power, it may have its own rules trying to suspend the Bluetooth dongle.
157
+
158
+ Following file created at /etc/tlp.d/99-bt500-no-autosuspend.conf
159
+
160
+ ```
161
+ USB_DENYLIST="0b05:190e"
162
+ ```
package/docs/cli.md ADDED
@@ -0,0 +1,131 @@
1
+ # Command Line Interface
2
+
3
+ To get fast feedback on templates and shelf devices without updating and configuring SignalK, a CLI call `esl-cli` is provided when the module is manually installed (see [Using Outside of SignalK](getting-started.md#using-outside-of-signalk)) that has these commands. Use `--help` to get all the options.
4
+
5
+ - `vendors` - list supported vendors
6
+ - `scan` - report supported devices found from a BLE scan
7
+ - `render` - transform an SVG template and data into a PNG
8
+ - `paint` - render an SVG template and data to a selected ESL
9
+ - `fields` and `field` - see [Debugging Templates](#debugging-templates)
10
+
11
+ The width, height, vertical offset and colour palette for the device are taken from the internal register of devices, however can be overridden on the command line with `-w/--width`, `--height`, `--voffset` and `--colours`. This could be used to help you choose what size of label to buy, or to get an unsupported label working.
12
+
13
+ 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.
14
+
15
+ `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](templates.md#reframing)):
16
+
17
+ - `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
18
+ - `scale` - stretches the rendered image onto the panel's exact dimensions (independently per axis, not preserving aspect ratio)
19
+ - `fixed` - no adjustment; rejects a size mismatch with an error instead
20
+
21
+ 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".
22
+
23
+ `paint` has matching options for the other per-label image settings too (see [Other Image Options](templates.md#other-image-options)):
24
+
25
+ - `--mirror <mode>` - `none` (default), `horizontal`, `vertical`, or `both` (rotate 180°). `render` also takes `--mirror`, to preview the flip as a PNG without a label
26
+ - `--no-compress` - send the image uncompressed, to rule compression out if a label won't update
27
+ - `--compression-format <format>` - `auto` (default) or `chunked`, to try the experimental compressed format on a Gicisky 4.2" BWR
28
+
29
+ `esl-cli` can also be extended with new subcommands - see [Extending](extending.md#cli-commands).
30
+
31
+ ( The CLI can also be run from a checked out module, or by opening a terminal shell at `~/.signalk/node_modules/@rhizomatics/signalk-einklabel-plugin`, as `npx esl-cli command --args` )
32
+
33
+ ## Scans from CLI
34
+
35
+ The command line tools, run from inside the `.signalk` directory, can be used to help troubleshoot
36
+
37
+ - Scan for longer, in this example 90 seconds
38
+ - `npx esl-cli scan -d 90`
39
+ - Scan for all BLE devices, whatever they are
40
+ - `npx esl-cli scan -a`
41
+
42
+ ## Debugging Templates
43
+
44
+ The `esl-cli` can be used to debug and validate templates quickly:
45
+
46
+ - `render` - Render templates with SignalK data and write to a local PNG file
47
+ - `paint` - Render templates with SignalK data and send to selected ESL device
48
+ - `fields` - List the fields in the template, with the source specification and the rendered data value
49
+ - `field` - Accept a source specification (outside of any template context) and return the rendered value if available
50
+
51
+ Use `--help` to get the full set of arguments for any of the commands.
52
+
53
+ ## CLI Examples
54
+
55
+ ### Paint Image Directly
56
+
57
+ The label address previously discovered via `esl-cli scan`
58
+
59
+ ```bash
60
+ npx esl-cli paint -t templates/tides/250x128-BWRY.svg -a FF:FF:92:84:53:93
61
+ ```
62
+
63
+ If the label turns out to be a different size than the template (e.g. it's a 250x128 template on a 416x240 panel), it's cropped to fit by default - add `--reframe scale` to stretch it instead:
64
+
65
+ ```bash
66
+ npx esl-cli paint -t templates/tides/250x128-BWRY.svg -a FF:FF:92:84:53:93 --reframe scale
67
+ ```
68
+
69
+ If a label doesn't update, try sending it uncompressed to see whether compression is the cause:
70
+
71
+ ```bash
72
+ npx esl-cli paint -t templates/tides/250x128-BWRY.svg -a FF:FF:92:84:53:93 --no-compress
73
+ ```
74
+
75
+ If the image comes out mirrored, try each `--mirror` mode until it looks right, then set the same _Mirror_ option in the label's config:
76
+
77
+ ```bash
78
+ npx esl-cli paint -t templates/tides/250x128-BWRY.svg -a FF:FF:92:84:53:93 --mirror horizontal
79
+ ```
80
+
81
+ ### Test Template Without Updating Label
82
+
83
+ This will work even if you don't have a label, or even bluetooth. (The `-u` can be left out if your SignalK server running locally on default ports).
84
+
85
+ ```bash
86
+ npx esl-cli render -t templates/tides/250x128-BWRY.svg -o example.png -u http://localhost
87
+ ```
88
+
89
+ and this version will work even without a running SignalK server, using some pre-packaged example data:
90
+
91
+ ```bash
92
+ npx esl-cli render -t templates/tides/250x128-BWRY.svg -o example.png -e examples
93
+ ```
94
+
95
+ ### List all Fields and Rendered Values
96
+
97
+ ```bash
98
+ npx esl-cli fields -t templates/tide.svg -u http://localhost
99
+ ```
100
+
101
+ ```
102
+ id spec value
103
+ station.name source=resources,resource=tides,provider=tides,path=station.name Tobermory
104
+ source.name. source=resources,resource=tides,provider=tides,path=station.source.name TICON-4
105
+ last_repaint source=einklabel,path=repainted,format=local_datetime_short 30 Jun 26 00:08
106
+ extremes.0 source=resources,resource=tides,provider=tides,path=extremes[0].label Low
107
+ extremes.1 source=resources,resource=tides,provider=tides,path=extremes[1].label High
108
+ extremes.2 source=resources,resource=tides,provider=tides,path=extremes[2].label Low
109
+ timezoneRegion source=einklabel,path=local_zone BST
110
+ lat source=resources,resource=tides,provider=tides,path=station.datums.LAT,category=depth 0.2m
111
+ hat source=resources,resource=tides,provider=tides,path=station.datums.HAT,category=depth 5.2m
112
+ extremes.2.level source=resources,resource=tides,provider=tides,path=extremes[2].level,category=depth 1.1m
113
+ extremes.2.time source=resources,resource=tides,provider=tides,path=extremes[2].time,format=local_time 13:15
114
+ extremes.1.level source=resources,resource=tides,provider=tides,path=extremes[1].level,category=depth 3.8m
115
+ extremes.1.time source=resources,resource=tides,provider=tides,path=extremes[1].time,format=local_time 07:05
116
+ extremes.0.time source=resources,resource=tides,provider=tides,path=extremes[0].time,format=local_time 01:21
117
+ extremes.0.time-8 source=resources,resource=tides,provider=tides,path=extremes[0].time,format=day_mon 30 Jun
118
+ extremes.0.time-8-5 source=resources,resource=tides,provider=tides,path=extremes[1].time,format=day_mon 30 Jun
119
+ extremes.0.time-8-9 source=resources,resource=tides,provider=tides,path=extremes[2].time,format=day_mon 30 Jun
120
+ extremes.0.level source=resources,resource=tides,provider=tides,path=extremes[0].level,category=depth 1.3m
121
+ ```
122
+
123
+ ## Offline Working
124
+
125
+ `render` and `paint` need a `--url` argument to point to the SignalK server to retrieve data. If you don't have access to one, you can use `--example-data` or `-e` to point to a directory of example data, which is bundled with the plugin or available in GitHub at [examples](https://github.com/rhizomatics/signalk-einklabel-plugin/tree/main/examples). This also allows you to write templates for resource APIs that aren't available yet.
126
+
127
+ - `vessels.json` - The standard SignalK vessel paths
128
+ - `resources/xxxx.json` - The output of the `xxxx` resources API call
129
+ - `categories.json` - SignalK unit categories needed for `category=depth` type formatting
130
+
131
+ For example, `npx esl-cli fields -t templates/tide.svg -e examples` will show all the field data that will be populated from the example API, vessel and category data in the `examples` local directory.
@@ -0,0 +1,25 @@
1
+ # Examples
2
+
3
+ The plugin comes with ready-made templates for these examples. Pick one as a label's _Template_ in the plugin config, or copy it into your own templates directory as a starting point - see [Templates](../templates.md).
4
+
5
+ - [Tide Clock](tide-clock.md) - the next few high and low tides, with the moon phase
6
+ - [Watch Schedule](watch-schedule.md) - who's on watch now and next
7
+
8
+ ## Bundled Templates
9
+
10
+ Every bundled template, with its sizes and the data it needs. Each example's page lists the exact labels each size fits and every field it reads.
11
+
12
+ <!-- BEGIN GENERATED: templates-summary -->
13
+ <!-- Generated from the bundled templates by `npm run docs:templates` - do not edit by hand. -->
14
+
15
+ | Template | Sizes | Data needed |
16
+ | ---------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
17
+ | [`tide.svg`](tide-clock.md) | 416×240 | `tides` resource (provider `tides`), Signal K `environment.moon.phaseName` |
18
+ | [`tides`](tide-clock.md) | 416×240, 296×128, 250×128 | `tides` resource (provider `tides`), Signal K `environment.moon.phaseName` |
19
+ | [`watch`](watch-schedule.md) | 416×240 | Signal K `watch.current.endTime`, `watch.current.startTime`, `watch.current.teamName`, `watch.next.endTime`, `watch.next.startTime`, `watch.next.teamName`, `watch.system.name` |
20
+
21
+ <!-- END GENERATED -->
22
+
23
+ ## Trying Examples Without a Boat
24
+
25
+ Each template can be rendered to a PNG with the CLI using the bundled example data, with no SignalK server or label needed - see [Test Template Without Updating Label](../cli.md#test-template-without-updating-label).