@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,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,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,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
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ZhsunycoDriver = void 0;
|
|
4
|
+
const bleDiscovery_1 = require("../bleDiscovery");
|
|
5
|
+
const metadata_1 = require("./metadata");
|
|
6
|
+
const encode_1 = require("./encode");
|
|
7
|
+
const protocol_1 = require("./protocol");
|
|
8
|
+
/** node-ble has no MTU API; this matches the reference driver's mtu(247)-9 default. */
|
|
9
|
+
const UPLOAD_CHUNK_SIZE = 238;
|
|
10
|
+
const CHUNK_WRITE_DELAY_MS = 20;
|
|
11
|
+
const AUTH_SETTLE_DELAY_MS = 500;
|
|
12
|
+
const STATUS_WAIT_TIMEOUT_MS = 60000;
|
|
13
|
+
const DEVICE_DISCOVERY_TIMEOUT_MS = 30000;
|
|
14
|
+
/** Used while identifying a device during a scan - kept short since a scan may be enumerating several devices. */
|
|
15
|
+
const SCAN_CONNECT_TIMEOUT_MS = 10000;
|
|
16
|
+
/** Fallback when `VendorDeviceConfig.connectTimeoutMs` is omitted (e.g. a bare CLI `paint` call) - matches `defaultConfig().paintConnectTimeoutSeconds`. */
|
|
17
|
+
const DEFAULT_PAINT_CONNECT_TIMEOUT_MS = 30000;
|
|
18
|
+
class ZhsunycoDriver {
|
|
19
|
+
constructor() {
|
|
20
|
+
this.vendor = 'zhsunyco';
|
|
21
|
+
}
|
|
22
|
+
matchesAdvertisement(name, manufacturerId) {
|
|
23
|
+
return manufacturerId === protocol_1.ZHSUNYCO_MANUFACTURER_ID || (name ?? '').startsWith('WL') || (name ?? '').startsWith('WOESL');
|
|
24
|
+
}
|
|
25
|
+
metadataForPid(pid, hwVersion) {
|
|
26
|
+
const candidates = metadata_1.ZHSUNYCO_PID_METADATA.filter((model) => model.pid === pid);
|
|
27
|
+
return candidates.find((model) => model.hwVersion === hwVersion) ?? candidates.find((model) => model.hwVersion === undefined);
|
|
28
|
+
}
|
|
29
|
+
supportedDevices() {
|
|
30
|
+
return metadata_1.ZHSUNYCO_PID_METADATA;
|
|
31
|
+
}
|
|
32
|
+
async identifyDevice(device, address, name, manufacturerId, manufacturerData) {
|
|
33
|
+
const advertisedInfo = manufacturerData ? (0, protocol_1.decodeAdvertisedInfo)(manufacturerData) : undefined;
|
|
34
|
+
const { info, batteryMv } = await readDeviceDetails(device, advertisedInfo);
|
|
35
|
+
return {
|
|
36
|
+
address,
|
|
37
|
+
name,
|
|
38
|
+
vendor: this.vendor,
|
|
39
|
+
pid: info?.pid,
|
|
40
|
+
hwVersion: info?.hwVersion,
|
|
41
|
+
metadata: info ? this.metadataForPid(info.pid, info.hwVersion) : undefined,
|
|
42
|
+
manufacturerId,
|
|
43
|
+
batteryMv,
|
|
44
|
+
rssi: await device
|
|
45
|
+
.getRSSI()
|
|
46
|
+
.then((value) => (value === undefined ? undefined : Number(value)))
|
|
47
|
+
.catch(() => undefined),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
async paint(bitmap, config) {
|
|
51
|
+
const aesKey = (0, protocol_1.resolveAesKey)(config.aesKey);
|
|
52
|
+
const { bluetooth, destroy } = (0, bleDiscovery_1.createBluetooth)();
|
|
53
|
+
try {
|
|
54
|
+
const adapter = await bluetooth.defaultAdapter();
|
|
55
|
+
const device = await (0, bleDiscovery_1.getOrDiscoverDevice)(adapter, config.address, DEVICE_DISCOVERY_TIMEOUT_MS);
|
|
56
|
+
await (0, bleDiscovery_1.connectWithTimeout)(device, config.connectTimeoutMs ?? DEFAULT_PAINT_CONNECT_TIMEOUT_MS);
|
|
57
|
+
try {
|
|
58
|
+
const gatt = await device.gatt();
|
|
59
|
+
const service = await gatt.getPrimaryService(protocol_1.WOLINK_SERVICE_UUID);
|
|
60
|
+
const dataChar = await service.getCharacteristic(protocol_1.WOLINK_CHARACTERISTIC_UUIDS.data);
|
|
61
|
+
const configChar = await service.getCharacteristic(protocol_1.WOLINK_CHARACTERISTIC_UUIDS.config);
|
|
62
|
+
const authChar = await service.getCharacteristic(protocol_1.WOLINK_CHARACTERISTIC_UUIDS.authenticate);
|
|
63
|
+
const statusChar = await service.getCharacteristic(protocol_1.WOLINK_CHARACTERISTIC_UUIDS.status);
|
|
64
|
+
const info = (0, protocol_1.decodeAdvertisedInfo)(await configChar.readValue());
|
|
65
|
+
if (!info) {
|
|
66
|
+
throw new Error('zhsunyco device did not return valid config data');
|
|
67
|
+
}
|
|
68
|
+
const metadata = config.modelOverride
|
|
69
|
+
? { pid: info.pid, ...config.modelOverride }
|
|
70
|
+
: this.metadataForPid(info.pid, info.hwVersion);
|
|
71
|
+
if (!metadata) {
|
|
72
|
+
throw new Error(`zhsunyco device reports unrecognised PID 0x${info.pid.toString(16).padStart(4, '0')} - ` +
|
|
73
|
+
'pass --width/--height/--voffset/--colours to describe it manually');
|
|
74
|
+
}
|
|
75
|
+
const statusReceived = new Promise((resolve, reject) => {
|
|
76
|
+
statusChar.once('valuechanged', (data) => {
|
|
77
|
+
const { errorCode } = (0, protocol_1.decodeStatus)(data);
|
|
78
|
+
if (errorCode === 0) {
|
|
79
|
+
resolve();
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
reject(new Error(`zhsunyco device reported error 0x${errorCode.toString(16).padStart(2, '0')} after refresh`));
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
await statusChar.startNotifications();
|
|
87
|
+
const challenge = await authChar.readValue();
|
|
88
|
+
await authChar.writeValueWithoutResponse((0, protocol_1.authResponse)(challenge, aesKey));
|
|
89
|
+
await (0, bleDiscovery_1.sleep)(AUTH_SETTLE_DELAY_MS);
|
|
90
|
+
const pixelData = (0, encode_1.encodeBitmap)(bitmap, metadata);
|
|
91
|
+
for (let offset = 0; offset < pixelData.length; offset += UPLOAD_CHUNK_SIZE) {
|
|
92
|
+
const chunk = pixelData.subarray(offset, offset + UPLOAD_CHUNK_SIZE);
|
|
93
|
+
await dataChar.writeValueWithResponse(Buffer.concat([(0, protocol_1.commandHeader)(protocol_1.COMMAND.uploadBlock, offset), chunk]));
|
|
94
|
+
await (0, bleDiscovery_1.sleep)(CHUNK_WRITE_DELAY_MS);
|
|
95
|
+
}
|
|
96
|
+
await dataChar.writeValueWithResponse((0, protocol_1.commandHeader)(protocol_1.COMMAND.refreshUncompressed, pixelData.length));
|
|
97
|
+
await Promise.race([statusReceived, (0, bleDiscovery_1.sleep)(STATUS_WAIT_TIMEOUT_MS)]);
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
await device.disconnect();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
destroy();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
exports.ZhsunycoDriver = ZhsunycoDriver;
|
|
109
|
+
/**
|
|
110
|
+
* Battery level needs a connection regardless, so reuse it to also fill in the PID/hwVersion
|
|
111
|
+
* when the advertisement didn't carry decodable manufacturer data - BlueZ's cached
|
|
112
|
+
* advertisement for a device matched purely by its name prefix can lack that, which would
|
|
113
|
+
* otherwise leave a real, nearby device's model (and so its entry in the config UI's
|
|
114
|
+
* device picker - see `deviceOptions()` in `config.ts`) silently missing. Reads the same
|
|
115
|
+
* config characteristic `paint()` reads, just to identify the device rather than to size a
|
|
116
|
+
* render.
|
|
117
|
+
*/
|
|
118
|
+
async function readDeviceDetails(device, advertisedInfo) {
|
|
119
|
+
const fallback = { info: advertisedInfo, batteryMv: undefined };
|
|
120
|
+
const read = async () => {
|
|
121
|
+
try {
|
|
122
|
+
await (0, bleDiscovery_1.connectWithTimeout)(device, SCAN_CONNECT_TIMEOUT_MS);
|
|
123
|
+
try {
|
|
124
|
+
const gatt = await device.gatt();
|
|
125
|
+
const service = await gatt.getPrimaryService(protocol_1.WOLINK_SERVICE_UUID);
|
|
126
|
+
const batteryChar = await service.getCharacteristic(protocol_1.WOLINK_CHARACTERISTIC_UUIDS.battery);
|
|
127
|
+
const batteryMv = (0, protocol_1.decodeBatteryMv)(await batteryChar.readValue());
|
|
128
|
+
let info = advertisedInfo;
|
|
129
|
+
if (!info) {
|
|
130
|
+
const configChar = await service.getCharacteristic(protocol_1.WOLINK_CHARACTERISTIC_UUIDS.config);
|
|
131
|
+
info = (0, protocol_1.decodeAdvertisedInfo)(await configChar.readValue());
|
|
132
|
+
}
|
|
133
|
+
return { info, batteryMv };
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
await device.disconnect();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return fallback;
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
// `connectWithTimeout` bounds the connect step itself, but a GATT call past that point (e.g.
|
|
144
|
+
// `getPrimaryService`/`readValue`) has no timeout of its own either - race the whole read so
|
|
145
|
+
// one unresponsive device can't stall the rest of the scan (see `plugin.ts`'s `scanInProgress`,
|
|
146
|
+
// which otherwise stays set forever and silently skips every later scan).
|
|
147
|
+
return Promise.race([read(), (0, bleDiscovery_1.sleep)(SCAN_CONNECT_TIMEOUT_MS * 2).then(() => fallback)]);
|
|
148
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { DeviceMetadata } from '../types';
|
|
2
|
+
/**
|
|
3
|
+
* Keyed by PID within the Zhsunyco/Wolink namespace only — this table is not shared
|
|
4
|
+
* with other vendors' PID spaces.
|
|
5
|
+
*
|
|
6
|
+
* PID 0x000E is reused across multiple physical panel sizes - the upstream wolink_ble.py
|
|
7
|
+
* reference driver's own header comment disagrees with its code (`types` dict) about what
|
|
8
|
+
* 0x000E even is, and OpenEPaperLink's `wolinkToOEPLtype()` independently hit the same
|
|
9
|
+
* ambiguity. OEPL resolves it via the advertised hwVersion field, listing
|
|
10
|
+
* 0x0103/0x0201/0x0203 as 2.13"/3.5"/7.5" BWRY panels respectively; the entry below with
|
|
11
|
+
* no `hwVersion` is the developer's own confirmed 3.7" hardware and is used as the
|
|
12
|
+
* fallback for any hwVersion not in that list.
|
|
13
|
+
*
|
|
14
|
+
* The remaining entries (0x0008/0x000A/0x0012/0x0016/0x001A) are unconfirmed against real
|
|
15
|
+
* hardware but are taken from wolink_ble.py's `types` dict rather than its header comment,
|
|
16
|
+
* since none of them shows the kind of hwVersion-dependent reuse 0x000E turned out to
|
|
17
|
+
* have - there's no reason yet to doubt them the way 0x000E's docstring/code mismatch
|
|
18
|
+
* gave reason to.
|
|
19
|
+
*/
|
|
20
|
+
export declare const ZHSUNYCO_PID_METADATA: DeviceMetadata[];
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ZHSUNYCO_PID_METADATA = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Keyed by PID within the Zhsunyco/Wolink namespace only — this table is not shared
|
|
6
|
+
* with other vendors' PID spaces.
|
|
7
|
+
*
|
|
8
|
+
* PID 0x000E is reused across multiple physical panel sizes - the upstream wolink_ble.py
|
|
9
|
+
* reference driver's own header comment disagrees with its code (`types` dict) about what
|
|
10
|
+
* 0x000E even is, and OpenEPaperLink's `wolinkToOEPLtype()` independently hit the same
|
|
11
|
+
* ambiguity. OEPL resolves it via the advertised hwVersion field, listing
|
|
12
|
+
* 0x0103/0x0201/0x0203 as 2.13"/3.5"/7.5" BWRY panels respectively; the entry below with
|
|
13
|
+
* no `hwVersion` is the developer's own confirmed 3.7" hardware and is used as the
|
|
14
|
+
* fallback for any hwVersion not in that list.
|
|
15
|
+
*
|
|
16
|
+
* The remaining entries (0x0008/0x000A/0x0012/0x0016/0x001A) are unconfirmed against real
|
|
17
|
+
* hardware but are taken from wolink_ble.py's `types` dict rather than its header comment,
|
|
18
|
+
* since none of them shows the kind of hwVersion-dependent reuse 0x000E turned out to
|
|
19
|
+
* have - there's no reason yet to doubt them the way 0x000E's docstring/code mismatch
|
|
20
|
+
* gave reason to.
|
|
21
|
+
*/
|
|
22
|
+
exports.ZHSUNYCO_PID_METADATA = [
|
|
23
|
+
{
|
|
24
|
+
pid: 0x0008,
|
|
25
|
+
label: '1.54"',
|
|
26
|
+
width: 200,
|
|
27
|
+
height: 200,
|
|
28
|
+
voffset: 0,
|
|
29
|
+
colours: ['black', 'white', 'red', 'yellow'],
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
pid: 0x000a,
|
|
33
|
+
label: '2.13"',
|
|
34
|
+
width: 250,
|
|
35
|
+
height: 128,
|
|
36
|
+
voffset: 0,
|
|
37
|
+
colours: ['black', 'white', 'red', 'yellow'],
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
pid: 0x000e,
|
|
41
|
+
label: '3.7"',
|
|
42
|
+
width: 416,
|
|
43
|
+
height: 240,
|
|
44
|
+
voffset: 0,
|
|
45
|
+
colours: ['black', 'white', 'red', 'yellow'],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
pid: 0x000e,
|
|
49
|
+
hwVersion: '0103',
|
|
50
|
+
label: '2.13"',
|
|
51
|
+
width: 250,
|
|
52
|
+
height: 128,
|
|
53
|
+
voffset: 0,
|
|
54
|
+
colours: ['black', 'white', 'red', 'yellow'],
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
pid: 0x000e,
|
|
58
|
+
hwVersion: '0201',
|
|
59
|
+
label: '3.5"',
|
|
60
|
+
width: 384,
|
|
61
|
+
height: 184,
|
|
62
|
+
voffset: 0,
|
|
63
|
+
colours: ['black', 'white', 'red', 'yellow'],
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
pid: 0x000e,
|
|
67
|
+
hwVersion: '0203',
|
|
68
|
+
label: '7.5"',
|
|
69
|
+
width: 800,
|
|
70
|
+
height: 480,
|
|
71
|
+
voffset: 0,
|
|
72
|
+
colours: ['black', 'white', 'red', 'yellow'],
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
pid: 0x0012,
|
|
76
|
+
label: '2.9"',
|
|
77
|
+
width: 296,
|
|
78
|
+
height: 128,
|
|
79
|
+
voffset: 0,
|
|
80
|
+
colours: ['black', 'white', 'red', 'yellow'],
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
pid: 0x0016,
|
|
84
|
+
label: '4.2"',
|
|
85
|
+
width: 400,
|
|
86
|
+
height: 300,
|
|
87
|
+
voffset: 0,
|
|
88
|
+
colours: ['black', 'white', 'red', 'yellow'],
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
pid: 0x001a,
|
|
92
|
+
label: '5.8"',
|
|
93
|
+
width: 648,
|
|
94
|
+
height: 480,
|
|
95
|
+
voffset: 0,
|
|
96
|
+
colours: ['black', 'white', 'red', 'yellow'],
|
|
97
|
+
},
|
|
98
|
+
];
|