@rhizomatics/signalk-esl-plugin 0.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.
Files changed (51) hide show
  1. package/README.md +109 -0
  2. package/dist/cli/index.d.ts +2 -0
  3. package/dist/cli/index.js +244 -0
  4. package/dist/cli/liveContext.d.ts +11 -0
  5. package/dist/cli/liveContext.js +72 -0
  6. package/dist/cli/log.d.ts +5 -0
  7. package/dist/cli/log.js +18 -0
  8. package/dist/config.d.ts +74 -0
  9. package/dist/config.js +193 -0
  10. package/dist/devices/bleDiscovery.d.ts +54 -0
  11. package/dist/devices/bleDiscovery.js +134 -0
  12. package/dist/devices/registry.d.ts +4 -0
  13. package/dist/devices/registry.js +15 -0
  14. package/dist/devices/types.d.ts +70 -0
  15. package/dist/devices/types.js +2 -0
  16. package/dist/devices/zhsunyco/encode.d.ts +12 -0
  17. package/dist/devices/zhsunyco/encode.js +47 -0
  18. package/dist/devices/zhsunyco/index.d.ts +11 -0
  19. package/dist/devices/zhsunyco/index.js +148 -0
  20. package/dist/devices/zhsunyco/metadata.d.ts +20 -0
  21. package/dist/devices/zhsunyco/metadata.js +98 -0
  22. package/dist/devices/zhsunyco/protocol.d.ts +49 -0
  23. package/dist/devices/zhsunyco/protocol.js +86 -0
  24. package/dist/httpJson.d.ts +6 -0
  25. package/dist/httpJson.js +40 -0
  26. package/dist/index.d.ts +26 -0
  27. package/dist/index.js +23 -0
  28. package/dist/pathMeta.d.ts +16 -0
  29. package/dist/pathMeta.js +21 -0
  30. package/dist/plugin.d.ts +2 -0
  31. package/dist/plugin.js +99 -0
  32. package/dist/render/binding.d.ts +53 -0
  33. package/dist/render/binding.js +168 -0
  34. package/dist/render/fonts.d.ts +9 -0
  35. package/dist/render/fonts.js +16 -0
  36. package/dist/render/formatters.d.ts +18 -0
  37. package/dist/render/formatters.js +78 -0
  38. package/dist/render/png.d.ts +3 -0
  39. package/dist/render/png.js +10 -0
  40. package/dist/render/svgRenderer.d.ts +32 -0
  41. package/dist/render/svgRenderer.js +80 -0
  42. package/dist/render/types.d.ts +26 -0
  43. package/dist/render/types.js +2 -0
  44. package/dist/repaintScheduler.d.ts +6 -0
  45. package/dist/repaintScheduler.js +193 -0
  46. package/dist/resolveApiUrl.d.ts +28 -0
  47. package/dist/resolveApiUrl.js +62 -0
  48. package/dist/unitCategories.d.ts +11 -0
  49. package/dist/unitCategories.js +46 -0
  50. package/package.json +70 -0
  51. package/templates/tide.svg +213 -0
package/dist/config.js ADDED
@@ -0,0 +1,193 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.defaultConfig = defaultConfig;
4
+ exports.resolveTemplatesDir = resolveTemplatesDir;
5
+ exports.parseDevice = parseDevice;
6
+ exports.resolveTemplatePath = resolveTemplatePath;
7
+ exports.configSchema = configSchema;
8
+ exports.configUiSchema = configUiSchema;
9
+ const fs_1 = require("fs");
10
+ const os_1 = require("os");
11
+ const path_1 = require("path");
12
+ const resolveApiUrl_1 = require("./resolveApiUrl");
13
+ /** The package's own bundled `templates/` directory (ships alongside `dist/`, see package.json's `files`) - templates here are always available, but a same-named template in the user's `templatesDir` takes priority. */
14
+ const BUNDLED_TEMPLATES_DIR = (0, path_1.join)(__dirname, '..', 'templates');
15
+ const SIGNALK_HOME_DIR = (0, path_1.join)((0, os_1.homedir)(), '.signalk');
16
+ const DEFAULT_TEMPLATES_DIR = (0, path_1.join)(SIGNALK_HOME_DIR, 'esl', 'templates');
17
+ function defaultConfig() {
18
+ return {
19
+ templatesDir: '',
20
+ scanOnStart: true,
21
+ scanDurationSeconds: 20,
22
+ paintConnectTimeoutSeconds: 30,
23
+ paintRetries: 3,
24
+ devices: [],
25
+ };
26
+ }
27
+ /**
28
+ * Resolves the user-facing `templatesDir` setting to an actual directory, mirroring
29
+ * signalk-parquet's `outputDirectory` convention: empty means the default location, a relative
30
+ * path is resolved against `~/.signalk` (where SignalK itself stores its config by default), and
31
+ * an absolute path is used as-is.
32
+ */
33
+ function resolveTemplatesDir(templatesDir) {
34
+ const trimmed = templatesDir?.trim();
35
+ if (!trimmed) {
36
+ return DEFAULT_TEMPLATES_DIR;
37
+ }
38
+ return (0, path_1.isAbsolute)(trimmed) ? trimmed : (0, path_1.join)(SIGNALK_HOME_DIR, trimmed);
39
+ }
40
+ /**
41
+ * Enum for the combined "device" field, built from recently scanned devices - including ones a
42
+ * driver identified as its vendor but whose PID isn't in its metadata table yet (clearly labelled,
43
+ * so the user can at least see what was found and report the PID; repainting such a device still
44
+ * does nothing without a model override, since there's no width/height to render with) - plus, as a
45
+ * fallback, whatever's already saved so an existing selection doesn't vanish from the dropdown just
46
+ * because this particular run hasn't re-scanned it yet.
47
+ */
48
+ function deviceOptions(discovered, current) {
49
+ const values = [];
50
+ const labels = [];
51
+ const seen = new Set();
52
+ for (const found of discovered) {
53
+ if (found.pid === undefined) {
54
+ continue;
55
+ }
56
+ const modelToken = [found.vendor, found.pid, found.hwVersion].filter((part) => part !== undefined).join(':');
57
+ const value = `${modelToken}@${found.address}`;
58
+ if (seen.has(value)) {
59
+ continue;
60
+ }
61
+ seen.add(value);
62
+ values.push(value);
63
+ const label = found.metadata
64
+ ? `${found.vendor} ${found.metadata.label} (${found.address})`
65
+ : `${found.vendor} unrecognised PID 0x${found.pid.toString(16).padStart(4, '0')} (${found.address})`;
66
+ labels.push(label);
67
+ }
68
+ for (const device of current.devices) {
69
+ if (!seen.has(device.device)) {
70
+ seen.add(device.device);
71
+ values.push(device.device);
72
+ labels.push(`${device.device} (not seen in last scan)`);
73
+ }
74
+ }
75
+ return { values, labels };
76
+ }
77
+ function parseDevice(device) {
78
+ const [modelToken, address] = device.split('@');
79
+ const [vendor, pidStr, hwVersion] = (modelToken ?? '').split(':');
80
+ const pid = Number(pidStr);
81
+ return vendor && address && Number.isInteger(pid) ? { vendor, pid, hwVersion, address } : undefined;
82
+ }
83
+ function listSvgFiles(dir) {
84
+ try {
85
+ return (0, fs_1.readdirSync)(dir).filter((name) => name.endsWith('.svg'));
86
+ }
87
+ catch {
88
+ return [];
89
+ }
90
+ }
91
+ /** Local templates take priority over a same-named bundled one; both show up as options. */
92
+ function templateNameOptions(templatesDir) {
93
+ const local = listSvgFiles(templatesDir);
94
+ const bundled = listSvgFiles(BUNDLED_TEMPLATES_DIR).filter((name) => !local.includes(name));
95
+ return [...local, ...bundled];
96
+ }
97
+ /** Resolves a template name to an actual file path - a local template overrides the bundled one of the same name. */
98
+ function resolveTemplatePath(templatesDir, templateName) {
99
+ const localPath = (0, path_1.join)(templatesDir, templateName);
100
+ return (0, fs_1.existsSync)(localPath) ? localPath : (0, path_1.join)(BUNDLED_TEMPLATES_DIR, templateName);
101
+ }
102
+ /** JSON Schema forbids an empty `enum` array, so only attach one when there's at least one option - otherwise the whole config schema fails validation. */
103
+ function withEnum(schema, values, names) {
104
+ return values.length > 0 ? { ...schema, enum: values, ...(names ? { enumNames: names } : {}) } : schema;
105
+ }
106
+ function configSchema(app, discovered = []) {
107
+ const defaults = defaultConfig();
108
+ const current = { ...defaults, ...app.readPluginOptions() };
109
+ const { values: deviceValues, labels: deviceLabels } = deviceOptions(discovered, current);
110
+ return {
111
+ type: 'object',
112
+ properties: {
113
+ templatesDir: {
114
+ type: 'string',
115
+ title: 'Templates directory',
116
+ description: `Relative path from ~/.signalk (e.g., "esl/templates" becomes ~/.signalk/esl/templates). ` +
117
+ `Leave empty for default (${DEFAULT_TEMPLATES_DIR}). Absolute paths also supported. A template here ` +
118
+ 'with the same name as a bundled one takes priority.',
119
+ default: defaults.templatesDir,
120
+ },
121
+ scanOnStart: {
122
+ type: 'boolean',
123
+ title: 'Scan for devices on plugin start',
124
+ description: 'Runs a short BLE scan so discovered devices show up in a device\'s "Device" picker below.',
125
+ default: defaults.scanOnStart,
126
+ },
127
+ scanDurationSeconds: {
128
+ type: 'number',
129
+ title: 'Scan duration (seconds)',
130
+ description: 'How long the startup scan runs - increase if devices are missing from the "Device" picker below.',
131
+ minimum: 1,
132
+ default: defaults.scanDurationSeconds,
133
+ },
134
+ paintConnectTimeoutSeconds: {
135
+ type: 'number',
136
+ title: 'Paint connect timeout (seconds)',
137
+ description: 'How long to wait for a device to accept a BLE connection before giving up on a repaint attempt.',
138
+ minimum: 1,
139
+ default: defaults.paintConnectTimeoutSeconds,
140
+ },
141
+ paintRetries: {
142
+ type: 'number',
143
+ title: 'Paint retries',
144
+ description: 'How many times to attempt a repaint (including the first try) before giving up and reporting failure.',
145
+ minimum: 1,
146
+ default: defaults.paintRetries,
147
+ },
148
+ signalkApiUrl: {
149
+ type: 'string',
150
+ title: 'SignalK API base URL (leave blank to auto-detect)',
151
+ description: 'Used for plugin access to SignalK REST APIs not yet integrated for direct plugin access. Left blank, the plugin probes the likely options at startup (3000, 80, 443 ) - only set this manually to skip probing. Anonymous read access is required.',
152
+ enum: ['', ...resolveApiUrl_1.SIGNALK_API_URL_OPTIONS],
153
+ },
154
+ devices: {
155
+ type: 'array',
156
+ title: 'Devices',
157
+ items: {
158
+ type: 'object',
159
+ required: ['friendlyName', 'device', 'templateName', 'repaintTrigger'],
160
+ properties: {
161
+ friendlyName: { type: 'string', title: 'Friendly name' },
162
+ device: withEnum({
163
+ type: 'string',
164
+ title: 'Device',
165
+ description: 'Picked from devices found by a scan (plugin start, or `esl-cli scan`) - sets both the model and BLE address.',
166
+ }, deviceValues, deviceLabels),
167
+ templateName: withEnum({ type: 'string', title: 'Template' }, templateNameOptions(resolveTemplatesDir(current.templatesDir))),
168
+ repaintTrigger: { type: 'string', title: 'Repaint trigger', enum: ['subscription', 'interval'] },
169
+ triggerPath: { type: 'string', title: 'Trigger SignalK path (if repaint trigger is subscription)' },
170
+ intervalHours: { type: 'number', title: 'Repaint every N hours (if repaint trigger is interval)', minimum: 1 },
171
+ intervalMinute: { type: 'number', title: 'Minutes past the hour (if repaint trigger is interval)', minimum: 0, maximum: 59, default: 0 },
172
+ aesKey: { type: 'string', title: 'BLE AES key (vendor-specific; leave blank to use a default key)' },
173
+ forceRepaint: {
174
+ type: 'boolean',
175
+ title: 'Force repaint',
176
+ description: 'Repaint even if the data is unchanged - clears itself automatically once that repaint completes',
177
+ default: false,
178
+ },
179
+ },
180
+ },
181
+ },
182
+ },
183
+ };
184
+ }
185
+ function configUiSchema() {
186
+ return {
187
+ devices: {
188
+ items: {
189
+ repaintTrigger: { 'ui:widget': 'radio' },
190
+ },
191
+ },
192
+ };
193
+ }
@@ -0,0 +1,54 @@
1
+ import { Adapter, Bluetooth, Device } from 'node-ble';
2
+ export declare function sleep(ms: number): Promise<void>;
3
+ /**
4
+ * Drop-in replacement for `node-ble`'s `createBluetooth` that fails fast and clearly when
5
+ * BLE isn't available at all, instead of letting `dbus-next` crash the whole process.
6
+ *
7
+ * `node-ble` connects to BlueZ over D-Bus, which only exists on Linux. On any other
8
+ * platform (e.g. a developer's Mac), the underlying `dbus-next` socket connection fails
9
+ * and emits an `'error'` event with no listener attached - Node treats that as an
10
+ * uncaught exception rather than a promise rejection, so no `try`/`catch` around
11
+ * `createBluetooth()` or its callers can see it; it takes the whole process down.
12
+ */
13
+ export declare function createBluetooth(): {
14
+ bluetooth: Bluetooth;
15
+ destroy: () => void;
16
+ };
17
+ /** BLE manufacturer ID advertised by the device, if any - the key of its manufacturer data map. */
18
+ export declare function getManufacturerId(device: Device): Promise<number | undefined>;
19
+ export interface AdvertisedDevice {
20
+ address: string;
21
+ device: Device;
22
+ name?: string;
23
+ manufacturerId?: number;
24
+ manufacturerData?: Buffer;
25
+ }
26
+ /**
27
+ * Reads each nearby device's advertisement exactly once and hands it to `fn` - shared by
28
+ * `plugin.ts`'s startup scan and the CLI's `scan` command so that identifying which vendor (if
29
+ * any) a device belongs to costs one `getName`/`getManufacturerData` read per device total, not
30
+ * one per device per registered driver.
31
+ */
32
+ export declare function forEachAdvertisedDevice(adapter: Adapter, fn: (advertised: AdvertisedDevice) => Promise<void>): Promise<void>;
33
+ /**
34
+ * Retries `fn` up to `attempts` times (including the first try), returning on the first success -
35
+ * shared by the repaint scheduler and the CLI's `paint` command so one flaky BLE connection
36
+ * doesn't fail a whole repaint after a single bad attempt.
37
+ */
38
+ export declare function withRetries<T>(attempts: number, fn: (attempt: number) => Promise<T>): Promise<T>;
39
+ /**
40
+ * Opens exactly one BLE discovery window and one D-Bus/BlueZ session, then hands the adapter to
41
+ * `fn` - shared by `plugin.ts`'s startup scan and the CLI's `scan` command so scanning across
42
+ * multiple registered vendor drivers costs one discovery window total, not one window per driver.
43
+ */
44
+ export declare function withDiscovery<T>(durationMs: number, fn: (adapter: Adapter) => Promise<T>): Promise<T>;
45
+ /**
46
+ * `device.connect()` has no timeout of its own - BlueZ's underlying D-Bus `Connect` call can hang
47
+ * indefinitely for a device that's out of range or stuck mid-handshake, which would otherwise
48
+ * block a scan (or `paint()`) on that one device forever. Throws once `timeoutMs` elapses; if the
49
+ * connect does eventually resolve afterwards, disconnects in the background so a stray successful
50
+ * connection doesn't itself block the next attempt.
51
+ */
52
+ export declare function connectWithTimeout(device: Device, timeoutMs: number): Promise<void>;
53
+ /** Uses an already-known device if BlueZ has one cached, otherwise scans until it appears. */
54
+ export declare function getOrDiscoverDevice(adapter: Adapter, address: string, timeoutMs: number): Promise<Device>;
@@ -0,0 +1,134 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.sleep = sleep;
4
+ exports.createBluetooth = createBluetooth;
5
+ exports.getManufacturerId = getManufacturerId;
6
+ exports.forEachAdvertisedDevice = forEachAdvertisedDevice;
7
+ exports.withRetries = withRetries;
8
+ exports.withDiscovery = withDiscovery;
9
+ exports.connectWithTimeout = connectWithTimeout;
10
+ exports.getOrDiscoverDevice = getOrDiscoverDevice;
11
+ const node_ble_1 = require("node-ble");
12
+ function sleep(ms) {
13
+ return new Promise((resolve) => setTimeout(resolve, ms));
14
+ }
15
+ /**
16
+ * Drop-in replacement for `node-ble`'s `createBluetooth` that fails fast and clearly when
17
+ * BLE isn't available at all, instead of letting `dbus-next` crash the whole process.
18
+ *
19
+ * `node-ble` connects to BlueZ over D-Bus, which only exists on Linux. On any other
20
+ * platform (e.g. a developer's Mac), the underlying `dbus-next` socket connection fails
21
+ * and emits an `'error'` event with no listener attached - Node treats that as an
22
+ * uncaught exception rather than a promise rejection, so no `try`/`catch` around
23
+ * `createBluetooth()` or its callers can see it; it takes the whole process down.
24
+ */
25
+ function createBluetooth() {
26
+ if (process.platform !== 'linux') {
27
+ throw new Error(`BLE support requires Linux (BlueZ over D-Bus); "${process.platform}" is not supported - ` +
28
+ 'run this on the target Linux host (e.g. the NanoPi), not on macOS/Windows.');
29
+ }
30
+ return (0, node_ble_1.createBluetooth)();
31
+ }
32
+ /** BLE manufacturer ID advertised by the device, if any - the key of its manufacturer data map. */
33
+ async function getManufacturerId(device) {
34
+ const manufacturerData = await device.getManufacturerData().catch(() => undefined);
35
+ const [key] = Object.keys(manufacturerData ?? {});
36
+ return key === undefined ? undefined : Number(key);
37
+ }
38
+ /**
39
+ * Reads each nearby device's advertisement exactly once and hands it to `fn` - shared by
40
+ * `plugin.ts`'s startup scan and the CLI's `scan` command so that identifying which vendor (if
41
+ * any) a device belongs to costs one `getName`/`getManufacturerData` read per device total, not
42
+ * one per device per registered driver.
43
+ */
44
+ async function forEachAdvertisedDevice(adapter, fn) {
45
+ for (const address of await adapter.devices()) {
46
+ // BlueZ can drop a device from its cache between `adapter.devices()` listing it and this
47
+ // lookup (e.g. it went out of range mid-scan) - skip it rather than aborting the whole scan.
48
+ const device = await adapter.getDevice(address).catch(() => undefined);
49
+ if (!device) {
50
+ continue;
51
+ }
52
+ const name = await device.getName().catch(() => undefined);
53
+ const manufacturerData = await device.getManufacturerData().catch(() => undefined);
54
+ const [key] = Object.keys(manufacturerData ?? {});
55
+ const manufacturerId = key === undefined ? undefined : Number(key);
56
+ await fn({ address, device, name, manufacturerId, manufacturerData: key === undefined ? undefined : manufacturerData[key] });
57
+ }
58
+ }
59
+ /**
60
+ * Retries `fn` up to `attempts` times (including the first try), returning on the first success -
61
+ * shared by the repaint scheduler and the CLI's `paint` command so one flaky BLE connection
62
+ * doesn't fail a whole repaint after a single bad attempt.
63
+ */
64
+ async function withRetries(attempts, fn) {
65
+ let lastErr;
66
+ for (let attempt = 1; attempt <= Math.max(1, attempts); attempt++) {
67
+ try {
68
+ return await fn(attempt);
69
+ }
70
+ catch (err) {
71
+ lastErr = err;
72
+ }
73
+ }
74
+ throw lastErr;
75
+ }
76
+ /**
77
+ * Opens exactly one BLE discovery window and one D-Bus/BlueZ session, then hands the adapter to
78
+ * `fn` - shared by `plugin.ts`'s startup scan and the CLI's `scan` command so scanning across
79
+ * multiple registered vendor drivers costs one discovery window total, not one window per driver.
80
+ */
81
+ async function withDiscovery(durationMs, fn) {
82
+ const { bluetooth, destroy } = createBluetooth();
83
+ try {
84
+ const adapter = await bluetooth.defaultAdapter();
85
+ const wasDiscovering = await adapter.isDiscovering();
86
+ if (!wasDiscovering) {
87
+ await adapter.startDiscovery();
88
+ }
89
+ await sleep(durationMs);
90
+ if (!wasDiscovering) {
91
+ await adapter.stopDiscovery();
92
+ }
93
+ return await fn(adapter);
94
+ }
95
+ finally {
96
+ destroy();
97
+ }
98
+ }
99
+ /**
100
+ * `device.connect()` has no timeout of its own - BlueZ's underlying D-Bus `Connect` call can hang
101
+ * indefinitely for a device that's out of range or stuck mid-handshake, which would otherwise
102
+ * block a scan (or `paint()`) on that one device forever. Throws once `timeoutMs` elapses; if the
103
+ * connect does eventually resolve afterwards, disconnects in the background so a stray successful
104
+ * connection doesn't itself block the next attempt.
105
+ */
106
+ async function connectWithTimeout(device, timeoutMs) {
107
+ const connecting = device.connect();
108
+ let timedOut = false;
109
+ await Promise.race([connecting, sleep(timeoutMs).then(() => void (timedOut = true))]);
110
+ if (timedOut) {
111
+ connecting.then(() => device.disconnect()).catch(() => { });
112
+ throw new Error(`connecting to device timed out after ${timeoutMs}ms`);
113
+ }
114
+ }
115
+ /** Uses an already-known device if BlueZ has one cached, otherwise scans until it appears. */
116
+ async function getOrDiscoverDevice(adapter, address, timeoutMs) {
117
+ try {
118
+ return await adapter.getDevice(address);
119
+ }
120
+ catch {
121
+ const wasDiscovering = await adapter.isDiscovering();
122
+ if (!wasDiscovering) {
123
+ await adapter.startDiscovery();
124
+ }
125
+ try {
126
+ return await adapter.waitDevice(address, timeoutMs);
127
+ }
128
+ finally {
129
+ if (!wasDiscovering) {
130
+ await adapter.stopDiscovery();
131
+ }
132
+ }
133
+ }
134
+ }
@@ -0,0 +1,4 @@
1
+ import { VendorDriver } from './types';
2
+ export declare function registerDriver(driver: VendorDriver): void;
3
+ export declare function getDriver(vendor: string): VendorDriver | undefined;
4
+ export declare function allDrivers(): VendorDriver[];
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerDriver = registerDriver;
4
+ exports.getDriver = getDriver;
5
+ exports.allDrivers = allDrivers;
6
+ const drivers = new Map();
7
+ function registerDriver(driver) {
8
+ drivers.set(driver.vendor, driver);
9
+ }
10
+ function getDriver(vendor) {
11
+ return drivers.get(vendor);
12
+ }
13
+ function allDrivers() {
14
+ return [...drivers.values()];
15
+ }
@@ -0,0 +1,70 @@
1
+ import { Device } from 'node-ble';
2
+ import { Bitmap } from '../render/types';
3
+ export type Colour = 'black' | 'white' | 'red' | 'yellow';
4
+ /**
5
+ * Static facts about one device model, keyed by (vendor, pid) by the registry —
6
+ * PID alone is not assumed unique across vendors.
7
+ */
8
+ export interface DeviceMetadata {
9
+ pid: number;
10
+ /**
11
+ * Disambiguates physical hardware that shares a PID (some vendors reuse PIDs across
12
+ * panel sizes) - the exact advertised value to match against; omit for the variant
13
+ * that should be treated as the default/fallback for that PID.
14
+ */
15
+ hwVersion?: string;
16
+ label: string;
17
+ width: number;
18
+ height: number;
19
+ voffset: number;
20
+ colours: Colour[];
21
+ }
22
+ export interface DiscoveredDevice {
23
+ address: string;
24
+ name?: string;
25
+ vendor: string;
26
+ pid?: number;
27
+ /**
28
+ * The advertised hardware-version disambiguator, captured independently of whether `metadata`
29
+ * lookup succeeded - lets `deviceOptions()` (`config.ts`) build a complete, parseable device token
30
+ * even for a PID a driver doesn't recognise yet, instead of losing it because it was only ever
31
+ * read off of `metadata.hwVersion`.
32
+ */
33
+ hwVersion?: string;
34
+ metadata?: DeviceMetadata;
35
+ rssi?: number;
36
+ /** BLE manufacturer ID (the key of the advertisement's manufacturer data), if advertised. */
37
+ manufacturerId?: number;
38
+ /** Battery level in millivolts, if the driver was able to read it during the scan. */
39
+ batteryMv?: number;
40
+ }
41
+ /** Manual model facts a user can supply for hardware that isn't yet in a driver's PID table. */
42
+ export type DeviceModelOverride = Omit<DeviceMetadata, 'pid'>;
43
+ /** Per-device settings the user supplies when registering a device, beyond what's in DeviceMetadata. */
44
+ export interface VendorDeviceConfig {
45
+ address: string;
46
+ /** AES key for vendors that need it, entered by the user. If omitted, vendors that have one may fall back to a stock/manufacturer-default key instead of failing. */
47
+ aesKey?: string;
48
+ /** Forces the device model facts instead of looking up the advertised PID - for hardware not yet in the driver's table. */
49
+ modelOverride?: DeviceModelOverride;
50
+ /** How long to wait for the BLE connect step before giving up - if omitted, the driver picks its own default. */
51
+ connectTimeoutMs?: number;
52
+ }
53
+ export interface VendorDriver {
54
+ vendor: string;
55
+ /** Does this advertisement look like it came from one of this vendor's devices? */
56
+ matchesAdvertisement(name: string | undefined, manufacturerId: number | undefined): boolean;
57
+ /** hwVersion disambiguates PIDs a vendor reuses across panel sizes - see `DeviceMetadata.hwVersion`. */
58
+ metadataForPid(pid: number, hwVersion?: string): DeviceMetadata | undefined;
59
+ /** All device models this driver currently has confirmed metadata for. */
60
+ supportedDevices(): DeviceMetadata[];
61
+ /**
62
+ * Identifies one device the shared caller (see `bleDiscovery.ts`'s `forEachAdvertisedDevice`)
63
+ * has already matched to this vendor via `matchesAdvertisement` - only called for matches, so a
64
+ * device gets at most one vendor-specific connect/read regardless of how many drivers are
65
+ * registered, not one attempt per driver.
66
+ */
67
+ identifyDevice(device: Device, address: string, name: string | undefined, manufacturerId: number | undefined, manufacturerData: Buffer | undefined): Promise<DiscoveredDevice>;
68
+ /** Quantise the common bitmap to this device's palette/encoding and send it over BLE. */
69
+ paint(bitmap: Bitmap, config: VendorDeviceConfig): Promise<void>;
70
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,12 @@
1
+ import { Bitmap } from '../../render/types';
2
+ import { DeviceMetadata } from '../types';
3
+ /**
4
+ * Quantises a common RGBA bitmap and packs it into the Wolink wire format: 2 bits per
5
+ * pixel, 4 pixels per byte, column-major, with both axes flipped (RAM is x/y-flipped
6
+ * relative to the displayed image). Mirrors `make_image`/`from_pillow` in the reference
7
+ * driver (examples/device_driver/zhunyco/wolink_ble.py).
8
+ *
9
+ * Rows above `voffset` (present on some panel sizes) are sent as black, matching the
10
+ * reference driver pasting the source image at a vertical offset onto a blank canvas.
11
+ */
12
+ export declare function encodeBitmap(bitmap: Bitmap, metadata: DeviceMetadata): Buffer;
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.encodeBitmap = encodeBitmap;
4
+ /** Mirrors the reference driver's `from_pillow` nearest-colour decision tree. */
5
+ function nearestColour(r, g, b) {
6
+ if (r > 150 && g > 150 && b > 150)
7
+ return 1 /* WolinkColour.White */;
8
+ if (r > 150 && g > 100 && b < 80)
9
+ return 2 /* WolinkColour.Yellow */;
10
+ if (r > 150 && g < 80 && b < 80)
11
+ return 3 /* WolinkColour.Red */;
12
+ return 0 /* WolinkColour.Black */;
13
+ }
14
+ /**
15
+ * Quantises a common RGBA bitmap and packs it into the Wolink wire format: 2 bits per
16
+ * pixel, 4 pixels per byte, column-major, with both axes flipped (RAM is x/y-flipped
17
+ * relative to the displayed image). Mirrors `make_image`/`from_pillow` in the reference
18
+ * driver (examples/device_driver/zhunyco/wolink_ble.py).
19
+ *
20
+ * Rows above `voffset` (present on some panel sizes) are sent as black, matching the
21
+ * reference driver pasting the source image at a vertical offset onto a blank canvas.
22
+ */
23
+ function encodeBitmap(bitmap, metadata) {
24
+ const { width, height, voffset } = metadata;
25
+ const contentHeight = height - voffset;
26
+ if (bitmap.width !== width || bitmap.height !== contentHeight) {
27
+ throw new Error(`zhsunyco paint: bitmap is ${bitmap.width}x${bitmap.height}, device "${metadata.label}" expects ${width}x${contentHeight}`);
28
+ }
29
+ const bytesPerColumn = height / 4;
30
+ const data = Buffer.alloc((width * height) / 4);
31
+ for (let x = 0; x < width; x++) {
32
+ const physX = width - 1 - x;
33
+ for (let y = 0; y < height; y++) {
34
+ const srcY = y - voffset;
35
+ const colour = srcY >= 0 && srcY < bitmap.height ? samplePixel(bitmap, x, srcY) : 0 /* WolinkColour.Black */;
36
+ const physY = height - 1 - y;
37
+ const byteIdx = physX * bytesPerColumn + Math.floor(physY / 4);
38
+ const bitShift = 6 - (physY % 4) * 2;
39
+ data[byteIdx] |= colour << bitShift;
40
+ }
41
+ }
42
+ return data;
43
+ }
44
+ function samplePixel(bitmap, x, y) {
45
+ const offset = (y * bitmap.width + x) * 4;
46
+ return nearestColour(bitmap.data[offset], bitmap.data[offset + 1], bitmap.data[offset + 2]);
47
+ }
@@ -0,0 +1,11 @@
1
+ import { Device } from 'node-ble';
2
+ import { Bitmap } from '../../render/types';
3
+ import { DeviceMetadata, DiscoveredDevice, VendorDeviceConfig, VendorDriver } from '../types';
4
+ export declare class ZhsunycoDriver implements VendorDriver {
5
+ readonly vendor = "zhsunyco";
6
+ matchesAdvertisement(name: string | undefined, manufacturerId: number | undefined): boolean;
7
+ metadataForPid(pid: number, hwVersion?: string): DeviceMetadata | undefined;
8
+ supportedDevices(): DeviceMetadata[];
9
+ identifyDevice(device: Device, address: string, name: string | undefined, manufacturerId: number | undefined, manufacturerData: Buffer | undefined): Promise<DiscoveredDevice>;
10
+ paint(bitmap: Bitmap, config: VendorDeviceConfig): Promise<void>;
11
+ }