@rhizomatics/signalk-einklabel-plugin 0.4.0 → 0.4.1
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 +2 -0
- package/README.md +1 -1
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +244 -0
- package/dist/cli/liveContext.d.ts +12 -0
- package/dist/cli/liveContext.js +76 -0
- package/dist/cli/log.d.ts +5 -0
- package/dist/cli/log.js +18 -0
- package/dist/config.d.ts +74 -0
- package/dist/config.js +193 -0
- package/dist/devices/bleDiscovery.d.ts +54 -0
- package/dist/devices/bleDiscovery.js +134 -0
- package/dist/devices/registry.d.ts +4 -0
- package/dist/devices/registry.js +15 -0
- package/dist/devices/types.d.ts +70 -0
- package/dist/devices/types.js +2 -0
- package/dist/devices/zhsunyco/encode.d.ts +12 -0
- package/dist/devices/zhsunyco/encode.js +47 -0
- package/dist/devices/zhsunyco/index.d.ts +11 -0
- package/dist/devices/zhsunyco/index.js +148 -0
- package/dist/devices/zhsunyco/metadata.d.ts +20 -0
- package/dist/devices/zhsunyco/metadata.js +98 -0
- package/dist/devices/zhsunyco/protocol.d.ts +49 -0
- package/dist/devices/zhsunyco/protocol.js +86 -0
- package/dist/httpJson.d.ts +6 -0
- package/dist/httpJson.js +40 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +23 -0
- package/dist/pathMeta.d.ts +16 -0
- package/dist/pathMeta.js +21 -0
- package/dist/plugin.d.ts +2 -0
- package/dist/plugin.js +99 -0
- package/dist/render/binding.d.ts +54 -0
- package/dist/render/binding.js +175 -0
- package/dist/render/fonts.d.ts +9 -0
- package/dist/render/fonts.js +16 -0
- package/dist/render/formatters.d.ts +18 -0
- package/dist/render/formatters.js +87 -0
- package/dist/render/png.d.ts +3 -0
- package/dist/render/png.js +10 -0
- package/dist/render/svgRenderer.d.ts +32 -0
- package/dist/render/svgRenderer.js +80 -0
- package/dist/render/types.d.ts +26 -0
- package/dist/render/types.js +2 -0
- package/dist/repaintScheduler.d.ts +6 -0
- package/dist/repaintScheduler.js +193 -0
- package/dist/resolveApiUrl.d.ts +28 -0
- package/dist/resolveApiUrl.js +62 -0
- package/dist/unitCategories.d.ts +11 -0
- package/dist/unitCategories.js +46 -0
- package/docs/assets/icons/icon_tmp.png +0 -0
- package/docs/assets/screenshots/example_tidal_clock.png +0 -0
- package/package.json +6 -4
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wolink ESL GATT service ("WOLINKBLEESL2020") and characteristic UUIDs, transcribed
|
|
3
|
+
* from the reference driver (examples/device_driver/zhunyco/wolink_ble.py).
|
|
4
|
+
*/
|
|
5
|
+
export declare const WOLINK_SERVICE_UUID = "30323032-4c53-4545-4c42-4b4e494c4f57";
|
|
6
|
+
/** BLE manufacturer ID Zhsunyco/Wolink devices advertise under. */
|
|
7
|
+
export declare const ZHSUNYCO_MANUFACTURER_ID = 48042;
|
|
8
|
+
export declare const WOLINK_CHARACTERISTIC_UUIDS: {
|
|
9
|
+
readonly data: "31323032-4c53-4545-4c42-4b4e494c4f57";
|
|
10
|
+
readonly config: "32323032-4c53-4545-4c42-4b4e494c4f57";
|
|
11
|
+
readonly authenticate: "33323032-4c53-4545-4c42-4b4e494c4f57";
|
|
12
|
+
readonly status: "34323032-4c53-4545-4c42-4b4e494c4f57";
|
|
13
|
+
readonly battery: "35323032-4c53-4545-4c42-4b4e494c4f57";
|
|
14
|
+
};
|
|
15
|
+
export declare const COMMAND: {
|
|
16
|
+
readonly uploadBlock: 42240;
|
|
17
|
+
readonly refreshUncompressed: 42241;
|
|
18
|
+
};
|
|
19
|
+
export interface AdvertisedDeviceInfo {
|
|
20
|
+
pid: number;
|
|
21
|
+
appVersion: string;
|
|
22
|
+
hwVersion: string;
|
|
23
|
+
}
|
|
24
|
+
/** Decodes the 8-byte header shared by the advertising mirror and config characteristic. */
|
|
25
|
+
export declare function decodeAdvertisedInfo(data: Buffer): AdvertisedDeviceInfo | undefined;
|
|
26
|
+
export declare function decodeBatteryMv(data: Buffer): number;
|
|
27
|
+
export declare function decodeStatus(data: Buffer): {
|
|
28
|
+
busy: boolean;
|
|
29
|
+
errorCode: number;
|
|
30
|
+
};
|
|
31
|
+
/** Builds the 6-byte command header: 2-byte LE command word + 4-byte LE offset/length. */
|
|
32
|
+
export declare function commandHeader(command: number, offsetOrLength?: number): Buffer;
|
|
33
|
+
/**
|
|
34
|
+
* Stock BLE auth key shared by Wolink ESL devices out of the box (same bytes as the
|
|
35
|
+
* reference driver's `BLE_SECRET_KEY`). Used as a fallback when a device hasn't been
|
|
36
|
+
* given its own key via the plugin config.
|
|
37
|
+
*/
|
|
38
|
+
export declare const DEFAULT_BLE_AUTH: number[];
|
|
39
|
+
/** Resolves the configured per-device hex key, falling back to `DEFAULT_BLE_AUTH`. */
|
|
40
|
+
export declare function resolveAesKey(aesKeyHex?: string): Buffer;
|
|
41
|
+
/**
|
|
42
|
+
* Encrypts the device's auth challenge with the AES-128 key (CBC, zero IV).
|
|
43
|
+
*
|
|
44
|
+
* The reference driver PKCS7-pads the challenge before encrypting and keeps only the
|
|
45
|
+
* first ciphertext block. Since CBC's first output block depends only on the IV and the
|
|
46
|
+
* plaintext's first block, that's equivalent to encrypting the (already block-sized)
|
|
47
|
+
* challenge directly with padding disabled — so we skip the pad-then-truncate round trip.
|
|
48
|
+
*/
|
|
49
|
+
export declare function authResponse(challenge: Buffer, key: Buffer): Buffer;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DEFAULT_BLE_AUTH = exports.COMMAND = exports.WOLINK_CHARACTERISTIC_UUIDS = exports.ZHSUNYCO_MANUFACTURER_ID = exports.WOLINK_SERVICE_UUID = void 0;
|
|
4
|
+
exports.decodeAdvertisedInfo = decodeAdvertisedInfo;
|
|
5
|
+
exports.decodeBatteryMv = decodeBatteryMv;
|
|
6
|
+
exports.decodeStatus = decodeStatus;
|
|
7
|
+
exports.commandHeader = commandHeader;
|
|
8
|
+
exports.resolveAesKey = resolveAesKey;
|
|
9
|
+
exports.authResponse = authResponse;
|
|
10
|
+
const crypto_1 = require("crypto");
|
|
11
|
+
/**
|
|
12
|
+
* Wolink ESL GATT service ("WOLINKBLEESL2020") and characteristic UUIDs, transcribed
|
|
13
|
+
* from the reference driver (examples/device_driver/zhunyco/wolink_ble.py).
|
|
14
|
+
*/
|
|
15
|
+
exports.WOLINK_SERVICE_UUID = '30323032-4c53-4545-4c42-4b4e494c4f57';
|
|
16
|
+
/** BLE manufacturer ID Zhsunyco/Wolink devices advertise under. */
|
|
17
|
+
exports.ZHSUNYCO_MANUFACTURER_ID = 0xbbaa;
|
|
18
|
+
exports.WOLINK_CHARACTERISTIC_UUIDS = {
|
|
19
|
+
data: '31323032-4c53-4545-4c42-4b4e494c4f57',
|
|
20
|
+
config: '32323032-4c53-4545-4c42-4b4e494c4f57',
|
|
21
|
+
authenticate: '33323032-4c53-4545-4c42-4b4e494c4f57',
|
|
22
|
+
status: '34323032-4c53-4545-4c42-4b4e494c4f57',
|
|
23
|
+
battery: '35323032-4c53-4545-4c42-4b4e494c4f57',
|
|
24
|
+
};
|
|
25
|
+
exports.COMMAND = {
|
|
26
|
+
uploadBlock: 0xa500,
|
|
27
|
+
refreshUncompressed: 0xa501,
|
|
28
|
+
};
|
|
29
|
+
/** Decodes the 8-byte header shared by the advertising mirror and config characteristic. */
|
|
30
|
+
function decodeAdvertisedInfo(data) {
|
|
31
|
+
if (data.length < 8) {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
pid: data.readUInt16BE(2),
|
|
36
|
+
appVersion: data.readUInt16BE(4).toString(16).padStart(4, '0'),
|
|
37
|
+
hwVersion: data.readUInt16BE(6).toString(16).padStart(4, '0'),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function decodeBatteryMv(data) {
|
|
41
|
+
return data.readUInt16BE(data.length - 2);
|
|
42
|
+
}
|
|
43
|
+
function decodeStatus(data) {
|
|
44
|
+
return { busy: data[0] === 0xff, errorCode: data[1] };
|
|
45
|
+
}
|
|
46
|
+
/** Builds the 6-byte command header: 2-byte LE command word + 4-byte LE offset/length. */
|
|
47
|
+
function commandHeader(command, offsetOrLength = 0) {
|
|
48
|
+
const header = Buffer.alloc(6);
|
|
49
|
+
header.writeUInt16LE(command, 0);
|
|
50
|
+
header.writeUInt32LE(offsetOrLength, 2);
|
|
51
|
+
return header;
|
|
52
|
+
}
|
|
53
|
+
const AES_KEY_LENGTH = 16;
|
|
54
|
+
const AES_CHALLENGE_LENGTH = 16;
|
|
55
|
+
/**
|
|
56
|
+
* Stock BLE auth key shared by Wolink ESL devices out of the box (same bytes as the
|
|
57
|
+
* reference driver's `BLE_SECRET_KEY`). Used as a fallback when a device hasn't been
|
|
58
|
+
* given its own key via the plugin config.
|
|
59
|
+
*/
|
|
60
|
+
exports.DEFAULT_BLE_AUTH = [
|
|
61
|
+
155, 96, 159, 40, 188, 73, 226, 87,
|
|
62
|
+
41, 189, 123, 141, 242, 43, 68, 32,
|
|
63
|
+
];
|
|
64
|
+
/** Resolves the configured per-device hex key, falling back to `DEFAULT_BLE_AUTH`. */
|
|
65
|
+
function resolveAesKey(aesKeyHex) {
|
|
66
|
+
return aesKeyHex ? Buffer.from(aesKeyHex, 'hex') : Buffer.from(exports.DEFAULT_BLE_AUTH);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Encrypts the device's auth challenge with the AES-128 key (CBC, zero IV).
|
|
70
|
+
*
|
|
71
|
+
* The reference driver PKCS7-pads the challenge before encrypting and keeps only the
|
|
72
|
+
* first ciphertext block. Since CBC's first output block depends only on the IV and the
|
|
73
|
+
* plaintext's first block, that's equivalent to encrypting the (already block-sized)
|
|
74
|
+
* challenge directly with padding disabled — so we skip the pad-then-truncate round trip.
|
|
75
|
+
*/
|
|
76
|
+
function authResponse(challenge, key) {
|
|
77
|
+
if (key.length !== AES_KEY_LENGTH) {
|
|
78
|
+
throw new Error(`zhsunyco AES key must be ${AES_KEY_LENGTH} bytes (${AES_KEY_LENGTH * 2} hex chars), got ${key.length}`);
|
|
79
|
+
}
|
|
80
|
+
if (challenge.length !== AES_CHALLENGE_LENGTH) {
|
|
81
|
+
throw new Error(`zhsunyco auth challenge expected ${AES_CHALLENGE_LENGTH} bytes, got ${challenge.length}`);
|
|
82
|
+
}
|
|
83
|
+
const cipher = (0, crypto_1.createCipheriv)('aes-128-cbc', key, Buffer.alloc(16));
|
|
84
|
+
cipher.setAutoPadding(false);
|
|
85
|
+
return Buffer.concat([cipher.update(challenge), cipher.final()]);
|
|
86
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetches and parses a JSON endpoint, with an error message that's actually actionable: which URL,
|
|
3
|
+
* what HTTP status, and the response/underlying error detail - see `describeCause` for why plain
|
|
4
|
+
* `fetch()` failures need unwrapping to be useful at all.
|
|
5
|
+
*/
|
|
6
|
+
export declare function fetchJson(url: string): Promise<unknown>;
|
package/dist/httpJson.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.fetchJson = fetchJson;
|
|
4
|
+
/**
|
|
5
|
+
* Describes a network-level fetch failure's `error.cause` - Node's `fetch()` only ever throws a
|
|
6
|
+
* generic `TypeError: fetch failed`, with the actually useful detail (e.g. ECONNREFUSED) nested in
|
|
7
|
+
* `.cause`, and a multi-address connection attempt (e.g. trying both IPv6 and IPv4 for "localhost")
|
|
8
|
+
* surfaces as an `AggregateError` whose own `.message` is empty - the real detail is in `.errors`.
|
|
9
|
+
*/
|
|
10
|
+
function describeCause(cause) {
|
|
11
|
+
const errors = cause.errors;
|
|
12
|
+
if (Array.isArray(errors)) {
|
|
13
|
+
return errors.map(describeCause).join('; ');
|
|
14
|
+
}
|
|
15
|
+
if (cause instanceof Error) {
|
|
16
|
+
return cause.message || cause.code || cause.toString();
|
|
17
|
+
}
|
|
18
|
+
return String(cause);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Fetches and parses a JSON endpoint, with an error message that's actually actionable: which URL,
|
|
22
|
+
* what HTTP status, and the response/underlying error detail - see `describeCause` for why plain
|
|
23
|
+
* `fetch()` failures need unwrapping to be useful at all.
|
|
24
|
+
*/
|
|
25
|
+
async function fetchJson(url) {
|
|
26
|
+
let response;
|
|
27
|
+
try {
|
|
28
|
+
response = await fetch(url);
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
const cause = err.cause;
|
|
32
|
+
const detail = cause !== undefined ? describeCause(cause) : err.message;
|
|
33
|
+
throw new Error(`fetch failed: ${url} - ${detail}`);
|
|
34
|
+
}
|
|
35
|
+
if (!response.ok) {
|
|
36
|
+
const body = await response.text().catch(() => '');
|
|
37
|
+
throw new Error(`fetch failed: ${url} (${response.status} ${response.statusText})${body ? ` - ${body}` : ''}`);
|
|
38
|
+
}
|
|
39
|
+
return response.json();
|
|
40
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { ServerAPI, Plugin } from '@signalk/server-api';
|
|
2
|
+
import { registerDriver, getDriver, allDrivers } from './devices/registry';
|
|
3
|
+
import type { VendorDriver as VendorDriverType, DeviceMetadata as DeviceMetadataType, DiscoveredDevice as DiscoveredDeviceType, VendorDeviceConfig as VendorDeviceConfigType, Colour as ColourType } from './devices/types';
|
|
4
|
+
/**
|
|
5
|
+
* Public extension point for vendor packages. A package that adds support for a new
|
|
6
|
+
* ESL vendor (e.g. `signalk-esl-shoplabelcorp-plugin`) imports this module and calls
|
|
7
|
+
* `plugin.registerDriver(new ShopLabelCorpDriver())` from its own SignalK plugin's
|
|
8
|
+
* `start()` (or at module load time). There's no scanning of installed packages -
|
|
9
|
+
* registration is always an explicit call by the extension's own code.
|
|
10
|
+
*
|
|
11
|
+
* Declare this package as a `peerDependency` (not a regular dependency) in the
|
|
12
|
+
* extension package, so npm resolves a single shared copy - otherwise the extension
|
|
13
|
+
* would register into a different registry instance than the one this plugin reads from.
|
|
14
|
+
*/
|
|
15
|
+
declare function plugin(app: ServerAPI): Plugin;
|
|
16
|
+
declare namespace plugin {
|
|
17
|
+
const registerVendorDriver: typeof registerDriver;
|
|
18
|
+
const getVendorDriver: typeof getDriver;
|
|
19
|
+
const allVendorDrivers: typeof allDrivers;
|
|
20
|
+
type VendorDriver = VendorDriverType;
|
|
21
|
+
type DeviceMetadata = DeviceMetadataType;
|
|
22
|
+
type DiscoveredDevice = DiscoveredDeviceType;
|
|
23
|
+
type VendorDeviceConfig = VendorDeviceConfigType;
|
|
24
|
+
type Colour = ColourType;
|
|
25
|
+
}
|
|
26
|
+
export = plugin;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const plugin_1 = require("./plugin");
|
|
3
|
+
const registry_1 = require("./devices/registry");
|
|
4
|
+
/**
|
|
5
|
+
* Public extension point for vendor packages. A package that adds support for a new
|
|
6
|
+
* ESL vendor (e.g. `signalk-esl-shoplabelcorp-plugin`) imports this module and calls
|
|
7
|
+
* `plugin.registerDriver(new ShopLabelCorpDriver())` from its own SignalK plugin's
|
|
8
|
+
* `start()` (or at module load time). There's no scanning of installed packages -
|
|
9
|
+
* registration is always an explicit call by the extension's own code.
|
|
10
|
+
*
|
|
11
|
+
* Declare this package as a `peerDependency` (not a regular dependency) in the
|
|
12
|
+
* extension package, so npm resolves a single shared copy - otherwise the extension
|
|
13
|
+
* would register into a different registry instance than the one this plugin reads from.
|
|
14
|
+
*/
|
|
15
|
+
function plugin(app) {
|
|
16
|
+
return (0, plugin_1.createPlugin)(app);
|
|
17
|
+
}
|
|
18
|
+
(function (plugin) {
|
|
19
|
+
plugin.registerVendorDriver = registry_1.registerDriver;
|
|
20
|
+
plugin.getVendorDriver = registry_1.getDriver;
|
|
21
|
+
plugin.allVendorDrivers = registry_1.allDrivers;
|
|
22
|
+
})(plugin || (plugin = {}));
|
|
23
|
+
module.exports = plugin;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { DisplayUnits } from './render/formatters';
|
|
2
|
+
export interface PathMetadata {
|
|
3
|
+
units?: string;
|
|
4
|
+
description?: string;
|
|
5
|
+
displayUnits?: DisplayUnits;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Fetches all of a vessel's per-path metadata in one request - `GET .../vessels/<context>/meta` returns
|
|
9
|
+
* a flat `{ "<dotted.path>": { units, description, displayUnits? }, ... }` map, with `displayUnits`
|
|
10
|
+
* already fully resolved (category/targetUnit/formula/symbol) server-side. That resolution
|
|
11
|
+
* (`enhanceMetadataResponse` in signalk-server's `src/interfaces/rest.js`) has no in-process equivalent
|
|
12
|
+
* reachable via the plugin API (confirmed against the signalk-server source - `app.getMetadata` is
|
|
13
|
+
* bound directly to the unenhanced `@signalk/path-metadata` package), so both the live plugin
|
|
14
|
+
* (repaintScheduler.ts) and the CLI (cli/liveContext.ts) fetch it the same way, over HTTP.
|
|
15
|
+
*/
|
|
16
|
+
export declare function fetchPathMeta(apiUrl: string, context: string): Promise<Record<string, PathMetadata>>;
|
package/dist/pathMeta.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.fetchPathMeta = fetchPathMeta;
|
|
4
|
+
const httpJson_1 = require("./httpJson");
|
|
5
|
+
/** `self` -> `vessels/self`, `vessels.urn:mrn:imo:mmsi:1` -> `vessels/urn:mrn:imo:mmsi:1` - matches the REST path for that context's whole-vessel metadata. */
|
|
6
|
+
function metaContextPath(context) {
|
|
7
|
+
return context === 'self' ? 'vessels/self' : context.replace(/\./g, '/');
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Fetches all of a vessel's per-path metadata in one request - `GET .../vessels/<context>/meta` returns
|
|
11
|
+
* a flat `{ "<dotted.path>": { units, description, displayUnits? }, ... }` map, with `displayUnits`
|
|
12
|
+
* already fully resolved (category/targetUnit/formula/symbol) server-side. That resolution
|
|
13
|
+
* (`enhanceMetadataResponse` in signalk-server's `src/interfaces/rest.js`) has no in-process equivalent
|
|
14
|
+
* reachable via the plugin API (confirmed against the signalk-server source - `app.getMetadata` is
|
|
15
|
+
* bound directly to the unenhanced `@signalk/path-metadata` package), so both the live plugin
|
|
16
|
+
* (repaintScheduler.ts) and the CLI (cli/liveContext.ts) fetch it the same way, over HTTP.
|
|
17
|
+
*/
|
|
18
|
+
async function fetchPathMeta(apiUrl, context) {
|
|
19
|
+
const url = `${apiUrl}/signalk/v1/api/${metaContextPath(context)}/meta`;
|
|
20
|
+
return (await (0, httpJson_1.fetchJson)(url));
|
|
21
|
+
}
|
package/dist/plugin.d.ts
ADDED
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createPlugin = createPlugin;
|
|
4
|
+
const config_1 = require("./config");
|
|
5
|
+
const registry_1 = require("./devices/registry");
|
|
6
|
+
const zhsunyco_1 = require("./devices/zhsunyco");
|
|
7
|
+
const bleDiscovery_1 = require("./devices/bleDiscovery");
|
|
8
|
+
const repaintScheduler_1 = require("./repaintScheduler");
|
|
9
|
+
/** Mirrors signalk-bluetti-plugin's convention: scan briefly, report finds via plugin status for the user to copy-paste. */
|
|
10
|
+
async function runStartupScan(app, discovered, durationSeconds) {
|
|
11
|
+
app.setPluginStatus(`Scanning for ESL devices for ${durationSeconds}s...`);
|
|
12
|
+
const startedAt = Date.now();
|
|
13
|
+
let scanError;
|
|
14
|
+
const drivers = (0, registry_1.allDrivers)();
|
|
15
|
+
try {
|
|
16
|
+
await (0, bleDiscovery_1.withDiscovery)(durationSeconds * 1000, async (adapter) => {
|
|
17
|
+
await (0, bleDiscovery_1.forEachAdvertisedDevice)(adapter, async ({ device, address, name, manufacturerId, manufacturerData }) => {
|
|
18
|
+
const driver = drivers.find((candidate) => candidate.matchesAdvertisement(name, manufacturerId));
|
|
19
|
+
if (!driver) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const found = await driver.identifyDevice(device, address, name, manufacturerId, manufacturerData).catch((err) => {
|
|
23
|
+
scanError = `${driver.vendor} scan failed: ${err.message}`;
|
|
24
|
+
app.debug(`${scanError}\n${err.stack ?? ''}`);
|
|
25
|
+
return undefined;
|
|
26
|
+
});
|
|
27
|
+
if (!found) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
discovered.push(found);
|
|
31
|
+
const pid = found.pid !== undefined ? `0x${found.pid.toString(16).padStart(4, '0')}` : 'unknown';
|
|
32
|
+
app.debug(`discovered ${driver.vendor} device "${found.name ?? ''}" [${found.address}] pid=${pid}`);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
// Without this, an error thrown anywhere in the discovery window (e.g. BlueZ dropping the
|
|
38
|
+
// D-Bus connection) skips every line below, leaving the admin UI stuck on "Scanning..."
|
|
39
|
+
// forever even though `discovered` may already hold devices found before the failure.
|
|
40
|
+
scanError = `scan failed: ${err.message}`;
|
|
41
|
+
app.debug(`${scanError}\n${err.stack ?? ''}`);
|
|
42
|
+
}
|
|
43
|
+
// Surfaces the real cause in the admin UI (instead of only the debug log) - a scan that
|
|
44
|
+
// ends in well under its configured duration is almost always this, not "no devices nearby".
|
|
45
|
+
app.setPluginError(scanError ?? '');
|
|
46
|
+
const elapsedSeconds = ((Date.now() - startedAt) / 1000).toFixed(1);
|
|
47
|
+
if (discovered.length === 0) {
|
|
48
|
+
app.setPluginStatus(`Scan complete - no ESL devices found nearby after ${elapsedSeconds} seconds.`);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const summary = discovered.map((device) => `${device.name ?? device.vendor} [${device.address}]`).join(', ');
|
|
52
|
+
app.setPluginStatus(`Scan complete - found ${discovered.length} device(s) in ${elapsedSeconds}s: ${summary} - pick one from a device's "Device" field below`);
|
|
53
|
+
}
|
|
54
|
+
function createPlugin(app) {
|
|
55
|
+
(0, registry_1.registerDriver)(new zhsunyco_1.ZhsunycoDriver());
|
|
56
|
+
let scheduler;
|
|
57
|
+
const lastDiscovered = [];
|
|
58
|
+
// node-ble/BlueZ has no scan-cancellation API, so a scan started by a previous start()
|
|
59
|
+
// keeps running its full duration even after stop() - tracking it here stops a quick
|
|
60
|
+
// disable/re-enable from opening a second concurrent D-Bus/BlueZ session, which was
|
|
61
|
+
// making the new scan fail (and report "no devices found") almost immediately.
|
|
62
|
+
let scanInProgress;
|
|
63
|
+
let scanStartedAt;
|
|
64
|
+
const plugin = {
|
|
65
|
+
id: 'signalk-einklabel-plugin',
|
|
66
|
+
name: 'eInk ESL (Electronic Shelf Label)',
|
|
67
|
+
description: 'Renders selected SignalK data to BLE eInk Electronic Shelf Labels',
|
|
68
|
+
schema: () => (0, config_1.configSchema)(app, lastDiscovered),
|
|
69
|
+
uiSchema: () => (0, config_1.configUiSchema)(),
|
|
70
|
+
start(config) {
|
|
71
|
+
const pluginConfig = { ...(0, config_1.defaultConfig)(), ...config };
|
|
72
|
+
app.debug(`starting with ${pluginConfig.devices.length} configured device(s)`);
|
|
73
|
+
if (pluginConfig.scanOnStart) {
|
|
74
|
+
if (scanInProgress) {
|
|
75
|
+
const elapsedSeconds = ((Date.now() - (scanStartedAt ?? Date.now())) / 1000).toFixed(0);
|
|
76
|
+
app.debug(`a scan from before this restart is still running (${elapsedSeconds}s) - skipping a new one to avoid a second concurrent BLE session`);
|
|
77
|
+
app.setPluginStatus(`Skipped startup scan - a scan from before this restart is still finishing (running ${elapsedSeconds}s) and will update the "Device" picker below once it does.`);
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
lastDiscovered.length = 0;
|
|
81
|
+
scanStartedAt = Date.now();
|
|
82
|
+
const scan = runStartupScan(app, lastDiscovered, pluginConfig.scanDurationSeconds).catch((err) => app.debug(`startup scan failed: ${err.message}`));
|
|
83
|
+
scanInProgress = scan;
|
|
84
|
+
scan.finally(() => {
|
|
85
|
+
scanInProgress = undefined;
|
|
86
|
+
scanStartedAt = undefined;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
scheduler = (0, repaintScheduler_1.startRepaintScheduler)(app, pluginConfig);
|
|
91
|
+
},
|
|
92
|
+
stop() {
|
|
93
|
+
scheduler?.stop();
|
|
94
|
+
scheduler = undefined;
|
|
95
|
+
app.debug('stopped');
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
return plugin;
|
|
99
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { TemplateContext } from './types';
|
|
2
|
+
declare const SOURCES: readonly ["signalk", "resources", "einklabel"];
|
|
3
|
+
type Source = (typeof SOURCES)[number];
|
|
4
|
+
/**
|
|
5
|
+
* Parsed form of a `<desc>`'s `key=value,key=value` content - see `parseBinding` for the grammar.
|
|
6
|
+
*/
|
|
7
|
+
export interface Binding {
|
|
8
|
+
source: Source;
|
|
9
|
+
/** `'self'` (default) or any other literal SignalK context as shown in the Data Browser, e.g. `vessels.urn:mrn:imo:mmsi:232345678`. */
|
|
10
|
+
context: string;
|
|
11
|
+
/** Required when `source === 'resources'` - the Resources API resource type, e.g. `tides`, `waypoints`. */
|
|
12
|
+
resource?: string;
|
|
13
|
+
/** For `source === 'einklabel'`, a dotted path into the plugin's own injected `meta` (e.g. `repainted`), rather than into vessel/resource data. */
|
|
14
|
+
path: string;
|
|
15
|
+
/** A named formatter (see `./formatters.ts`), or `'raw'` to suppress automatic unit conversion (see `renderBinding`). */
|
|
16
|
+
format?: string;
|
|
17
|
+
/** Explicit unit-preferences category (e.g. `depth`, `speed`, `temperature`) for a numeric value with no path metadata of its own, e.g. a `source=resources` value - see `../unitCategories.ts`. */
|
|
18
|
+
category?: string;
|
|
19
|
+
round?: number;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Parses a `<desc>` element's text content into a `Binding`, e.g.
|
|
23
|
+
* `source=resources,resource=tides,path=extremes.[0].level,category=depth,round=2` or, using the
|
|
24
|
+
* defaults (`source=signalk,context=self`), plain `path=navigation.speedOverGround` (auto-converts via
|
|
25
|
+
* that path's own metadata - see `renderBinding`). A bare path with no `key=value` pairs at all, e.g.
|
|
26
|
+
* `environment.forecast.description`, is shorthand for `path=environment.forecast.description`
|
|
27
|
+
* (source/context still default to signalk/self) - SignalK paths never contain `=`, so its absence
|
|
28
|
+
* unambiguously signals this shorthand.
|
|
29
|
+
*/
|
|
30
|
+
export declare function parseBinding(desc: string): Binding;
|
|
31
|
+
/**
|
|
32
|
+
* Parses every `<text>` element's `<desc>` binding out of raw SVG source - lets a caller discover what
|
|
33
|
+
* data a template needs before fetching anything, with no separate config declaring it (see
|
|
34
|
+
* `assembleRawContext` in repaintScheduler.ts).
|
|
35
|
+
*/
|
|
36
|
+
export declare function findBindings(svgSource: string): Binding[];
|
|
37
|
+
/** Resolves a parsed `Binding` against the render context assembled by `assembleRawContext`. */
|
|
38
|
+
export declare function resolveBinding(binding: Binding, context: TemplateContext): unknown;
|
|
39
|
+
/**
|
|
40
|
+
* Resolves a binding and renders it to text exactly as `SvgRenderer` does for a `<desc>` - shared so
|
|
41
|
+
* the CLI's `field`/`fields` commands show the same thing a real render would.
|
|
42
|
+
*
|
|
43
|
+
* Precedence for a numeric value:
|
|
44
|
+
* 1. An explicit named `format=` (anything other than `raw`) - `local_time`/`utc_offset`/`position`.
|
|
45
|
+
* 2. An explicit `category=` - for values with no path metadata of their own, e.g. a `source=resources`
|
|
46
|
+
* value.
|
|
47
|
+
* 3. Otherwise, a `signalk`-sourced value auto-converts to its path's own preferred display unit (from
|
|
48
|
+
* `context.pathMeta`) by default - `format=raw` opts out of this step only.
|
|
49
|
+
* 4. Falls through to `round=` (`toFixed`), `JSON.stringify` for an unformatted object/array value
|
|
50
|
+
* (e.g. a path that resolved to a whole sub-tree rather than a leaf) instead of the useless
|
|
51
|
+
* `String(value)` -> `"[object Object]"`, else `String`.
|
|
52
|
+
*/
|
|
53
|
+
export declare function renderBinding(binding: Binding, context: TemplateContext): string;
|
|
54
|
+
export {};
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.parseBinding = parseBinding;
|
|
4
|
+
exports.findBindings = findBindings;
|
|
5
|
+
exports.resolveBinding = resolveBinding;
|
|
6
|
+
exports.renderBinding = renderBinding;
|
|
7
|
+
const xmldom_1 = require("@xmldom/xmldom");
|
|
8
|
+
const formatters_1 = require("./formatters");
|
|
9
|
+
const SOURCES = ['signalk', 'resources', 'einklabel'];
|
|
10
|
+
const KNOWN_KEYS = new Set(['source', 'context', 'resource', 'path', 'format', 'category', 'round']);
|
|
11
|
+
/**
|
|
12
|
+
* Parses a `<desc>` element's text content into a `Binding`, e.g.
|
|
13
|
+
* `source=resources,resource=tides,path=extremes.[0].level,category=depth,round=2` or, using the
|
|
14
|
+
* defaults (`source=signalk,context=self`), plain `path=navigation.speedOverGround` (auto-converts via
|
|
15
|
+
* that path's own metadata - see `renderBinding`). A bare path with no `key=value` pairs at all, e.g.
|
|
16
|
+
* `environment.forecast.description`, is shorthand for `path=environment.forecast.description`
|
|
17
|
+
* (source/context still default to signalk/self) - SignalK paths never contain `=`, so its absence
|
|
18
|
+
* unambiguously signals this shorthand.
|
|
19
|
+
*/
|
|
20
|
+
function parseBinding(desc) {
|
|
21
|
+
const trimmedDesc = desc.trim();
|
|
22
|
+
if (trimmedDesc && !trimmedDesc.includes('=')) {
|
|
23
|
+
return { source: 'signalk', context: 'self', path: trimmedDesc };
|
|
24
|
+
}
|
|
25
|
+
const fields = {};
|
|
26
|
+
for (const pair of desc.split(',')) {
|
|
27
|
+
const trimmed = pair.trim();
|
|
28
|
+
if (!trimmed)
|
|
29
|
+
continue;
|
|
30
|
+
const eq = trimmed.indexOf('=');
|
|
31
|
+
if (eq < 0) {
|
|
32
|
+
throw new Error(`invalid binding "${desc}" - expected "key=value" pairs, got "${trimmed}"`);
|
|
33
|
+
}
|
|
34
|
+
const key = trimmed.slice(0, eq).trim();
|
|
35
|
+
if (!KNOWN_KEYS.has(key)) {
|
|
36
|
+
throw new Error(`invalid binding "${desc}" - unknown key "${key}"`);
|
|
37
|
+
}
|
|
38
|
+
fields[key] = trimmed.slice(eq + 1).trim();
|
|
39
|
+
}
|
|
40
|
+
const source = (fields.source ?? 'signalk');
|
|
41
|
+
if (!SOURCES.includes(source)) {
|
|
42
|
+
throw new Error(`invalid binding "${desc}" - unknown source "${source}"`);
|
|
43
|
+
}
|
|
44
|
+
const context = fields.context ?? 'self';
|
|
45
|
+
if (source === 'resources' && !fields.resource) {
|
|
46
|
+
throw new Error(`invalid binding "${desc}" - source=resources requires a "resource" key`);
|
|
47
|
+
}
|
|
48
|
+
if (!fields.path) {
|
|
49
|
+
throw new Error(`invalid binding "${desc}" - missing required "path" key`);
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
source,
|
|
53
|
+
context,
|
|
54
|
+
resource: fields.resource,
|
|
55
|
+
path: fields.path,
|
|
56
|
+
format: fields.format,
|
|
57
|
+
category: fields.category,
|
|
58
|
+
round: fields.round !== undefined ? Number(fields.round) : undefined,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Parses every `<text>` element's `<desc>` binding out of raw SVG source - lets a caller discover what
|
|
63
|
+
* data a template needs before fetching anything, with no separate config declaring it (see
|
|
64
|
+
* `assembleRawContext` in repaintScheduler.ts).
|
|
65
|
+
*/
|
|
66
|
+
function findBindings(svgSource) {
|
|
67
|
+
const doc = new xmldom_1.DOMParser().parseFromString(svgSource, 'image/svg+xml');
|
|
68
|
+
const elements = doc.getElementsByTagName('text');
|
|
69
|
+
const bindings = [];
|
|
70
|
+
for (let i = 0; i < elements.length; i++) {
|
|
71
|
+
const desc = elements.item(i)?.getElementsByTagName('desc').item(0);
|
|
72
|
+
if (desc?.textContent) {
|
|
73
|
+
bindings.push(parseBinding(desc.textContent));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return bindings;
|
|
77
|
+
}
|
|
78
|
+
/** Supports both `a.[0].b` and `a[0].b` array index notation, matching `setAtPath` in repaintScheduler.ts. */
|
|
79
|
+
function getAtPath(obj, path) {
|
|
80
|
+
const segments = path
|
|
81
|
+
.replace(/\[(\d+)\]/g, '.$1')
|
|
82
|
+
.split('.')
|
|
83
|
+
.filter((segment) => segment.length > 0);
|
|
84
|
+
let node = obj;
|
|
85
|
+
for (const segment of segments) {
|
|
86
|
+
if (node === null || typeof node !== 'object')
|
|
87
|
+
return undefined;
|
|
88
|
+
node = node[segment];
|
|
89
|
+
}
|
|
90
|
+
return node;
|
|
91
|
+
}
|
|
92
|
+
/** Resolves a parsed `Binding` against the render context assembled by `assembleRawContext`. */
|
|
93
|
+
function resolveBinding(binding, context) {
|
|
94
|
+
if (binding.source === 'signalk') {
|
|
95
|
+
const signalk = context.signalk;
|
|
96
|
+
const vessel = signalk?.[binding.context];
|
|
97
|
+
if (vessel === undefined) {
|
|
98
|
+
throw new Error(`binding references context "${binding.context}" which is not present in the render context`);
|
|
99
|
+
}
|
|
100
|
+
return getAtPath(vessel, binding.path);
|
|
101
|
+
}
|
|
102
|
+
if (binding.source === 'einklabel') {
|
|
103
|
+
const meta = context.meta;
|
|
104
|
+
if (meta === undefined) {
|
|
105
|
+
throw new Error('binding references source "einklabel" but no "meta" is present in the render context');
|
|
106
|
+
}
|
|
107
|
+
return getAtPath(meta, binding.path);
|
|
108
|
+
}
|
|
109
|
+
const resources = context.resources;
|
|
110
|
+
const resource = resources?.[binding.resource];
|
|
111
|
+
if (resource === undefined) {
|
|
112
|
+
throw new Error(`binding references resource "${binding.resource}" which is not present in the render context`);
|
|
113
|
+
}
|
|
114
|
+
return getAtPath(resource, binding.path);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Looks up a `signalk`-sourced binding's path in `context.pathMeta` - a flat `{ [context]:
|
|
118
|
+
* { [dottedPath]: { displayUnits } } }` map (see `../pathMeta.ts`), matching the flat shape
|
|
119
|
+
* `GET .../vessels/<context>/meta` itself returns, unlike `context.signalk`'s nested tree. Unlike
|
|
120
|
+
* `resolveBinding`, never throws - metadata is always best-effort (a `source=resources` binding, a
|
|
121
|
+
* path with no metadata, or a server unreachable at `signalkApiUrl` all resolve to "no metadata"
|
|
122
|
+
* rather than an error).
|
|
123
|
+
*/
|
|
124
|
+
function resolveDisplayUnits(binding, context) {
|
|
125
|
+
if (binding.source !== 'signalk')
|
|
126
|
+
return undefined;
|
|
127
|
+
const pathMeta = context.pathMeta;
|
|
128
|
+
return pathMeta?.[binding.context]?.[binding.path]?.displayUnits;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Looks up an explicit `category=` binding's resolved conversion info from `context.categories` (built
|
|
132
|
+
* by `fetchCategoryDisplayUnits` in `../unitCategories.ts`) - same throw-on-missing pattern as
|
|
133
|
+
* `resolveBinding`'s context/resource lookups, since naming a category is a declared dependency.
|
|
134
|
+
*/
|
|
135
|
+
function resolveCategoryDisplayUnits(binding, context) {
|
|
136
|
+
const categories = context.categories;
|
|
137
|
+
const displayUnits = categories?.[binding.category];
|
|
138
|
+
if (!displayUnits) {
|
|
139
|
+
throw new Error(`binding references category "${binding.category}" which is not present in the render context`);
|
|
140
|
+
}
|
|
141
|
+
return displayUnits;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Resolves a binding and renders it to text exactly as `SvgRenderer` does for a `<desc>` - shared so
|
|
145
|
+
* the CLI's `field`/`fields` commands show the same thing a real render would.
|
|
146
|
+
*
|
|
147
|
+
* Precedence for a numeric value:
|
|
148
|
+
* 1. An explicit named `format=` (anything other than `raw`) - `local_time`/`utc_offset`/`position`.
|
|
149
|
+
* 2. An explicit `category=` - for values with no path metadata of their own, e.g. a `source=resources`
|
|
150
|
+
* value.
|
|
151
|
+
* 3. Otherwise, a `signalk`-sourced value auto-converts to its path's own preferred display unit (from
|
|
152
|
+
* `context.pathMeta`) by default - `format=raw` opts out of this step only.
|
|
153
|
+
* 4. Falls through to `round=` (`toFixed`), `JSON.stringify` for an unformatted object/array value
|
|
154
|
+
* (e.g. a path that resolved to a whole sub-tree rather than a leaf) instead of the useless
|
|
155
|
+
* `String(value)` -> `"[object Object]"`, else `String`.
|
|
156
|
+
*/
|
|
157
|
+
function renderBinding(binding, context) {
|
|
158
|
+
const value = resolveBinding(binding, context);
|
|
159
|
+
if (binding.format && binding.format !== 'raw')
|
|
160
|
+
return (0, formatters_1.applyFormat)(binding.format, value, context, binding.round);
|
|
161
|
+
if (typeof value === 'number') {
|
|
162
|
+
if (binding.category)
|
|
163
|
+
return (0, formatters_1.formatDisplayUnits)(value, resolveCategoryDisplayUnits(binding, context), binding.round);
|
|
164
|
+
const displayUnits = binding.format === 'raw' ? undefined : resolveDisplayUnits(binding, context);
|
|
165
|
+
if (displayUnits)
|
|
166
|
+
return (0, formatters_1.formatDisplayUnits)(value, displayUnits, binding.round);
|
|
167
|
+
if (binding.round !== undefined)
|
|
168
|
+
return value.toFixed(binding.round);
|
|
169
|
+
}
|
|
170
|
+
if (value === null || value === undefined)
|
|
171
|
+
return '';
|
|
172
|
+
if (typeof value === 'object')
|
|
173
|
+
return JSON.stringify(value);
|
|
174
|
+
return String(value);
|
|
175
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One font per CSS generic family bucket (monospace/sans-serif/serif), bundled so rendering
|
|
3
|
+
* doesn't depend on the host having any fonts installed - resvg-wasm cannot see host fonts at
|
|
4
|
+
* all (see project memory). Must be .woff2 - resvg-wasm silently fails to render .woff.
|
|
5
|
+
* resvg auto-classifies each loaded font from its own metadata; there's no proven way to
|
|
6
|
+
* control which one wins if two loaded fonts share a generic bucket, so keep this to one per
|
|
7
|
+
* bucket rather than adding e.g. bold variants here.
|
|
8
|
+
*/
|
|
9
|
+
export declare const DEFAULT_FONT_PATHS: string[];
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DEFAULT_FONT_PATHS = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* One font per CSS generic family bucket (monospace/sans-serif/serif), bundled so rendering
|
|
6
|
+
* doesn't depend on the host having any fonts installed - resvg-wasm cannot see host fonts at
|
|
7
|
+
* all (see project memory). Must be .woff2 - resvg-wasm silently fails to render .woff.
|
|
8
|
+
* resvg auto-classifies each loaded font from its own metadata; there's no proven way to
|
|
9
|
+
* control which one wins if two loaded fonts share a generic bucket, so keep this to one per
|
|
10
|
+
* bucket rather than adding e.g. bold variants here.
|
|
11
|
+
*/
|
|
12
|
+
exports.DEFAULT_FONT_PATHS = [
|
|
13
|
+
require.resolve('@fontsource/jetbrains-mono/files/jetbrains-mono-latin-400-normal.woff2'),
|
|
14
|
+
require.resolve('@fontsource/roboto/files/roboto-latin-400-normal.woff2'),
|
|
15
|
+
require.resolve('@fontsource/playfair-display/files/playfair-display-latin-400-normal.woff2'),
|
|
16
|
+
];
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { TemplateContext } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* Matches `displayUnits` on a path's own metadata (`app.getMetadata(path).displayUnits` in the live
|
|
4
|
+
* plugin, the `.../meta` tree over HTTP in the CLI) - SignalK's per-path unit-preference info, fully
|
|
5
|
+
* resolved (formula/symbol ready to use), unlike the global `/signalk/v1/unitpreferences/active`
|
|
6
|
+
* endpoint which only gives a bare `targetUnit` name with no conversion math at all.
|
|
7
|
+
*/
|
|
8
|
+
export interface DisplayUnits {
|
|
9
|
+
category: string;
|
|
10
|
+
targetUnit: string;
|
|
11
|
+
formula?: string;
|
|
12
|
+
symbol?: string;
|
|
13
|
+
displayFormat?: string;
|
|
14
|
+
}
|
|
15
|
+
/** Converts a base-SI value (always what SignalK paths deliver) to its metadata's preferred display unit and formats it with the unit's symbol, e.g. 3.42 -> "11.2ft". */
|
|
16
|
+
export declare function formatDisplayUnits(value: number, displayUnits: DisplayUnits, round: number | undefined): string;
|
|
17
|
+
/** Applies a named `format=` formatter to a resolved binding value. */
|
|
18
|
+
export declare function applyFormat(name: string, value: unknown, context: TemplateContext, round: number | undefined): string;
|