@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.
- package/README.md +109 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +244 -0
- package/dist/cli/liveContext.d.ts +11 -0
- package/dist/cli/liveContext.js +72 -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 +53 -0
- package/dist/render/binding.js +168 -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 +78 -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/package.json +70 -0
- package/templates/tide.svg +213 -0
|
@@ -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
|
+
];
|
|
@@ -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-esl-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
|
+
}
|