@rhizomatics/signalk-einklabel-plugin 1.3.0-beta12 → 1.3.0-beta13

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/dist/config.js CHANGED
@@ -7,6 +7,9 @@ exports.readCurrentConfig = readCurrentConfig;
7
7
  exports.healStoredConfig = healStoredConfig;
8
8
  exports.resolveTemplatesDir = resolveTemplatesDir;
9
9
  exports.parseDevice = parseDevice;
10
+ exports.listSvgFiles = listSvgFiles;
11
+ exports.listTemplateVariants = listTemplateVariants;
12
+ exports.listTemplateFamilies = listTemplateFamilies;
10
13
  exports.resolveTemplatePath = resolveTemplatePath;
11
14
  exports.configSchema = configSchema;
12
15
  exports.configUiSchema = configUiSchema;
@@ -362,9 +365,9 @@ function withEnum(schema, values, names) {
362
365
  }
363
366
  /** The plugin's documentation site - its sections' anchors are the README's own headings (see `site/scripts/sync-readme.mjs`). */
364
367
  const DOCS_URL = "https://signalk-einklabel.rhizomatics.org.uk/";
365
- /** A Markdown link to a docs section, for a field description rendered with `ui:enableMarkdownInDescription` (see `configUiSchema`). */
366
- function docsLink(anchor, text = "More in the docs") {
367
- return `[${text}](${DOCS_URL}#${anchor})`;
368
+ /** A Markdown link to a docs page (and optional section, e.g. `templates/#reframing`), for a field description rendered with `ui:enableMarkdownInDescription` (see `configUiSchema`). */
369
+ function docsLink(page, text = "More in the docs") {
370
+ return `[${text}](${DOCS_URL}${page})`;
368
371
  }
369
372
  /**
370
373
  * An enum-like string field whose options show explanatory labels rather than their raw stored values
@@ -481,12 +484,12 @@ function configSchema(app, discovered = []) {
481
484
  title: "Location/description (optional)",
482
485
  description: 'Free-text notes about where this label is physically mounted/viewed from, e.g. "chart table, viewed from ~1m ' +
483
486
  'in poor light" - available to any template as `source=label,path=description`. ' +
484
- docsLink("label-details"),
487
+ docsLink("templates/#label-details"),
485
488
  },
486
489
  templateName: withEnum({
487
490
  type: "string",
488
491
  title: "Template",
489
- description: `A bundled template, or one from your templates directory. ${docsLink("templating")}`,
492
+ description: `A bundled template, or one from your templates directory. ${docsLink("examples/")}`,
490
493
  }, templateNameOptions(resolveTemplatesDir(current.templatesDir))),
491
494
  repaintTrigger: choiceField("Repaint trigger", [
492
495
  ["subscription", "When a SignalK path changes"],
@@ -501,11 +504,11 @@ function configSchema(app, discovered = []) {
501
504
  ["crop", "Crop - place at the top-left, cutting off anything too big or leaving the rest blank"],
502
505
  ["scale", "Scale - stretch to fit exactly (may distort)"],
503
506
  ["fixed", "Fixed - fail the repaint rather than show an off-size image"],
504
- ], { description: docsLink("reframing"), default: "crop" }),
507
+ ], { description: docsLink("templates/#reframing"), default: "crop" }),
505
508
  compress: {
506
509
  type: "boolean",
507
510
  title: 'Compress upload (Zhsunyco, Gicisky 7.5"/10.2")',
508
- description: `Sends far less data over BLE, so repaints are quicker. Turn off if a label stops updating. ${docsLink("other-image-options")}`,
511
+ description: `Sends far less data over BLE, so repaints are quicker. Turn off if a label stops updating. ${docsLink("templates/#other-image-options")}`,
509
512
  default: true,
510
513
  },
511
514
  mirror: choiceField("Mirror", [
@@ -514,7 +517,7 @@ function configSchema(app, discovered = []) {
514
517
  ["vertical", "Flip top to bottom"],
515
518
  ["both", "Rotate 180° - for a label mounted upside down"],
516
519
  ], {
517
- description: `Only needed if the image shows up mirrored or upside down on the label. ${docsLink("other-image-options")}`,
520
+ description: `Only needed if the image shows up mirrored or upside down on the label. ${docsLink("templates/#other-image-options")}`,
518
521
  default: "none",
519
522
  }),
520
523
  compressionFormat: choiceField("Wire format (Gicisky, experimental)", [
@@ -524,7 +527,7 @@ function configSchema(app, discovered = []) {
524
527
  'Chunked - send compressed like the 7.5"/10.2" panels, e.g. to speed up a 4.2" BWR (untested on current firmware)',
525
528
  ],
526
529
  ], {
527
- description: `Chunked needs Compress upload on. Switch back to Auto if the label stops updating. ${docsLink("other-image-options")}`,
530
+ description: `Chunked needs Compress upload on. Switch back to Auto if the label stops updating. ${docsLink("templates/#other-image-options")}`,
528
531
  default: "auto",
529
532
  }),
530
533
  forceRepaint: {
@@ -616,7 +619,7 @@ function configUiSchema() {
616
619
  "advanced",
617
620
  ],
618
621
  friendlyName: { "ui:placeholder": "e.g. Tide clock" },
619
- description: { "ui:widget": "textarea", "ui:placeholder": "e.g. chart table, viewed from about 1m in poor light", ...MARKDOWN },
622
+ description: { "ui:widget": "textarea", "ui:placeholder": "e.g. at companionway", ...MARKDOWN },
620
623
  templateName: MARKDOWN,
621
624
  repaintTrigger: { "ui:widget": "radio" },
622
625
  triggerPath: { "ui:placeholder": "e.g. environment.tide.state" },
@@ -0,0 +1,18 @@
1
+ /** The reference block for one bundled template name, e.g. `tides` (a family) or `tide.svg`. */
2
+ export declare function templateReference(templateName: string, templatesDir?: string): string;
3
+ /** Every bundled template name a user can pick - plain `.svg` files and family directories, not dot-prefixed internals. */
4
+ export declare function bundledTemplateNames(templatesDir?: string): string[];
5
+ /** A one-row-per-template summary, linking each to the example page whose reference block covers it. */
6
+ export declare function templatesSummary(pagesByTemplate: Map<string, string>, templatesDir?: string): string;
7
+ /**
8
+ * Regenerates every marked block under `examplesDir`, returning each file's current and regenerated
9
+ * content. `templates-summary` blocks link to whichever page holds a template's `template-reference`
10
+ * block, so all pages are scanned for those first.
11
+ */
12
+ export declare function regenerateExampleDocs(examplesDir: string, templatesDir?: string): {
13
+ path: string;
14
+ current: string;
15
+ updated: string;
16
+ stale: boolean;
17
+ }[];
18
+ export declare const EXAMPLES_DOCS_DIR: string;
@@ -0,0 +1,207 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.EXAMPLES_DOCS_DIR = void 0;
4
+ exports.templateReference = templateReference;
5
+ exports.bundledTemplateNames = bundledTemplateNames;
6
+ exports.templatesSummary = templatesSummary;
7
+ exports.regenerateExampleDocs = regenerateExampleDocs;
8
+ const fs_1 = require("fs");
9
+ const path_1 = require("path");
10
+ const binding_1 = require("../render/binding");
11
+ const config_1 = require("../config");
12
+ const zhsunyco_1 = require("../devices/zhsunyco");
13
+ const gicisky_1 = require("../devices/gicisky");
14
+ /**
15
+ * Generates the "Template reference" blocks in the docs' example pages from the bundled templates
16
+ * themselves - sizes, aspect ratios, colours, which supported labels each size fits, and the data
17
+ * fields each one reads - so that reference can't drift from the templates it describes. Blocks sit
18
+ * between `<!-- BEGIN GENERATED: <kind> [<template>] -->` and `<!-- END GENERATED -->` markers in
19
+ * hand-written pages; everything outside them is left alone. Run `npm run docs:templates` to update,
20
+ * and `templateReference.test.ts` fails when a block is stale.
21
+ */
22
+ const TEMPLATE_SOURCE_URL = "https://github.com/rhizomatics/signalk-einklabel-plugin/blob/main/templates";
23
+ const MARKER = /<!-- BEGIN GENERATED: ([\w-]+)(?: ([^\s>]+))? -->[\s\S]*?<!-- END GENERATED -->/g;
24
+ const COLOUR_LETTER = { black: "B", white: "W", red: "R", yellow: "Y" };
25
+ function supportedLabels() {
26
+ return [new zhsunyco_1.ZhsunycoDriver(), new gicisky_1.GiciskyDriver()].flatMap((driver) => driver.supportedDevices());
27
+ }
28
+ /** Every file a bundled template name covers - one for a plain `.svg`, one per variant for a family directory. */
29
+ function templateFiles(templatesDir, templateName) {
30
+ const path = (0, path_1.join)(templatesDir, templateName);
31
+ if (!(0, fs_1.existsSync)(path)) {
32
+ throw new Error(`template "${templateName}" not found in ${templatesDir}`);
33
+ }
34
+ const read = (relativePath, colours) => {
35
+ const source = (0, fs_1.readFileSync)((0, path_1.join)(templatesDir, relativePath), "utf-8");
36
+ return { path: relativePath, ...(0, binding_1.readTemplateDimensions)(source), colours, bindings: (0, binding_1.findBindings)(source) };
37
+ };
38
+ if ((0, fs_1.statSync)(path).isDirectory()) {
39
+ return (0, config_1.listTemplateVariants)(path)
40
+ .sort((a, b) => b.width - a.width || b.height - a.height)
41
+ .map((variant) => read(`${templateName}/${variant.fileName}`, variant.colours));
42
+ }
43
+ return [read(templateName)];
44
+ }
45
+ function variantName(file) {
46
+ return file.path
47
+ .split("/")
48
+ .pop()
49
+ .replace(/\.svg$/, "");
50
+ }
51
+ function aspectRatio(width, height) {
52
+ return `${(width / height).toFixed(2)} : 1`;
53
+ }
54
+ function matchingLabels(file, labels) {
55
+ const matches = labels
56
+ .filter((label) => label.width === file.width && label.height - label.voffset === file.height)
57
+ .map((label) => {
58
+ const colours = label.colours.map((colour) => COLOUR_LETTER[colour]).join("");
59
+ // Some models' own label already names their colours (e.g. Gicisky's `2.9" BWR`).
60
+ const name = label.label.includes(colours) ? label.label : `${label.label} ${colours}`;
61
+ return `${label.manufacturer ?? ""} ${name}`.trim();
62
+ });
63
+ return matches.length > 0 ? [...new Set(matches)].join(", ") : "none exactly - see [Reframing](../templates.md#reframing)";
64
+ }
65
+ function describeSource(binding) {
66
+ switch (binding.source) {
67
+ case "signalk":
68
+ return binding.context === "self" ? "Signal K path" : `Signal K path (${binding.context})`;
69
+ case "resources":
70
+ return `\`${binding.resource}\` resource${binding.provider ? ` (provider \`${binding.provider}\`)` : ""}`;
71
+ case "einklabel":
72
+ return "Plugin";
73
+ case "label":
74
+ return "Label details";
75
+ }
76
+ }
77
+ function describeOptions(binding) {
78
+ return [
79
+ binding.assets && `image from \`${binding.assets}\``,
80
+ binding.format && `format \`${binding.format}\``,
81
+ binding.category && `category \`${binding.category}\``,
82
+ binding.round !== undefined && `round ${binding.round}`,
83
+ binding.default !== undefined && `default \`${binding.default}\``,
84
+ ].filter((option) => Boolean(option));
85
+ }
86
+ /** A table row per distinct field - array indexes collapsed, so `extremes[0].time` and `extremes[2].time` are one row. */
87
+ function fieldsTable(files) {
88
+ const rows = new Map();
89
+ for (const file of files) {
90
+ for (const binding of file.bindings) {
91
+ const source = describeSource(binding);
92
+ const path = binding.path.replace(/\[\d+\]/g, "[n]");
93
+ const key = `${source}\u0000${path}`;
94
+ const row = rows.get(key) ?? { source, path, options: new Set(), usedIn: new Set() };
95
+ describeOptions(binding).forEach((option) => row.options.add(option));
96
+ row.usedIn.add(variantName(file));
97
+ rows.set(key, row);
98
+ }
99
+ }
100
+ const showUsedIn = files.length > 1;
101
+ const header = showUsedIn ? "| Source | Path | Options | Used in |\n|---|---|---|---|" : "| Source | Path | Options |\n|---|---|---|";
102
+ const sorted = [...rows.values()].sort((a, b) => a.source.localeCompare(b.source) || a.path.localeCompare(b.path));
103
+ const lines = sorted.map((row) => {
104
+ const usedIn = row.usedIn.size === files.length ? "all" : [...row.usedIn].join(", ");
105
+ const cells = [row.source, `\`${row.path}\``, [...row.options].join(", ") || "-", ...(showUsedIn ? [usedIn] : [])];
106
+ return `| ${cells.join(" | ")} |`;
107
+ });
108
+ return [header, ...lines].join("\n");
109
+ }
110
+ /** The reference block for one bundled template name, e.g. `tides` (a family) or `tide.svg`. */
111
+ function templateReference(templateName, templatesDir = config_1.BUNDLED_TEMPLATES_DIR) {
112
+ const files = templateFiles(templatesDir, templateName);
113
+ const labels = supportedLabels();
114
+ const sizes = files.map((file) => {
115
+ const size = file.width && file.height ? `${file.width} × ${file.height}` : "-";
116
+ const ratio = file.width && file.height ? aspectRatio(file.width, file.height) : "-";
117
+ const colours = file.colours?.join(", ") ?? "-";
118
+ return `| [\`${file.path}\`](${TEMPLATE_SOURCE_URL}/${file.path}) | ${size} | ${ratio} | ${colours} | ${matchingLabels(file, labels)} |`;
119
+ });
120
+ return [
121
+ `**Template:** \`${templateName}\``,
122
+ "",
123
+ "| File | Size (px) | Aspect ratio | Colours | Fits these labels exactly |",
124
+ "|---|---|---|---|---|",
125
+ ...sizes,
126
+ "",
127
+ "**Data used**",
128
+ "",
129
+ fieldsTable(files),
130
+ ].join("\n");
131
+ }
132
+ /** Every bundled template name a user can pick - plain `.svg` files and family directories, not dot-prefixed internals. */
133
+ function bundledTemplateNames(templatesDir = config_1.BUNDLED_TEMPLATES_DIR) {
134
+ return [...(0, config_1.listTemplateFamilies)(templatesDir), ...(0, config_1.listSvgFiles)(templatesDir)].sort();
135
+ }
136
+ /** A one-row-per-template summary, linking each to the example page whose reference block covers it. */
137
+ function templatesSummary(pagesByTemplate, templatesDir = config_1.BUNDLED_TEMPLATES_DIR) {
138
+ const rows = bundledTemplateNames(templatesDir).map((name) => {
139
+ const files = templateFiles(templatesDir, name);
140
+ const sizes = files.map((file) => (file.width && file.height ? `${file.width}×${file.height}` : "?")).join(", ");
141
+ // Only what has to come from outside the plugin - its own and the label's details are always there.
142
+ const external = files
143
+ .flatMap((file) => file.bindings)
144
+ .filter((binding) => binding.source === "signalk" || binding.source === "resources");
145
+ const resources = external.filter((binding) => binding.source === "resources").map(describeSource);
146
+ const paths = external.filter((binding) => binding.source === "signalk").map((binding) => `\`${binding.path}\``);
147
+ const sources = [...new Set(resources), ...(paths.length > 0 ? [`Signal K ${[...new Set(paths)].sort().join(", ")}`] : [])];
148
+ const page = pagesByTemplate.get(name);
149
+ return `| ${page ? `[\`${name}\`](${page})` : `\`${name}\``} | ${sizes} | ${sources.join(", ") || "-"} |`;
150
+ });
151
+ return ["| Template | Sizes | Data needed |", "|---|---|---|", ...rows].join("\n");
152
+ }
153
+ function markdownFiles(dir) {
154
+ return (0, fs_1.readdirSync)(dir, { withFileTypes: true }).flatMap((entry) => {
155
+ const path = (0, path_1.join)(dir, entry.name);
156
+ if (entry.isDirectory())
157
+ return markdownFiles(path);
158
+ return entry.name.endsWith(".md") ? [path] : [];
159
+ });
160
+ }
161
+ /**
162
+ * Regenerates every marked block under `examplesDir`, returning each file's current and regenerated
163
+ * content. `templates-summary` blocks link to whichever page holds a template's `template-reference`
164
+ * block, so all pages are scanned for those first.
165
+ */
166
+ function regenerateExampleDocs(examplesDir, templatesDir = config_1.BUNDLED_TEMPLATES_DIR) {
167
+ const pages = markdownFiles(examplesDir).map((path) => ({ path, current: (0, fs_1.readFileSync)(path, "utf-8") }));
168
+ const pagesByTemplate = new Map();
169
+ for (const page of pages) {
170
+ for (const match of page.current.matchAll(MARKER)) {
171
+ if (match[1] === "template-reference" && match[2]) {
172
+ pagesByTemplate.set(match[2], page.path.slice(examplesDir.length + 1));
173
+ }
174
+ }
175
+ }
176
+ return pages.map((page) => {
177
+ const updated = page.current.replace(MARKER, (_block, kind, arg) => {
178
+ const body = kind === "template-reference" && arg
179
+ ? templateReference(arg, templatesDir)
180
+ : kind === "templates-summary"
181
+ ? templatesSummary(pagesByTemplate, templatesDir)
182
+ : undefined;
183
+ if (body === undefined)
184
+ throw new Error(`${page.path}: unknown generated block "${kind}${arg ? ` ${arg}` : ""}"`);
185
+ return `<!-- BEGIN GENERATED: ${kind}${arg ? ` ${arg}` : ""} -->\n<!-- Generated from the bundled templates by \`npm run docs:templates\` - do not edit by hand. -->\n\n${body}\n\n<!-- END GENERATED -->`;
186
+ });
187
+ return { ...page, updated, stale: ignoringTableAlignment(updated) !== ignoringTableAlignment(page.current) };
188
+ });
189
+ }
190
+ /**
191
+ * The formatter (`oxfmt`, run by `npm run docs:templates` after this) pads Markdown table columns to
192
+ * line up, which this generator doesn't - so compare with that padding stripped, or a freshly
193
+ * formatted page would always look stale.
194
+ */
195
+ function ignoringTableAlignment(markdown) {
196
+ return markdown
197
+ .split("\n")
198
+ .map((line) => (line.startsWith("|") ? line.replace(/\s*\|\s*/g, "|").replace(/-{3,}/g, "---") : line))
199
+ .join("\n");
200
+ }
201
+ exports.EXAMPLES_DOCS_DIR = (0, path_1.join)(__dirname, "..", "..", "docs", "examples");
202
+ if (require.main === module) {
203
+ const changed = regenerateExampleDocs(exports.EXAMPLES_DOCS_DIR).filter((page) => page.stale);
204
+ for (const page of changed)
205
+ (0, fs_1.writeFileSync)(page.path, page.updated);
206
+ console.log(changed.length > 0 ? `updated ${changed.map((page) => page.path).join(", ")}` : "template docs already up to date");
207
+ }
@@ -0,0 +1,109 @@
1
+ # Bluetooth
2
+
3
+ Advice for getting a reliable Bluetooth Low Energy (BLE) connection between the SignalK server and your labels.
4
+
5
+ Most of this applies to direct BlueZ mode only. If the "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.
6
+
7
+ ## Choosing a Bluetooth Adapter
8
+
9
+ Only needed in direct BlueZ mode - in BLE Manager mode the adapter is whatever the SignalK server's own Bluetooth settings provide.
10
+
11
+ - Bluetooth adapters for Linux can be tricky
12
+ - 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))
13
+ - 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
14
+ - Some Raspberry Pi models come with suitable Bluetooth built in
15
+ - See advice at [Recommended Bluetooth Adapters for Linux](https://github.com/morrownr/USB-WiFi/blob/main/home/Recommended_Bluetooth_Adapters_for_Linux.md)
16
+ - 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.
17
+
18
+ > - Don't worry about the very latest Bluetooth versions, 4.0 is minimum for BLE, 5.0 is nice
19
+ > - Home Assistant is massively more popular than SignalK, and often also run on Raspberry Pi and similar, so good source of advice
20
+
21
+ ## Weak Signal
22
+
23
+ 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.
24
+
25
+ ## Bluetooth Plugins Impacting Each Other
26
+
27
+ 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.
28
+
29
+ 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.
30
+
31
+ ## Stuck Bluetooth Adapters
32
+
33
+ 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.
34
+
35
+ ## Tuning Bluetooth Connections
36
+
37
+ 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:
38
+
39
+ - **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.
40
+ - **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.
41
+ - **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.
42
+
43
+ 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.
44
+
45
+ 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.
46
+
47
+ ## SignalK starts before the Bluetooth daemon
48
+
49
+ 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.
50
+
51
+ 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.
52
+
53
+ 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:
54
+
55
+ ```bash
56
+ sudo systemctl edit signalk.service
57
+ ```
58
+
59
+ This opens an override file — add:
60
+
61
+ ```ini
62
+ [Unit]
63
+ After=bluetooth.target
64
+ Wants=bluetooth.target
65
+ ```
66
+
67
+ Save and exit, then:
68
+
69
+ ```bash
70
+ sudo systemctl daemon-reload
71
+ sudo systemctl restart signalk
72
+ ```
73
+
74
+ 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.
75
+
76
+ ## Adapter stops responding: "No gpio to reset"
77
+
78
+ Example log:
79
+
80
+ ```
81
+ Bluetooth: hci0: No gpio to reset Realtek device, ignoring
82
+ Bluetooth: hci0: Unable to disable scanning: -110
83
+ Bluetooth: hci0: command 0x2042 tx timeout
84
+ Bluetooth: hci0: Opcode 0x2042 failed: -110
85
+ ```
86
+
87
+ 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 — adapters like the popular ASUS USB-500 lack a GPIO to allow reset and its stuck, and spams logs.
88
+
89
+ ### Example udev rule fix
90
+
91
+ > [!Note]
92
+ > 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.
93
+
94
+ Following file created at `/etc/udev/rules.d/99-bt500-no-autosuspend.rules`
95
+
96
+ ```
97
+ # Disable USB autosuspend for the ASUS USB-BT500 (RTL8761BU, 0b05:190e).
98
+ ACTION=="add", SUBSYSTEM=="usb", ATTR{idVendor}=="0b05", ATTR{idProduct}=="190e", TEST=="power/control", ATTR{power/control}="on"
99
+ ```
100
+
101
+ ### Example tlp fix
102
+
103
+ If `tlp` running to minimize power, it may have its own rules trying to suspend the Bluetooth dongle.
104
+
105
+ Following file created at /etc/tlp.d/99-bt500-no-autosuspend.conf
106
+
107
+ ```
108
+ USB_DENYLIST="0b05:190e"
109
+ ```
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).