@rhizomatics/signalk-einklabel-plugin 1.3.0-beta8 → 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.
- package/CHANGELOG.md +36 -1
- package/README.md +21 -511
- package/dist/cli/index.d.ts +4 -1
- package/dist/cli/index.js +26 -1
- package/dist/config.d.ts +62 -15
- package/dist/config.js +252 -60
- package/dist/devices/bleBackend.js +44 -7
- package/dist/devices/bleDiscovery.d.ts +5 -0
- package/dist/devices/bleDiscovery.js +18 -0
- package/dist/devices/gicisky/compression.d.ts +22 -9
- package/dist/devices/gicisky/compression.js +128 -13
- package/dist/devices/gicisky/encode.d.ts +1 -1
- package/dist/devices/gicisky/encode.js +2 -2
- package/dist/devices/gicisky/index.js +9 -3
- package/dist/devices/gicisky/layout.d.ts +13 -1
- package/dist/devices/gicisky/layout.js +21 -0
- package/dist/devices/types.d.ts +26 -0
- package/dist/devices/types.js +2 -0
- package/dist/devices/zhsunyco/compression.d.ts +1 -0
- package/dist/devices/zhsunyco/compression.js +30 -0
- package/dist/devices/zhsunyco/index.js +44 -9
- package/dist/devices/zhsunyco/protocol.d.ts +2 -0
- package/dist/devices/zhsunyco/protocol.js +2 -0
- package/dist/docs/templateReference.d.ts +18 -0
- package/dist/docs/templateReference.js +207 -0
- package/dist/email/emailSender.d.ts +27 -0
- package/dist/email/emailSender.js +66 -0
- package/dist/plugin.js +2 -2
- package/dist/render/fieldsTable.d.ts +15 -0
- package/dist/render/fieldsTable.js +52 -0
- package/dist/render/llmPrompt.d.ts +86 -0
- package/dist/render/llmPrompt.js +199 -0
- package/dist/render/mirror.d.ts +11 -0
- package/dist/render/mirror.js +24 -0
- package/dist/repaintScheduler.d.ts +8 -0
- package/dist/repaintScheduler.js +86 -29
- package/dist/resolveApiUrl.js +3 -0
- package/docs/bluetooth.md +162 -0
- package/docs/cli.md +131 -0
- package/docs/examples/README.md +25 -0
- package/docs/examples/tide-clock.md +93 -0
- package/docs/examples/watch-schedule.md +37 -0
- package/docs/extending.md +33 -0
- package/docs/faq.md +44 -0
- package/docs/getting-started.md +111 -0
- package/docs/templates.md +142 -0
- package/package.json +16 -7
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.buildLabelContext = buildLabelContext;
|
|
4
|
+
exports.findPromptBindings = findPromptBindings;
|
|
5
|
+
exports.substitutePlaceholders = substitutePlaceholders;
|
|
6
|
+
exports.extractSvg = extractSvg;
|
|
7
|
+
exports.loadOptional = loadOptional;
|
|
8
|
+
exports.callLlm = callLlm;
|
|
9
|
+
const binding_1 = require("./binding");
|
|
10
|
+
const formatters_1 = require("./formatters");
|
|
11
|
+
const COLOUR_HEX = { black: "#000000", white: "#FFFFFF", red: "#FF0000", yellow: "#FFFF00" };
|
|
12
|
+
/** The only `font-family` values `SvgRenderer` is guaranteed to render - see `expandGenericFontFamilies` in `./svgRenderer.ts`. */
|
|
13
|
+
const SAFE_FONT_FAMILIES = ["serif", "sans-serif", "monospace"];
|
|
14
|
+
/**
|
|
15
|
+
* Builds the `context.label` object a prompt/target-guidance fragment addresses via
|
|
16
|
+
* `source=label,path=...` bindings (e.g. `{source=label,path=width}`, or the bare-path shorthand
|
|
17
|
+
* `{width}` is *not* supported here deliberately - see `./binding.ts`'s `parseBinding`, every `{...}`
|
|
18
|
+
* placeholder is a real binding, so a `label` path always needs the explicit `source=label,path=`
|
|
19
|
+
* form to disambiguate it from a `signalk` self path). `colours`/`fonts` are left as arrays (each
|
|
20
|
+
* colour entry pre-annotated with its hex code, e.g. `"black (#000000)"`) rather than joined into a
|
|
21
|
+
* single string, so a prompt author can either use them bare (renders as JSON, e.g. for a model that
|
|
22
|
+
* parses structured hints) or add `format=csv` (see `./formatters.ts`) for a plain comma-separated list.
|
|
23
|
+
*/
|
|
24
|
+
function buildLabelContext(meta) {
|
|
25
|
+
return {
|
|
26
|
+
manufacturer: meta.manufacturer,
|
|
27
|
+
label: meta.label,
|
|
28
|
+
width: meta.width,
|
|
29
|
+
height: meta.height,
|
|
30
|
+
colours: meta.colours.map((colour) => `${colour} (${COLOUR_HEX[colour]})`),
|
|
31
|
+
fonts: SAFE_FONT_FAMILIES,
|
|
32
|
+
description: meta.description ?? "",
|
|
33
|
+
position: meta.position ? (0, formatters_1.applyFormat)("position", meta.position, {}, 3) : undefined,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Every binding referenced across one or more prompt fragments - every `{...}` placeholder, deduplicated
|
|
38
|
+
* across all of `texts` combined, parsed exactly the way a template's `<desc>` binding is
|
|
39
|
+
* (`parseBinding`, see `./binding.ts`), so a bare path (`{design.length}`, `source=signalk,context=self`
|
|
40
|
+
* shorthand), the full binding grammar (`{source=signalk,path=navigation.position,format=position}`),
|
|
41
|
+
* and a `source=label,path=...` binding all work uniformly. Pass the result to `assembleRawContext`
|
|
42
|
+
* (repaintScheduler.ts) exactly as a template's own bindings are - it fetches the `signalk`/
|
|
43
|
+
* `resources`-sourced ones and silently ignores `label`/`einklabel`-sourced ones, which resolve directly
|
|
44
|
+
* against `context.label`/`context.meta` instead (built by the caller, not fetched).
|
|
45
|
+
*
|
|
46
|
+
* A placeholder that isn't valid binding grammar (e.g. a typo like `{source=taheight}`) is silently
|
|
47
|
+
* skipped here rather than thrown - the same per-field isolation `SvgRenderer` gives a bad `<desc>`
|
|
48
|
+
* binding, so one malformed placeholder doesn't take down the whole prompt. `substitutePlaceholders`
|
|
49
|
+
* hits the identical parse error at substitution time and turns it into "???" for just that field.
|
|
50
|
+
*/
|
|
51
|
+
function findPromptBindings(...texts) {
|
|
52
|
+
const seen = new Set();
|
|
53
|
+
const bindings = [];
|
|
54
|
+
for (const text of texts) {
|
|
55
|
+
for (const match of text.matchAll(/\{([^{}]+)\}/g)) {
|
|
56
|
+
const key = match[1].trim();
|
|
57
|
+
if (seen.has(key))
|
|
58
|
+
continue;
|
|
59
|
+
seen.add(key);
|
|
60
|
+
try {
|
|
61
|
+
bindings.push((0, binding_1.parseBinding)(key));
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// see doc comment above - left for `substitutePlaceholders` to turn into "???"
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return bindings;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Substitutes every `{...}` placeholder in `text` with its resolved binding value against `context`
|
|
72
|
+
* (built by `assembleRawContext` plus `context.label` from `buildLabelContext` - see
|
|
73
|
+
* `findPromptBindings`). Mirrors `renderBinding`'s per-field isolation, but substitutes "???" for
|
|
74
|
+
* anything that resolves to no value at all (missing path, invalid binding grammar) rather than "" -
|
|
75
|
+
* prose with a silently-blank word reads as a fact ("for the sailor of a m vessel"), not as a gap the
|
|
76
|
+
* reader would notice. Two things override that "???": a binding that resolves successfully to a
|
|
77
|
+
* legitimately empty string (e.g. an unset `{source=label,path=description}`) is left as empty, since
|
|
78
|
+
* that's a real answer, not a miss; and a binding with an explicit `default=` (see `./binding.ts`) uses
|
|
79
|
+
* that default instead, since the prompt author has already said what a missing value should read as.
|
|
80
|
+
*/
|
|
81
|
+
function substitutePlaceholders(text, context) {
|
|
82
|
+
return text.replace(/\{([^{}]+)\}/g, (_match, raw) => {
|
|
83
|
+
const key = raw.trim();
|
|
84
|
+
try {
|
|
85
|
+
const binding = (0, binding_1.parseBinding)(key);
|
|
86
|
+
const value = (0, binding_1.resolveBinding)(binding, context);
|
|
87
|
+
if ((value === undefined || value === null) && binding.default === undefined)
|
|
88
|
+
return "???";
|
|
89
|
+
return (0, binding_1.renderBinding)(binding, context);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return "???";
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Strips everything outside the first `<svg`...last `</svg>` span - a chat model asked for "only raw
|
|
98
|
+
* SVG markup" still often wraps it in a markdown code fence or adds a sentence of commentary either
|
|
99
|
+
* side, despite the target-guidance fragment telling it not to.
|
|
100
|
+
*/
|
|
101
|
+
function extractSvg(raw) {
|
|
102
|
+
const start = raw.indexOf("<svg");
|
|
103
|
+
const end = raw.lastIndexOf("</svg>");
|
|
104
|
+
if (start === -1 || end === -1 || end < start) {
|
|
105
|
+
throw new Error('LLM response did not contain a "<svg>...</svg>" document');
|
|
106
|
+
}
|
|
107
|
+
return raw.slice(start, end + "</svg>".length);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* `ai` and every `@ai-sdk/*` provider package are `optionalDependencies` (see package.json), not plain
|
|
111
|
+
* `dependencies` - most installs (every device using `renderMode: "svg-template"` only) never touch an
|
|
112
|
+
* LLM at all, so they shouldn't have to pull in this whole gateway stack, and an install that fails to
|
|
113
|
+
* fetch one of these (network hiccup, `npm install --omit=optional`, an unsupported platform) must not
|
|
114
|
+
* break BLE painting/templates, which have nothing to do with it. That means every reference to one of
|
|
115
|
+
* these packages has to be a `require()` reached only when a `renderMode: "llm-prompt"` device actually
|
|
116
|
+
* calls `callLlm` - a top-level `import` would be resolved eagerly the moment this module loads (which
|
|
117
|
+
* is every plugin start, via `repaintScheduler.ts`), throwing before any config is even read. Types are
|
|
118
|
+
* still fully checked via `typeof import(...)` below, which - unlike a value `import` - is erased
|
|
119
|
+
* entirely at compile time and leaves no runtime trace for `tsc` to eagerly require.
|
|
120
|
+
*/
|
|
121
|
+
function loadOptional(moduleName) {
|
|
122
|
+
try {
|
|
123
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
124
|
+
return require(moduleName);
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
throw new Error(`LLM provider support needs the optional dependency "${moduleName}", which isn't installed - run "npm install ${moduleName}" (${err.message})`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** Ollama's own default local listen address - see the `"ollama"` case in `resolveModel` below. */
|
|
131
|
+
const DEFAULT_OLLAMA_BASE_URL = "http://localhost:11434/v1";
|
|
132
|
+
/**
|
|
133
|
+
* Resolves `settings` to a Vercel AI SDK model handle. `"ollama"` and `"local"` both go through the
|
|
134
|
+
* generic `@ai-sdk/openai-compatible` provider (Ollama/LM Studio/vLLM all expose an OpenAI-compatible
|
|
135
|
+
* endpoint, and none of them has - or needs - its own dedicated `@ai-sdk/*` package): `"ollama"`
|
|
136
|
+
* defaults `llmBaseUrl` to Ollama's own standard local address so it works with no further config
|
|
137
|
+
* (override it only if Ollama is running elsewhere, e.g. on the SignalK server's own host reached over
|
|
138
|
+
* the network); `"local"` is for anything else OpenAI-compatible, where there's no sensible universal
|
|
139
|
+
* default, so `llmBaseUrl` is required.
|
|
140
|
+
*/
|
|
141
|
+
function resolveModel(settings) {
|
|
142
|
+
const model = settings.llmModel;
|
|
143
|
+
if (!model) {
|
|
144
|
+
throw new Error("no LLM model configured (PluginConfig.llmModel)");
|
|
145
|
+
}
|
|
146
|
+
switch (settings.llmProvider ?? "openai") {
|
|
147
|
+
case "anthropic": {
|
|
148
|
+
const { createAnthropic } = loadOptional("@ai-sdk/anthropic");
|
|
149
|
+
return createAnthropic({ apiKey: settings.llmApiKey })(model);
|
|
150
|
+
}
|
|
151
|
+
case "google": {
|
|
152
|
+
const { createGoogleGenerativeAI } = loadOptional("@ai-sdk/google");
|
|
153
|
+
return createGoogleGenerativeAI({ apiKey: settings.llmApiKey })(model);
|
|
154
|
+
}
|
|
155
|
+
case "xai": {
|
|
156
|
+
const { createXai } = loadOptional("@ai-sdk/xai");
|
|
157
|
+
return createXai({ apiKey: settings.llmApiKey })(model);
|
|
158
|
+
}
|
|
159
|
+
case "ollama": {
|
|
160
|
+
const { createOpenAICompatible } = loadOptional("@ai-sdk/openai-compatible");
|
|
161
|
+
return createOpenAICompatible({
|
|
162
|
+
name: "ollama",
|
|
163
|
+
baseURL: settings.llmBaseUrl || DEFAULT_OLLAMA_BASE_URL,
|
|
164
|
+
apiKey: settings.llmApiKey,
|
|
165
|
+
})(model);
|
|
166
|
+
}
|
|
167
|
+
case "local": {
|
|
168
|
+
if (!settings.llmBaseUrl) {
|
|
169
|
+
throw new Error('llmBaseUrl is required when llmProvider is "local"');
|
|
170
|
+
}
|
|
171
|
+
const { createOpenAICompatible } = loadOptional("@ai-sdk/openai-compatible");
|
|
172
|
+
return createOpenAICompatible({ name: "local", baseURL: settings.llmBaseUrl, apiKey: settings.llmApiKey })(model);
|
|
173
|
+
}
|
|
174
|
+
case "openai":
|
|
175
|
+
default: {
|
|
176
|
+
const { createOpenAI } = loadOptional("@ai-sdk/openai");
|
|
177
|
+
return createOpenAI({ apiKey: settings.llmApiKey })(model);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Calls the configured LLM gateway with `prompt`, returning its raw text response - not yet extracted/
|
|
183
|
+
* validated as SVG, see `extractSvg`. This is the first network call in the codebase that needs its own
|
|
184
|
+
* timeout (`fetchJson` in `../httpJson.ts` has none - every existing call is to the local SignalK
|
|
185
|
+
* server's own REST API).
|
|
186
|
+
*/
|
|
187
|
+
async function callLlm(settings, prompt) {
|
|
188
|
+
const model = resolveModel(settings);
|
|
189
|
+
const { generateText } = loadOptional("ai");
|
|
190
|
+
const controller = new AbortController();
|
|
191
|
+
const timer = setTimeout(() => controller.abort(), (settings.llmTimeoutSeconds ?? 30) * 1000);
|
|
192
|
+
try {
|
|
193
|
+
const { text } = await generateText({ model, prompt, abortSignal: controller.signal });
|
|
194
|
+
return text;
|
|
195
|
+
}
|
|
196
|
+
finally {
|
|
197
|
+
clearTimeout(timer);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -13,4 +13,12 @@ export interface RepaintScheduler {
|
|
|
13
13
|
* rather than waiting up to `intervalHours` for the next one).
|
|
14
14
|
*/
|
|
15
15
|
export declare function mostRecentScheduledSlot(now: Date, intervalHours: number, intervalMinute: number): Date;
|
|
16
|
+
/**
|
|
17
|
+
* Wraps `run` so only one call per key is in progress at a time. A call made while that key is busy
|
|
18
|
+
* is folded into a single follow-up (with the latest arguments), run once the current one finishes -
|
|
19
|
+
* but only if it succeeded (`run` resolved `true`). For repaints: newer data still gets shown promptly,
|
|
20
|
+
* but a label that's failing isn't hammered with a fresh round of retries straight after the last
|
|
21
|
+
* one gave up - the next scheduled trigger tries again instead.
|
|
22
|
+
*/
|
|
23
|
+
export declare function oneAtATimePerKey<T>(keyOf: (arg: T) => string, run: (arg: T) => Promise<boolean>, onBusy?: (arg: T) => void): (arg: T) => Promise<void>;
|
|
16
24
|
export declare function startRepaintScheduler(app: ServerAPI, config: PluginConfig): RepaintScheduler;
|
package/dist/repaintScheduler.js
CHANGED
|
@@ -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,22 +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, {
|
|
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
|
-
|
|
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,
|
|
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,
|
|
361
379
|
gattBackend,
|
|
380
|
+
log: (message) => app.debug(`${label}: ${message}`),
|
|
362
381
|
});
|
|
382
|
+
await (0, bleDiscovery_1.withDeadline)(paint, connectTimeoutMs + PAINT_ATTEMPT_BACKSTOP_MS, `paint attempt ${attempt}/${attempts}`);
|
|
363
383
|
paintDurationMs = Date.now() - startedAt;
|
|
364
384
|
}, {
|
|
365
385
|
delayMs: PAINT_RETRY_DELAY_MS,
|
|
@@ -375,6 +395,39 @@ async function considerRepaint(app, config, device, target, state, getApiUrl) {
|
|
|
375
395
|
}
|
|
376
396
|
app.debug(succeeded ? `${label}: repainted (${repaintReason}, paint took ${paintDurationMs}ms)` : `${label}: repainted fallback warning`);
|
|
377
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
|
+
}
|
|
378
431
|
function startRepaintScheduler(app, config) {
|
|
379
432
|
const state = loadState(app);
|
|
380
433
|
const unsubscribes = [];
|
|
@@ -386,15 +439,17 @@ function startRepaintScheduler(app, config) {
|
|
|
386
439
|
// (startup check, interval, subscription alike) funnels through this one gate.
|
|
387
440
|
const startedAt = Date.now();
|
|
388
441
|
const settleMs = (config.settleSeconds ?? 120) * 1000;
|
|
389
|
-
const repaint =
|
|
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) => {
|
|
390
445
|
const elapsedMs = Date.now() - startedAt;
|
|
391
446
|
if (elapsedMs < settleMs) {
|
|
392
447
|
app.debug(`"${device.friendlyName}": still settling (${Math.round(elapsedMs / 1000)}s/${Math.round(settleMs / 1000)}s) - skipping repaint`);
|
|
393
|
-
return;
|
|
448
|
+
return false;
|
|
394
449
|
}
|
|
395
450
|
const targets = await resolveTargets(app, config, device);
|
|
396
451
|
if (targets.length === 0) {
|
|
397
|
-
return;
|
|
452
|
+
return false;
|
|
398
453
|
}
|
|
399
454
|
const results = await Promise.allSettled(targets.map((target) => considerRepaint(app, config, device, target, state, getApiUrl)));
|
|
400
455
|
results.forEach((result, i) => {
|
|
@@ -405,9 +460,11 @@ function startRepaintScheduler(app, config) {
|
|
|
405
460
|
// A single `forceRepaint` flag covers every target under `ALL_DEVICES` too - only clear it once
|
|
406
461
|
// every target has actually succeeded, so a target that failed still gets forced again next time
|
|
407
462
|
// instead of quietly reverting to ordinary hash-based dedup.
|
|
408
|
-
|
|
463
|
+
const allSucceeded = results.every((result) => result.status === "fulfilled");
|
|
464
|
+
if (device.advanced?.forceRepaint && allSucceeded) {
|
|
409
465
|
clearForceRepaint(app, device.friendlyName);
|
|
410
466
|
}
|
|
467
|
+
return allSucceeded;
|
|
411
468
|
};
|
|
412
469
|
const intervalDevices = config.devices.filter((device) => device.repaintTrigger === "interval");
|
|
413
470
|
if (intervalDevices.length > 0) {
|
|
@@ -452,7 +509,7 @@ function startRepaintScheduler(app, config) {
|
|
|
452
509
|
// still avoids a redundant paint once targets are resolved.
|
|
453
510
|
const startupCheckTimer = setTimeout(() => {
|
|
454
511
|
for (const device of config.devices) {
|
|
455
|
-
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) {
|
|
456
513
|
const hours = device.intervalHours ?? 1;
|
|
457
514
|
const minute = device.intervalMinute ?? 0;
|
|
458
515
|
const dueSlot = mostRecentScheduledSlot(new Date(), hours, minute);
|
package/dist/resolveApiUrl.js
CHANGED
|
@@ -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
|
+
```
|