@rhizomatics/signalk-einklabel-plugin 1.3.0-beta2 → 1.3.0-beta4

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 CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  First implementation of using new SignalK BLE Manager rather than directly using the `bluez` services. Off by default until longer term stability demonstrated.
4
4
 
5
+
5
6
  # 1.2.3
6
7
 
7
8
  - Improved example tide template for 2.9" Gicisky, and added blank and error templates
package/dist/cli/index.js CHANGED
File without changes
@@ -12,7 +12,28 @@ export interface BleBackend {
12
12
  connectGatt(address: string, timeoutMs: number): Promise<GattConnection>;
13
13
  waitForManufacturerData(address: string, manufacturerId: number, timeoutMs: number): Promise<Buffer | undefined>;
14
14
  }
15
+ /**
16
+ * Wraps a connected `GattConnection` so the session as a whole - not just the connect step - can't
17
+ * hang forever. Starts a single timer when the connection opens; if `disconnect()` hasn't been
18
+ * called (i.e. the caller's `paint()` hasn't finished, successfully or not) by the time it fires,
19
+ * treats the session as stuck: calls `forceClose` (which must tear down the connection and, for a
20
+ * shared backend, release any claim on it) and fails every call still outstanding or made afterwards.
21
+ * `Promise.race`ing each call against that same failure - rather than just refusing new calls once
22
+ * killed - is what actually unblocks a caller `await`ing a hung `write()`/`discoverServices()`:
23
+ * forcing the underlying connection closed doesn't guarantee the hung call's own promise ever
24
+ * settles, so the caller needs a second, independent way to move on.
25
+ */
26
+ export declare function withSessionWatchdog(conn: GattConnection, timeoutMs: number, forceClose: () => Promise<void>): GattConnection;
15
27
  export declare function nodeBleBackend(): BleBackend;
28
+ /**
29
+ * Waits for the BLE Manager to know about `address` before a GATT connect is attempted against it -
30
+ * mirrors `getOrDiscoverDevice` in `bleDiscovery.ts`, which gives `nodeBleBackend` the same guarantee
31
+ * against BlueZ directly. Without this, a specifically-addressed device that the manager hasn't seen
32
+ * yet (e.g. this plugin's own `scanOnStart` is off and no other BLE plugin happens to be scanning)
33
+ * would sit on `bleApi.connectGATT()` until that call's own `timeoutMs` gives up, rather than the
34
+ * plugin quietly rediscovering it the way the direct-BlueZ backend already does.
35
+ */
36
+ export declare function ensureDeviceVisible(bleApi: BLEApi, pluginId: string, address: string, timeoutMs: number): Promise<void>;
16
37
  /**
17
38
  * `bleApi.connectGATT()` has no timeout of its own, same story as node-ble's `Device#connect()` (see
18
39
  * `connectWithTimeout` in `bleDiscovery.ts`) - races it against `timeoutMs`.
@@ -1,10 +1,81 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.withSessionWatchdog = withSessionWatchdog;
3
4
  exports.nodeBleBackend = nodeBleBackend;
5
+ exports.ensureDeviceVisible = ensureDeviceVisible;
4
6
  exports.bleApiBackend = bleApiBackend;
7
+ const pluginVersion_1 = require("../pluginVersion");
5
8
  const bleDiscovery_1 = require("./bleDiscovery");
6
9
  /** How long to wait for BlueZ to have (or acquire) a `Device` object for the target address before giving up - a separate budget from the connect step itself, matching both drivers' previous hardcoded constant. */
7
10
  const DEVICE_DISCOVERY_TIMEOUT_MS = 30000;
11
+ /**
12
+ * Bounds the whole post-connect GATT session (service discovery, every read/write/notification a
13
+ * driver's `paint()` makes until it calls `disconnect()`), not just the connect step above - unlike
14
+ * `connectWithTimeout`/`bleApiBackend`'s connect race, neither backend's underlying `write()` or
15
+ * `discoverServices()` has any timeout of its own, so a hang there (plausible against a flaky real
16
+ * device) would otherwise sit forever with the connection, and any BLE Manager GATT claim, held open
17
+ * - see `withSessionWatchdog`. Generous relative to any single driver operation's own timeout (e.g.
18
+ * gicisky's 15s per-chunk ack) since it has to cover a whole multi-chunk image transfer, not one step
19
+ * of it; it's a last-resort backstop, not a normal-path budget.
20
+ */
21
+ const GATT_SESSION_WATCHDOG_MS = 5 * 60000;
22
+ /**
23
+ * Wraps a connected `GattConnection` so the session as a whole - not just the connect step - can't
24
+ * hang forever. Starts a single timer when the connection opens; if `disconnect()` hasn't been
25
+ * called (i.e. the caller's `paint()` hasn't finished, successfully or not) by the time it fires,
26
+ * treats the session as stuck: calls `forceClose` (which must tear down the connection and, for a
27
+ * shared backend, release any claim on it) and fails every call still outstanding or made afterwards.
28
+ * `Promise.race`ing each call against that same failure - rather than just refusing new calls once
29
+ * killed - is what actually unblocks a caller `await`ing a hung `write()`/`discoverServices()`:
30
+ * forcing the underlying connection closed doesn't guarantee the hung call's own promise ever
31
+ * settles, so the caller needs a second, independent way to move on.
32
+ */
33
+ function withSessionWatchdog(conn, timeoutMs, forceClose) {
34
+ let killedError;
35
+ let resolveKilled;
36
+ const killed = new Promise((resolve) => {
37
+ resolveKilled = resolve;
38
+ });
39
+ let settled = false;
40
+ // `unref()` so this backstop timer never itself keeps the process alive (e.g. the CLI's `paint`
41
+ // command exiting naturally once its work is done) - it only ever needs to fire while something
42
+ // else is already keeping the event loop running anyway.
43
+ const timer = setTimeout(() => {
44
+ if (settled)
45
+ return;
46
+ killedError = new Error(`GATT session watchdog fired after ${timeoutMs}ms with no completion - forcing disconnect`);
47
+ console.error(`${pluginVersion_1.PLUGIN_NAME}: ${killedError.message}`);
48
+ resolveKilled(killedError);
49
+ void forceClose().catch(() => { });
50
+ }, timeoutMs).unref();
51
+ function guard(op) {
52
+ if (killedError)
53
+ return Promise.reject(killedError);
54
+ const result = op();
55
+ result.catch(() => { }); // observed here too, so losing the race below never surfaces as an unhandled rejection
56
+ return Promise.race([result, killed.then((err) => Promise.reject(err))]);
57
+ }
58
+ return {
59
+ read: (serviceUuid, charUuid) => guard(() => conn.read(serviceUuid, charUuid)),
60
+ write: (serviceUuid, charUuid, data, withResponse) => guard(() => conn.write(serviceUuid, charUuid, data, withResponse)),
61
+ startNotifications: (serviceUuid, charUuid, callback) => guard(() => conn.startNotifications(serviceUuid, charUuid, callback)),
62
+ stopNotifications: (serviceUuid, charUuid) => guard(() => conn.stopNotifications(serviceUuid, charUuid)),
63
+ discoverServices: () => guard(() => conn.discoverServices()),
64
+ onDisconnect: conn.onDisconnect.bind(conn),
65
+ get connected() {
66
+ return conn.connected;
67
+ },
68
+ async disconnect() {
69
+ if (settled)
70
+ return;
71
+ settled = true;
72
+ clearTimeout(timer);
73
+ if (killedError)
74
+ return; // already forced closed by the watchdog above
75
+ await conn.disconnect();
76
+ },
77
+ };
78
+ }
8
79
  function nodeBleBackend() {
9
80
  return {
10
81
  async connectGatt(address, timeoutMs) {
@@ -14,7 +85,14 @@ function nodeBleBackend() {
14
85
  const device = await (0, bleDiscovery_1.getOrDiscoverDevice)(adapter, address, DEVICE_DISCOVERY_TIMEOUT_MS);
15
86
  await (0, bleDiscovery_1.connectWithTimeout)(device, timeoutMs);
16
87
  const conn = (0, bleDiscovery_1.openNodeBleGattConnection)(device);
17
- return {
88
+ let destroyed = false;
89
+ const destroyOnce = () => {
90
+ if (destroyed)
91
+ return;
92
+ destroyed = true;
93
+ destroy();
94
+ };
95
+ const gatt = {
18
96
  read: conn.read.bind(conn),
19
97
  write: conn.write.bind(conn),
20
98
  startNotifications: conn.startNotifications.bind(conn),
@@ -25,10 +103,22 @@ function nodeBleBackend() {
25
103
  return conn.connected;
26
104
  },
27
105
  async disconnect() {
28
- await conn.disconnect();
29
- destroy();
106
+ try {
107
+ await conn.disconnect();
108
+ }
109
+ finally {
110
+ destroyOnce();
111
+ }
30
112
  },
31
113
  };
114
+ return withSessionWatchdog(gatt, GATT_SESSION_WATCHDOG_MS, async () => {
115
+ // `device.disconnect()` can hang for the same underlying reason the operations
116
+ // `withSessionWatchdog` already guards can (see its doc comment) - race it briefly rather
117
+ // than awaiting it unbounded here too, but tear down the D-Bus connection either way so
118
+ // those resources don't leak even if BlueZ's disconnect itself never completes.
119
+ await Promise.race([gatt.disconnect(), (0, bleDiscovery_1.sleep)(5000)]);
120
+ destroyOnce();
121
+ });
32
122
  }
33
123
  catch (err) {
34
124
  destroy();
@@ -48,6 +138,35 @@ function nodeBleBackend() {
48
138
  },
49
139
  };
50
140
  }
141
+ /**
142
+ * Waits for the BLE Manager to know about `address` before a GATT connect is attempted against it -
143
+ * mirrors `getOrDiscoverDevice` in `bleDiscovery.ts`, which gives `nodeBleBackend` the same guarantee
144
+ * against BlueZ directly. Without this, a specifically-addressed device that the manager hasn't seen
145
+ * yet (e.g. this plugin's own `scanOnStart` is off and no other BLE plugin happens to be scanning)
146
+ * would sit on `bleApi.connectGATT()` until that call's own `timeoutMs` gives up, rather than the
147
+ * plugin quietly rediscovering it the way the direct-BlueZ backend already does.
148
+ */
149
+ async function ensureDeviceVisible(bleApi, pluginId, address, timeoutMs) {
150
+ const known = await bleApi.getDevice(address).catch(() => null);
151
+ if (known)
152
+ return;
153
+ const seen = await new Promise((resolve) => {
154
+ const timer = setTimeout(() => {
155
+ unsubscribe();
156
+ resolve(false);
157
+ }, timeoutMs);
158
+ const unsubscribe = bleApi.onAdvertisement(pluginId, (adv) => {
159
+ if (adv.mac !== address)
160
+ return;
161
+ clearTimeout(timer);
162
+ unsubscribe();
163
+ resolve(true);
164
+ });
165
+ });
166
+ if (!seen) {
167
+ throw new Error(`device ${address} isn't visible to the SignalK BLE Manager yet (out of range, asleep, or never seen) after waiting ${timeoutMs}ms`);
168
+ }
169
+ }
51
170
  /**
52
171
  * `bleApi.connectGATT()` has no timeout of its own, same story as node-ble's `Device#connect()` (see
53
172
  * `connectWithTimeout` in `bleDiscovery.ts`) - races it against `timeoutMs`.
@@ -60,6 +179,7 @@ function bleApiBackend(bleApi, pluginId) {
60
179
  // "already claimed" until the server restarts - see signalk-bluetti-plugin's BleManagerDevice
61
180
  // for the same defensive call. A no-op if we don't currently hold the claim.
62
181
  await bleApi.releaseGATTDevice(address, pluginId).catch(() => { });
182
+ await ensureDeviceVisible(bleApi, pluginId, address, DEVICE_DISCOVERY_TIMEOUT_MS);
63
183
  const connecting = bleApi.connectGATT(address, pluginId);
64
184
  let timedOut = false;
65
185
  const conn = await Promise.race([connecting, (0, bleDiscovery_1.sleep)(timeoutMs).then(() => void (timedOut = true))]);
@@ -78,13 +198,26 @@ function bleApiBackend(bleApi, pluginId) {
78
198
  .then(() => connecting.then((c) => c.disconnect()).catch(() => { }));
79
199
  throw new Error(`connecting to device timed out after ${timeoutMs}ms`);
80
200
  }
81
- return conn;
201
+ return withSessionWatchdog(conn, GATT_SESSION_WATCHDOG_MS, async () => {
202
+ // Belt-and-suspenders, matching the timeout-cleanup above: `releaseGATTDevice` is the
203
+ // authoritative claim release, `disconnect()` a secondary teardown of this specific handle -
204
+ // do both regardless of which (if either) itself hangs or rejects.
205
+ await Promise.allSettled([bleApi.releaseGATTDevice(address, pluginId), conn.disconnect()]);
206
+ });
82
207
  },
83
208
  async waitForManufacturerData(address, manufacturerId, timeoutMs) {
84
209
  // Unlike node-ble's `device.getManufacturerData()`, there's no cached-instant-read path here -
85
210
  // `BLEDeviceInfo` (from `getDevices()`/`getDevice()`) carries mac/name/rssi/seenBy but not
86
211
  // manufacturer data (see `BLEDeviceInfoSchema` in `@signalk/server-api`'s `ble-schemas.ts`) -
87
212
  // only the streamed `BLEAdvertisement` does. So this always actively waits on the stream.
213
+ //
214
+ // Same defensive release as `connectGatt`, and just as necessary here: a device the BLE Manager
215
+ // still thinks *we* hold a GATT claim on (e.g. from a crash/reload mid-paint, before this call
216
+ // ever reaches `connectGatt` below) stops advertising while claimed - which would otherwise wedge
217
+ // this wait forever, since nothing else in this path ever calls `connectGatt` (and so never gets a
218
+ // chance to release the stale claim) unless a fresh advertisement shows up first. A no-op if we
219
+ // don't currently hold the claim.
220
+ await bleApi.releaseGATTDevice(address, pluginId).catch(() => { });
88
221
  return new Promise((resolve) => {
89
222
  const timer = setTimeout(() => {
90
223
  unsubscribe();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rhizomatics/signalk-einklabel-plugin",
3
- "version": "1.3.0-beta2",
3
+ "version": "1.3.0-beta4",
4
4
  "description": "Display SignalK data on eInk Electronic Shelf Labels, includes working examples for tide clock and watch schedule.",
5
5
  "keywords": [
6
6
  "ble",
@@ -1,27 +0,0 @@
1
- import { EmailConfig } from "../config";
2
- import { Bitmap } from "../render/types";
3
- import { FieldRow } from "../render/fieldsTable";
4
- /** Either the SVG template's field-by-field table (`buildFieldsTable`), a `TemplateProvider`'s own rendered-content text (`describeContent`), or neither (a provider offered no `describeContent`). */
5
- export type EmailContentBody = {
6
- kind: "table";
7
- rows: FieldRow[];
8
- } | {
9
- kind: "text";
10
- text: string;
11
- } | {
12
- kind: "none";
13
- };
14
- export interface LabelEmailContent {
15
- friendlyName: string;
16
- description?: string;
17
- bitmap: Bitmap;
18
- body: EmailContentBody;
19
- }
20
- /**
21
- * Renders and sends one device's repaint as an email - a PNG attachment (also shown inline via a `cid`
22
- * reference), the same field-by-field table `esl-cli fields` shows for a hand-authored SVG template (or
23
- * a `TemplateProvider`'s own rendered-content text for something like a GenAI prompt - see `body`), and a
24
- * link back to the docs site. Called from `considerRepaint` (`../repaintScheduler.ts`) whenever a
25
- * device's `emailTo` is set and its render succeeds.
26
- */
27
- export declare function sendLabelEmail(emailConfig: EmailConfig, to: string, content: LabelEmailContent): Promise<void>;
@@ -1,66 +0,0 @@
1
- "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
- Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.sendLabelEmail = sendLabelEmail;
7
- const nodemailer_1 = __importDefault(require("nodemailer"));
8
- const png_1 = require("../render/png");
9
- const DOCS_URL = "https://rhizomatics.github.io/signalk-einklabel-plugin/";
10
- function escapeHtml(text) {
11
- return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
12
- }
13
- function bodyHtml(body) {
14
- if (body.kind === "table") {
15
- if (body.rows.length === 0)
16
- return "";
17
- const rows = body.rows
18
- .map((row) => `<tr><td>${escapeHtml(row.id)}</td><td>${escapeHtml(row.spec)}</td><td>${escapeHtml(row.value)}</td></tr>`)
19
- .join("");
20
- return ('<table border="1" cellpadding="4" cellspacing="0" style="border-collapse:collapse;font-family:monospace;font-size:13px">' +
21
- `<thead><tr><th>id</th><th>spec</th><th>value</th></tr></thead><tbody>${rows}</tbody></table>`);
22
- }
23
- if (body.kind === "text") {
24
- return `<pre style="white-space:pre-wrap;font-family:monospace;font-size:13px">${escapeHtml(body.text)}</pre>`;
25
- }
26
- return "";
27
- }
28
- /**
29
- * Renders and sends one device's repaint as an email - a PNG attachment (also shown inline via a `cid`
30
- * reference), the same field-by-field table `esl-cli fields` shows for a hand-authored SVG template (or
31
- * a `TemplateProvider`'s own rendered-content text for something like a GenAI prompt - see `body`), and a
32
- * link back to the docs site. Called from `considerRepaint` (`../repaintScheduler.ts`) whenever a
33
- * device's `emailTo` is set and its render succeeds.
34
- */
35
- async function sendLabelEmail(emailConfig, to, content) {
36
- const transporter = nodemailer_1.default.createTransport({
37
- host: emailConfig.smtpHost,
38
- port: emailConfig.smtpPort,
39
- secure: emailConfig.smtpSecure,
40
- auth: emailConfig.smtpUser ? { user: emailConfig.smtpUser, pass: emailConfig.smtpPassword } : undefined,
41
- });
42
- const png = (0, png_1.bitmapToPng)(content.bitmap);
43
- const html = [
44
- '<div style="font-family:sans-serif">',
45
- `<h2>${escapeHtml(content.friendlyName)}</h2>`,
46
- content.description ? `<p>${escapeHtml(content.description)}</p>` : "",
47
- `<p><img src="cid:label-image" alt="${escapeHtml(content.friendlyName)}" style="border:1px solid #ccc" /></p>`,
48
- bodyHtml(content.body),
49
- `<p><a href="${DOCS_URL}">${DOCS_URL}</a></p>`,
50
- "</div>",
51
- ].join("\n");
52
- await transporter.sendMail({
53
- from: emailConfig.fromAddress,
54
- to,
55
- subject: `eInk Label: ${content.friendlyName}`,
56
- html,
57
- attachments: [
58
- {
59
- filename: "label.png",
60
- content: png,
61
- contentType: "image/png",
62
- cid: "label-image",
63
- },
64
- ],
65
- });
66
- }
@@ -1,15 +0,0 @@
1
- import { TemplateContext } from "./types";
2
- export interface FieldRow {
3
- id: string;
4
- spec: string;
5
- value: string;
6
- }
7
- /**
8
- * Synchronous counterpart to the CLI's `fields` command (`../cli/index.ts`) - same three columns (id,
9
- * spec, resolved value), but resolved against a single already-assembled `context` rather than
10
- * re-fetching per binding, since a caller like `considerRepaint` (`../repaintScheduler.ts`) already has
11
- * the full context its render used. Only meaningful for a hand-authored SVG template - a
12
- * `TemplateProvider`-backed template (e.g. GenAI) has no `<desc>` bindings to walk; see
13
- * `TemplateProvider.describeContent` (`./templateProviders.ts`) for that case's own text summary instead.
14
- */
15
- export declare function buildFieldsTable(svgSource: string, context: TemplateContext, templatesDir: string, bundledTemplatesDir: string): FieldRow[];
@@ -1,52 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.buildFieldsTable = buildFieldsTable;
4
- const path_1 = require("path");
5
- const xmldom_1 = require("@xmldom/xmldom");
6
- const assets_1 = require("./assets");
7
- const binding_1 = require("./binding");
8
- function resolveFieldValue(tag, desc, context, templatesDir, bundledTemplatesDir) {
9
- try {
10
- const binding = (0, binding_1.parseBinding)(desc);
11
- if (tag !== "image") {
12
- return (0, binding_1.renderBinding)(binding, context);
13
- }
14
- if (!binding.assets) {
15
- return 'ERROR: an <image> binding requires an "assets" key';
16
- }
17
- const key = (0, assets_1.normalizeAssetKey)((0, binding_1.resolveBinding)(binding, context));
18
- if (!key)
19
- return "(no usable value - image would be omitted)";
20
- const assetPath = (0, assets_1.resolveAssetPath)(templatesDir, bundledTemplatesDir, binding.assets, key);
21
- return assetPath ? `"${key}" -> ${(0, path_1.basename)(assetPath)}` : `"${key}" -> no matching asset file (image would be omitted)`;
22
- }
23
- catch (err) {
24
- return `ERROR: ${err.message}`;
25
- }
26
- }
27
- /**
28
- * Synchronous counterpart to the CLI's `fields` command (`../cli/index.ts`) - same three columns (id,
29
- * spec, resolved value), but resolved against a single already-assembled `context` rather than
30
- * re-fetching per binding, since a caller like `considerRepaint` (`../repaintScheduler.ts`) already has
31
- * the full context its render used. Only meaningful for a hand-authored SVG template - a
32
- * `TemplateProvider`-backed template (e.g. GenAI) has no `<desc>` bindings to walk; see
33
- * `TemplateProvider.describeContent` (`./templateProviders.ts`) for that case's own text summary instead.
34
- */
35
- function buildFieldsTable(svgSource, context, templatesDir, bundledTemplatesDir) {
36
- const doc = new xmldom_1.DOMParser().parseFromString(svgSource, "image/svg+xml");
37
- const rows = [];
38
- for (const tag of ["text", "image"]) {
39
- const elements = doc.getElementsByTagName(tag);
40
- for (let i = 0; i < elements.length; i++) {
41
- const element = elements.item(i);
42
- if (!element)
43
- continue;
44
- const id = element.getAttribute("id") ?? `${tag}#${i}`;
45
- const desc = element.getElementsByTagName("desc").item(0);
46
- if (!desc?.textContent)
47
- continue;
48
- rows.push({ id, spec: desc.textContent, value: resolveFieldValue(tag, desc.textContent, context, templatesDir, bundledTemplatesDir) });
49
- }
50
- }
51
- return rows;
52
- }
@@ -1,86 +0,0 @@
1
- import { PluginConfig } from "../config";
2
- import { Colour } from "../devices/types";
3
- import { Binding } from "./binding";
4
- import { TemplateContext } from "./types";
5
- /** Facts about one physical label a prompt's `source=label,path=...` placeholders can reference - see `buildLabelContext`. */
6
- export interface LabelMeta {
7
- manufacturer: string;
8
- /** The physical panel's own size label, e.g. `'3.7"'` - `DeviceMetadata.label` verbatim, see `../devices/types.ts`. */
9
- label: string;
10
- width: number;
11
- height: number;
12
- colours: Colour[];
13
- description?: string;
14
- position?: {
15
- latitude: number;
16
- longitude: number;
17
- };
18
- }
19
- /**
20
- * Builds the `context.label` object a prompt/target-guidance fragment addresses via
21
- * `source=label,path=...` bindings (e.g. `{source=label,path=width}`, or the bare-path shorthand
22
- * `{width}` is *not* supported here deliberately - see `./binding.ts`'s `parseBinding`, every `{...}`
23
- * placeholder is a real binding, so a `label` path always needs the explicit `source=label,path=`
24
- * form to disambiguate it from a `signalk` self path). `colours`/`fonts` are left as arrays (each
25
- * colour entry pre-annotated with its hex code, e.g. `"black (#000000)"`) rather than joined into a
26
- * single string, so a prompt author can either use them bare (renders as JSON, e.g. for a model that
27
- * parses structured hints) or add `format=csv` (see `./formatters.ts`) for a plain comma-separated list.
28
- */
29
- export declare function buildLabelContext(meta: LabelMeta): Record<string, unknown>;
30
- /**
31
- * Every binding referenced across one or more prompt fragments - every `{...}` placeholder, deduplicated
32
- * across all of `texts` combined, parsed exactly the way a template's `<desc>` binding is
33
- * (`parseBinding`, see `./binding.ts`), so a bare path (`{design.length}`, `source=signalk,context=self`
34
- * shorthand), the full binding grammar (`{source=signalk,path=navigation.position,format=position}`),
35
- * and a `source=label,path=...` binding all work uniformly. Pass the result to `assembleRawContext`
36
- * (repaintScheduler.ts) exactly as a template's own bindings are - it fetches the `signalk`/
37
- * `resources`-sourced ones and silently ignores `label`/`einklabel`-sourced ones, which resolve directly
38
- * against `context.label`/`context.meta` instead (built by the caller, not fetched).
39
- *
40
- * A placeholder that isn't valid binding grammar (e.g. a typo like `{source=taheight}`) is silently
41
- * skipped here rather than thrown - the same per-field isolation `SvgRenderer` gives a bad `<desc>`
42
- * binding, so one malformed placeholder doesn't take down the whole prompt. `substitutePlaceholders`
43
- * hits the identical parse error at substitution time and turns it into "???" for just that field.
44
- */
45
- export declare function findPromptBindings(...texts: string[]): Binding[];
46
- /**
47
- * Substitutes every `{...}` placeholder in `text` with its resolved binding value against `context`
48
- * (built by `assembleRawContext` plus `context.label` from `buildLabelContext` - see
49
- * `findPromptBindings`). Mirrors `renderBinding`'s per-field isolation, but substitutes "???" for
50
- * anything that resolves to no value at all (missing path, invalid binding grammar) rather than "" -
51
- * prose with a silently-blank word reads as a fact ("for the sailor of a m vessel"), not as a gap the
52
- * reader would notice. Two things override that "???": a binding that resolves successfully to a
53
- * legitimately empty string (e.g. an unset `{source=label,path=description}`) is left as empty, since
54
- * that's a real answer, not a miss; and a binding with an explicit `default=` (see `./binding.ts`) uses
55
- * that default instead, since the prompt author has already said what a missing value should read as.
56
- */
57
- export declare function substitutePlaceholders(text: string, context: TemplateContext): string;
58
- /**
59
- * Strips everything outside the first `<svg`...last `</svg>` span - a chat model asked for "only raw
60
- * SVG markup" still often wraps it in a markdown code fence or adds a sentence of commentary either
61
- * side, despite the target-guidance fragment telling it not to.
62
- */
63
- export declare function extractSvg(raw: string): string;
64
- export type LlmSettings = Pick<PluginConfig, "llmProvider" | "llmApiKey" | "llmModel" | "llmBaseUrl"> & {
65
- llmTimeoutSeconds?: number;
66
- };
67
- /**
68
- * `ai` and every `@ai-sdk/*` provider package are `optionalDependencies` (see package.json), not plain
69
- * `dependencies` - most installs (every device using `renderMode: "svg-template"` only) never touch an
70
- * LLM at all, so they shouldn't have to pull in this whole gateway stack, and an install that fails to
71
- * fetch one of these (network hiccup, `npm install --omit=optional`, an unsupported platform) must not
72
- * break BLE painting/templates, which have nothing to do with it. That means every reference to one of
73
- * these packages has to be a `require()` reached only when a `renderMode: "llm-prompt"` device actually
74
- * calls `callLlm` - a top-level `import` would be resolved eagerly the moment this module loads (which
75
- * is every plugin start, via `repaintScheduler.ts`), throwing before any config is even read. Types are
76
- * still fully checked via `typeof import(...)` below, which - unlike a value `import` - is erased
77
- * entirely at compile time and leaves no runtime trace for `tsc` to eagerly require.
78
- */
79
- export declare function loadOptional<M>(moduleName: string): M;
80
- /**
81
- * Calls the configured LLM gateway with `prompt`, returning its raw text response - not yet extracted/
82
- * validated as SVG, see `extractSvg`. This is the first network call in the codebase that needs its own
83
- * timeout (`fetchJson` in `../httpJson.ts` has none - every existing call is to the local SignalK
84
- * server's own REST API).
85
- */
86
- export declare function callLlm(settings: LlmSettings, prompt: string): Promise<string>;
@@ -1,199 +0,0 @@
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
- }