homebridge-dyson-solarcycle-morph 1.2.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,30 @@ All notable changes to this project are documented here.
5
5
  The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.2.2] — 2026-09-21
9
+
10
+ ### Fixed
11
+
12
+ - **The settings schema declares its required fields the way JSON Schema
13
+ defines it.** `"required": true` sat on the individual fields, which the
14
+ Homebridge UI has always accepted but which is not what the keyword means:
15
+ in JSON Schema `required` belongs on the enclosing object and lists the names
16
+ of the properties it requires. The plugin name is now listed on the schema
17
+ itself, and a light's name, MAC address and serial on the light object. The
18
+ same fields are required as before.
19
+
20
+ ## [1.2.1] — 2026-09-21
21
+
22
+ ### Changed
23
+
24
+ - **The two development probes under `tools/` are no longer published to npm.**
25
+ They are scripts for poking at a lamp over Bluetooth during development, they
26
+ are documented nowhere a user would look, and nothing in the plugin calls
27
+ them. Shipping them only meant that a scan of the installed package found two
28
+ environment variables being read — `DYSON_PLUGIN_DIR` and `DYSON_PERSIST_DIR`,
29
+ both of them path overrides for running a probe against a checkout — with no
30
+ context explaining why. They stay in the repository, where they belong.
31
+
8
32
  ## [1.2.0] — 2026-09-21
9
33
 
10
34
  ### Added
@@ -5,18 +5,17 @@
5
5
  "headerDisplay": "Controls the Bluetooth-only Dyson Solarcycle / Lightcycle Morph.\n\n**The lamp must be registered in the MyDyson app.** Its key is issued by Dyson against the account the lamp belongs to and cannot be read from the device itself.\n\nEach lamp becomes two accessories in the Home app: the light itself, and a second one holding the switches below.",
6
6
  "schema": {
7
7
  "type": "object",
8
+ "required": ["name"],
8
9
  "properties": {
9
10
  "name": {
10
11
  "title": "Name",
11
12
  "type": "string",
12
13
  "default": "Dyson Solarcycle Morph",
13
- "required": true,
14
14
  "description": "Shown in the Homebridge log in front of this plugin\u2019s messages."
15
15
  },
16
16
  "adapter": {
17
17
  "title": "Bluetooth adapter",
18
18
  "type": "string",
19
- "required": false,
20
19
  "placeholder": "hci0",
21
20
  "description": "Leave empty to use the system default adapter."
22
21
  },
@@ -25,24 +24,22 @@
25
24
  "type": "array",
26
25
  "items": {
27
26
  "type": "object",
27
+ "required": ["name", "mac", "serial"],
28
28
  "properties": {
29
29
  "name": {
30
30
  "title": "Name",
31
31
  "type": "string",
32
- "required": true,
33
32
  "default": "Dyson Morph"
34
33
  },
35
34
  "mac": {
36
35
  "title": "BLE MAC address",
37
36
  "type": "string",
38
- "required": true,
39
37
  "placeholder": "AA:BB:CC:DD:EE:FF",
40
38
  "pattern": "^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$"
41
39
  },
42
40
  "serial": {
43
41
  "title": "Serial number",
44
42
  "type": "string",
45
- "required": true,
46
43
  "description": "Printed on the lamp base and shown in the MyDyson app."
47
44
  },
48
45
  "autoBrightnessSwitch": {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "homebridge-dyson-solarcycle-morph",
3
3
  "displayName": "Dyson Solarcycle Morph",
4
- "version": "1.2.0",
4
+ "version": "1.2.2",
5
5
  "description": "Homebridge plugin for the Bluetooth LE controlled Dyson Solarcycle/Lightcycle Morph desk light",
6
6
  "license": "MIT",
7
7
  "author": "Oliver Rummeyer",
@@ -56,7 +56,6 @@
56
56
  "files": [
57
57
  "dist",
58
58
  "homebridge-ui",
59
- "tools",
60
59
  "docs",
61
60
  "config.schema.json",
62
61
  "README.md",
@@ -1,132 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Standalone GATT probe. Plain JavaScript so it runs on the Raspberry Pi with
4
- * nothing but `npm install node-ble` — no build step, no plugin install.
5
- *
6
- * node tools/ble-probe.js AA:BB:CC:DD:EE:FF
7
- *
8
- * Dumps every service and characteristic the lamp exposes, reads what it can,
9
- * and watches for notifications. Use this to confirm the documented UUIDs
10
- * before trusting them, and to see whether unauthenticated reads are allowed.
11
- */
12
- import { createBluetooth } from 'node-ble';
13
-
14
- const MAC = (process.argv[2] || '').toUpperCase();
15
- const WATCH_SECONDS = Number(process.argv[3] || 20);
16
-
17
- if (!/^([0-9A-F]{2}:){5}[0-9A-F]{2}$/.test(MAC)) {
18
- console.error('Usage: node tools/ble-probe.js <MAC> [seconds-to-watch]');
19
- process.exit(1);
20
- }
21
-
22
- /** UUIDs we expect, from the reverse-engineered Lightcycle/Solarcycle protocol. */
23
- const KNOWN = {
24
- '2dd10010-1c37-452d-8979-d1b4a787d0a4': 'Dyson service',
25
- '2dd10011-1c37-452d-8979-d1b4a787d0a4': 'auth channel',
26
- '2dd10013-1c37-452d-8979-d1b4a787d0a4': 'RSSI',
27
- '2dd10021-1c37-452d-8979-d1b4a787d0a4': 'attribute write',
28
- '2dd11000-1c37-452d-8979-d1b4a787d0a4': 'brightness %',
29
- '2dd11001-1c37-452d-8979-d1b4a787d0a4': 'colour temp (K, uint16 LE)',
30
- '2dd11005-1c37-452d-8979-d1b4a787d0a4': 'power (0/1)',
31
- '2dd11006-1c37-452d-8979-d1b4a787d0a4': 'runtime flags',
32
- '2dd11007-1c37-452d-8979-d1b4a787d0a4': 'ambient sensor',
33
- '2dd11008-1c37-452d-8979-d1b4a787d0a4': 'motion',
34
- '2dd11009-1c37-452d-8979-d1b4a787d0a4': 'brightness (lm, uint16 LE)',
35
- };
36
-
37
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
38
-
39
- /** Render a value as hex plus the interpretations that are plausible for it. */
40
- function describeValue(buf) {
41
- const parts = [`hex=${buf.toString('hex') || '(empty)'}`];
42
- if (buf.length === 1) {
43
- parts.push(`u8=${buf[0]}`);
44
- }
45
- if (buf.length === 2) {
46
- parts.push(`u16le=${buf.readUInt16LE(0)}`);
47
- }
48
- return parts.join(' ');
49
- }
50
-
51
- async function main() {
52
- const { bluetooth, destroy } = createBluetooth();
53
- const adapter = await bluetooth.defaultAdapter();
54
-
55
- if (!(await adapter.isPowered())) {
56
- throw new Error('Bluetooth adapter is powered off — run: bluetoothctl power on');
57
- }
58
- if (!(await adapter.isDiscovering())) {
59
- await adapter.startDiscovery();
60
- }
61
-
62
- console.log(`Waiting for ${MAC} …`);
63
- const device = await adapter.waitDevice(MAC, 60_000);
64
-
65
- for (const [label, fn] of [
66
- ['name', 'getName'],
67
- ['alias', 'getAlias'],
68
- ['RSSI', 'getRSSI'],
69
- ['paired', 'isPaired'],
70
- ]) {
71
- try {
72
- console.log(` ${label}: ${await device[fn]()}`);
73
- } catch (e) {
74
- console.log(` ${label}: (unavailable)`);
75
- }
76
- }
77
-
78
- console.log('Connecting …');
79
- await device.connect();
80
- const gatt = await device.gatt();
81
-
82
- const notifying = [];
83
-
84
- for (const serviceUuid of await gatt.services()) {
85
- const service = await gatt.getPrimaryService(serviceUuid);
86
- console.log(`\nService ${serviceUuid} ${KNOWN[serviceUuid] ? `(${KNOWN[serviceUuid]})` : ''}`);
87
-
88
- for (const charUuid of await service.characteristics()) {
89
- const characteristic = await service.getCharacteristic(charUuid);
90
- const flags = await characteristic.getFlags();
91
- const label = KNOWN[charUuid] ? ` ← ${KNOWN[charUuid]}` : '';
92
- console.log(` ${charUuid} [${flags.join(', ')}]${label}`);
93
-
94
- if (flags.includes('read')) {
95
- try {
96
- console.log(` read: ${describeValue(await characteristic.readValue())}`);
97
- } catch (e) {
98
- // A permission error here is the interesting case: it tells us the
99
- // lamp gates this characteristic behind the auth handshake.
100
- console.log(` read failed: ${e.message}`);
101
- }
102
- }
103
-
104
- if (flags.includes('notify')) {
105
- try {
106
- characteristic.on('valuechanged', (buf) => {
107
- console.log(` [notify] ${charUuid} ${describeValue(buf)}`);
108
- });
109
- await characteristic.startNotifications();
110
- notifying.push(characteristic);
111
- } catch (e) {
112
- console.log(` notify failed: ${e.message}`);
113
- }
114
- }
115
- }
116
- }
117
-
118
- console.log(`\nWatching notifications for ${WATCH_SECONDS}s — touch the lamp or wave at it now.`);
119
- await sleep(WATCH_SECONDS * 1000);
120
-
121
- for (const characteristic of notifying) {
122
- await characteristic.stopNotifications().catch(() => {});
123
- }
124
- await device.disconnect().catch(() => {});
125
- destroy();
126
- console.log('Done.');
127
- }
128
-
129
- main().catch((e) => {
130
- console.error(`\nProbe failed: ${e.message}`);
131
- process.exit(1);
132
- });
@@ -1,593 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Does the lamp actually need to be taken out of daylight mode before it will
4
- * accept a brightness or colour-temperature write?
5
- *
6
- * The plugin sends `13 20 01 00 00` to `2dd10021` before every such write,
7
- * because the reverse-engineering notes say the lamp otherwise discards the
8
- * value. That has never been checked against hardware, and it costs the user a
9
- * feature they like: the lamp stops tracking daylight the moment HomeKit
10
- * touches it. If writes land without it, the whole mechanism can go.
11
- *
12
- * Runs on the Raspberry Pi, from a directory where `node-ble` is installed:
13
- *
14
- * node daylight-test.js watch F0:B1:… E5T-EU-… [seconds]
15
- * node daylight-test.js trial F0:B1:… E5T-EU-… [--repeat N] [--kelvin-only]
16
- * node daylight-test.js attrread F0:B1:… E5T-EU-…
17
- * node daylight-test.js writescan F0:B1:… E5T-EU-…
18
- *
19
- * `watch` prints everything the lamp notifies. `trial` writes values and checks
20
- * whether they landed. `attrread` reads an attribute range, which cannot
21
- * disturb anything, and `setattr` writes one. `charwrite` reads and writes a
22
- * characteristic by its four-digit id — how the lamp's mode switches were
23
- * found. `monitor` polls everything and reports what changed, so whoever is at
24
- * the lamp can flip a switch whenever they like rather than inside a window
25
- * someone else opened.
26
- *
27
- * Crypto and framing are imported from the installed plugin rather than
28
- * reimplemented, so what this exercises is the code that actually ships.
29
- *
30
- * Modes that answered a question once have been removed rather than left to
31
- * rot; what they found is in docs/PROTOCOL.md.
32
- */
33
- import { readFileSync, readdirSync } from 'node:fs';
34
- import { join } from 'node:path';
35
- import { createInterface } from 'node:readline/promises';
36
- import { randomBytes } from 'node:crypto';
37
-
38
- import { createBluetooth } from 'node-ble';
39
-
40
- const PLUGIN = process.env.DYSON_PLUGIN_DIR
41
- ?? '/var/lib/homebridge/node_modules/homebridge-dyson-solarcycle-morph';
42
- const PERSIST = process.env.DYSON_PERSIST_DIR
43
- ?? '/var/lib/homebridge/homebridge-dyson-solarcycle-morph/persist';
44
-
45
- const { deriveAesKey, buildReauthPayloadA, buildReauthPayloadC, parseReauthPayloadB } =
46
- await import(join(PLUGIN, 'dist/dyson/crypto.js'));
47
- const {
48
- CHAR_AUTH, CHAR_RSSI, CHAR_WRITE_ATTR, CHAR_COLOR_TEMP, CHAR_POWER, CHAR_BRIGHTNESS_LM,
49
- MsgType, MessageAssembler, fragmentMessage,
50
- } = await import(join(PLUGIN, 'dist/dyson/protocol.js'));
51
-
52
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
53
- const hex = (buf) => buf.toString('hex').replace(/(..)/g, '$1 ').trim();
54
-
55
- /**
56
- * The two targets each trial alternates between.
57
- *
58
- * Far apart, so a value that lands is unmistakable, and both well inside the
59
- * lamp's range so neither can be explained away as clamping.
60
- */
61
- const TARGETS = [
62
- { lumens: 200, kelvin: 2700 },
63
- { lumens: 900, kelvin: 6500 },
64
- ];
65
-
66
- /** How long to give the lamp before reading back. */
67
- const SETTLE_MS = 2_000;
68
-
69
- function usage() {
70
- console.error('Usage: node daylight-test.js <watch|trial|attrread|setattr|charwrite|monitor> <MAC> <SERIAL> [options]');
71
- process.exit(1);
72
- }
73
-
74
- /**
75
- * Read the long-term key straight out of node-persist's directory.
76
- *
77
- * Deliberately not via the plugin's CredentialStore: this script has no
78
- * business initialising a store the running plugin also writes to.
79
- */
80
- function loadCredentials(serial) {
81
- const wanted = `${serial.trim().toUpperCase()}:credentials`;
82
- for (const name of readdirSync(PERSIST)) {
83
- try {
84
- const entry = JSON.parse(readFileSync(join(PERSIST, name), 'utf8'));
85
- if (entry.key === wanted && entry.value?.ltk && entry.value?.accountId) {
86
- return entry.value;
87
- }
88
- } catch {
89
- // Not one of ours, or half-written. Either way, keep looking.
90
- }
91
- }
92
- throw new Error(`No stored credentials for ${serial} in ${PERSIST} — is the serial right?`);
93
- }
94
-
95
- /** Everything the lamp says, in one stream, so ordering is visible. */
96
- class Session {
97
- constructor(mac, credentials) {
98
- this.mac = mac;
99
- this.key = deriveAesKey(Buffer.from(credentials.ltk, 'hex'));
100
- this.accountId = credentials.accountId;
101
- this.assembler = new MessageAssembler();
102
- this.waiters = new Map();
103
- this.chars = {};
104
- this.writeTypes = {};
105
- this.t0 = Date.now();
106
- }
107
-
108
- log(...parts) {
109
- const t = ((Date.now() - this.t0) / 1000).toFixed(2).padStart(7);
110
- console.log(`[${t}s]`, ...parts);
111
- }
112
-
113
- async open() {
114
- const { bluetooth, destroy } = createBluetooth();
115
- this.destroy = destroy;
116
- const adapter = await bluetooth.defaultAdapter();
117
- if (!(await adapter.isPowered())) {
118
- throw new Error('Bluetooth adapter is powered off — run: bluetoothctl power on');
119
- }
120
- // The plugin found a scan has to be running across the connect, not before it.
121
- if (!(await adapter.isDiscovering())) {
122
- await adapter.startDiscovery();
123
- this.startedDiscovery = true;
124
- await sleep(500);
125
- }
126
-
127
- this.log(`Waiting for ${this.mac} …`);
128
- this.device = await adapter.waitDevice(this.mac, 60_000);
129
- // The link to this lamp runs a few dB above the plugin's weak threshold and
130
- // drops occasionally. A drop between the write and the read-back would look
131
- // exactly like the lamp discarding the write, which is the one conclusion
132
- // this script exists to get right, so trials that span one are thrown away.
133
- this.drops = 0;
134
- this.device.on('disconnect', () => {
135
- this.drops++;
136
- this.log('Link dropped');
137
- });
138
-
139
- let lastError;
140
- for (let attempt = 1; attempt <= 6; attempt++) {
141
- try {
142
- await this.device.connect();
143
- this.log(`Connected on attempt ${attempt}`);
144
- lastError = undefined;
145
- break;
146
- } catch (e) {
147
- lastError = e;
148
- await sleep(1_000);
149
- }
150
- }
151
- if (lastError) {
152
- throw lastError;
153
- }
154
-
155
- const gatt = await this.device.gatt();
156
- const wanted = new Set([
157
- CHAR_AUTH, CHAR_RSSI, CHAR_WRITE_ATTR, CHAR_COLOR_TEMP, CHAR_POWER, CHAR_BRIGHTNESS_LM,
158
- '2dd11000-1c37-452d-8979-d1b4a787d0a4',
159
- '2dd11004-1c37-452d-8979-d1b4a787d0a4',
160
- '2dd11006-1c37-452d-8979-d1b4a787d0a4',
161
- '2dd11008-1c37-452d-8979-d1b4a787d0a4',
162
- '2dd11007-1c37-452d-8979-d1b4a787d0a4',
163
- ]);
164
- for (const serviceUuid of await gatt.services()) {
165
- const service = await gatt.getPrimaryService(serviceUuid);
166
- for (const charUuid of await service.characteristics()) {
167
- if (!wanted.has(charUuid)) {
168
- continue;
169
- }
170
- const characteristic = await service.getCharacteristic(charUuid);
171
- this.chars[charUuid] = characteristic;
172
- const flags = await characteristic.getFlags().catch(() => []);
173
- this.writeTypes[charUuid] = flags.includes('write') ? 'request' : 'command';
174
- }
175
- }
176
- if (this.startedDiscovery) {
177
- await adapter.stopDiscovery().catch(() => {});
178
- this.startedDiscovery = false;
179
- }
180
-
181
- const auth = this.chars[CHAR_AUTH];
182
- auth.on('valuechanged', (buffer) => {
183
- const message = this.assembler.push(buffer);
184
- if (!message) {
185
- return;
186
- }
187
- const waiter = this.waiters.get(message.type);
188
- if (waiter) {
189
- this.waiters.delete(message.type);
190
- waiter(message);
191
- }
192
- });
193
- await auth.startNotifications();
194
- await this.authenticate();
195
- }
196
-
197
- async write(uuid, value) {
198
- await this.chars[uuid].writeValue(value, { type: this.writeTypes[uuid] ?? 'command' });
199
- }
200
-
201
- async send(type, payload) {
202
- for (const fragment of fragmentMessage(type, payload)) {
203
- await this.write(CHAR_AUTH, fragment);
204
- }
205
- }
206
-
207
- waitFor(type, timeoutMs) {
208
- return new Promise((resolve, reject) => {
209
- const timer = setTimeout(() => {
210
- this.waiters.delete(type);
211
- reject(new Error(`timed out waiting for 0x${type.toString(16)}`));
212
- }, timeoutMs);
213
- this.waiters.set(type, (message) => {
214
- clearTimeout(timer);
215
- resolve(message);
216
- });
217
- });
218
- }
219
-
220
- async authenticate() {
221
- await this.send(MsgType.REQUEST_PRODUCT_INFO);
222
- await this.waitFor(MsgType.PRODUCT_INFO, 5_000).catch(() => {});
223
- const nonce = randomBytes(16);
224
- await this.send(MsgType.REAUTH_PAYLOAD_A, buildReauthPayloadA(this.accountId, this.key, nonce));
225
- const payloadB = await this.waitFor(MsgType.REAUTH_PAYLOAD_B, 10_000);
226
- const challenge = parseReauthPayloadB(this.key, payloadB.payload);
227
- await this.send(MsgType.REAUTH_PAYLOAD_C, buildReauthPayloadC(this.key, challenge));
228
- await this.waitFor(MsgType.CONNECTION_ESTABLISHED, 10_000);
229
- this.log('Authenticated');
230
- }
231
-
232
- /** Subscribe to every characteristic that will notify, and print what arrives. */
233
- async watchAll() {
234
- const labels = {
235
- [CHAR_WRITE_ATTR]: 'attr',
236
- [CHAR_BRIGHTNESS_LM]: 'lumens',
237
- [CHAR_COLOR_TEMP]: 'kelvin',
238
- [CHAR_POWER]: 'power',
239
- '2dd11000-1c37-452d-8979-d1b4a787d0a4': 'pct',
240
- '2dd11004-1c37-452d-8979-d1b4a787d0a4': '1004',
241
- '2dd11006-1c37-452d-8979-d1b4a787d0a4': 'auto',
242
- '2dd11007-1c37-452d-8979-d1b4a787d0a4': 'movemode',
243
- '2dd11008-1c37-452d-8979-d1b4a787d0a4': '1008',
244
- };
245
- for (const [uuid, label] of Object.entries(labels)) {
246
- const characteristic = this.chars[uuid];
247
- if (!characteristic) {
248
- continue;
249
- }
250
- characteristic.on('valuechanged', (buf) => {
251
- const extra = buf.length === 2 ? ` u16le=${buf.readUInt16LE(0)}` : buf.length === 1 ? ` u8=${buf[0]}` : '';
252
- this.log(`notify ${label.padEnd(6)} ${hex(buf)}${extra}`);
253
- });
254
- await characteristic.startNotifications().catch((e) => {
255
- this.log(`notify ${label}: unavailable (${e.message})`);
256
- });
257
- }
258
- }
259
-
260
- async readState() {
261
- const read = async (uuid) => {
262
- try {
263
- return await this.chars[uuid].readValue();
264
- } catch {
265
- return undefined;
266
- }
267
- };
268
- const lumens = await read(CHAR_BRIGHTNESS_LM);
269
- const kelvin = await read(CHAR_COLOR_TEMP);
270
- const power = await read(CHAR_POWER);
271
- return {
272
- lumens: lumens?.length === 2 ? lumens.readUInt16LE(0) : undefined,
273
- kelvin: kelvin?.length === 2 ? kelvin.readUInt16LE(0) : undefined,
274
- power: power?.length ? power[0] : undefined,
275
- };
276
- }
277
-
278
- async close() {
279
- await this.device?.disconnect().catch(() => {});
280
- this.destroy?.();
281
- }
282
- }
283
-
284
- const ask = async (question) => {
285
- const rl = createInterface({ input: process.stdin, output: process.stdout });
286
- const answer = await rl.question(question);
287
- rl.close();
288
- return answer;
289
- };
290
-
291
- /**
292
- * One measurement: write a target and see where the lamp ends up.
293
- *
294
- * Brightness is the weaker signal of the two — the lamp adjusts it to track
295
- * daylight, so landing off-target proves nothing on its own. Colour temperature
296
- * is the one that decides this, because nothing else moves it.
297
- */
298
- async function runTrial(session, requested, { manual, only = 'both', gapMs = 0 }) {
299
- const dropsAtStart = session.drops;
300
- const current = await session.readState();
301
- // Aim at whichever end of the range the lamp is furthest from, so every trial
302
- // has an unambiguous answer.
303
- const target = {
304
- lumens: (current.lumens ?? 0) > 550 ? 200 : 900,
305
- kelvin: (current.kelvin ?? 0) > 4600 ? 2700 : 6500,
306
- };
307
- if (manual) {
308
- await session.write(CHAR_WRITE_ATTR, Buffer.from([0x80, 0x93, 0x13, 0x20, 0x01, 0x00, 0x00]));
309
- await sleep(200);
310
- }
311
- const before = current;
312
-
313
- if (only !== 'kelvin') {
314
- const lumens = Buffer.alloc(2);
315
- lumens.writeUInt16LE(target.lumens);
316
- await session.write(CHAR_BRIGHTNESS_LM, lumens);
317
- }
318
-
319
- if (only === 'both' && gapMs > 0) {
320
- await sleep(gapMs);
321
- }
322
-
323
- if (only !== 'lumens') {
324
- const kelvin = Buffer.alloc(2);
325
- kelvin.writeUInt16LE(target.kelvin);
326
- await session.write(CHAR_COLOR_TEMP, kelvin);
327
- }
328
-
329
- await sleep(SETTLE_MS);
330
- const after = await session.readState();
331
-
332
- if (session.drops !== dropsAtStart) {
333
- session.log('Discarding this trial: the link dropped while it was running');
334
- return { manual, asked: target, void: true };
335
- }
336
-
337
- const row = {
338
- manual,
339
- asked: target,
340
- before: { lumens: before.lumens, kelvin: before.kelvin },
341
- after: { lumens: after.lumens, kelvin: after.kelvin },
342
- kelvinLanded: after.kelvin === target.kelvin,
343
- kelvinMoved: before.kelvin !== after.kelvin,
344
- lumensDelta: after.lumens === undefined ? undefined : after.lumens - target.lumens,
345
- };
346
- session.log(
347
- `${manual ? 'manual ' : 'no-manual'} [${only}${gapMs ? `+${gapMs}ms` : ''}] ` +
348
- `ask ${target.lumens}lm/${target.kelvin}K ` +
349
- `→ ${after.lumens ?? '?'}lm/${after.kelvin ?? '?'}K ` +
350
- `(kelvin ${row.kelvinLanded ? 'LANDED' : row.kelvinMoved ? 'moved, off target' : 'DID NOT MOVE'})`,
351
- );
352
- return row;
353
- }
354
-
355
- async function main() {
356
- const [mode, mac, serial, ...rest] = process.argv.slice(2);
357
- if (!mode || !mac || !serial) {
358
- usage();
359
- }
360
- const manual = rest.includes('--manual');
361
- // Set when the lamp is already in the state the trial needs and nobody is
362
- // sitting at the terminal to confirm it.
363
- const noPrompt = rest.includes('--no-prompt');
364
- // The plugin debounces per characteristic, so its writes are always spaced
365
- // apart in real time. Writing both back to back is this tool's invention, and
366
- // it is a difference worth being able to switch off.
367
- const only = rest.includes('--kelvin-only') ? 'kelvin' : rest.includes('--lumens-only') ? 'lumens' : 'both';
368
- const gapIndex = rest.indexOf('--gap');
369
- const gapMs = gapIndex === -1 ? 0 : Number(rest[gapIndex + 1] ?? 0);
370
- const repeatFlag = rest.indexOf('--repeat');
371
- const repeat = repeatFlag === -1 ? 1 : Number(rest[repeatFlag + 1] ?? 1);
372
- const seconds = Number(rest.find((a) => /^\d+$/.test(a)) ?? 60);
373
-
374
- const session = new Session(mac.toUpperCase(), loadCredentials(serial));
375
- await session.open();
376
-
377
- try {
378
- if (mode === 'watch') {
379
- // Answers the standing question of whether daylight mode is observable at
380
- // all: if toggling it in the app notifies on 2dd10021, it is readable
381
- // state and the TTL guesswork can be replaced with a subscription.
382
- await session.watchAll();
383
- session.log(`Initial state: ${JSON.stringify(await session.readState())}`);
384
- console.log(
385
- `\nWatching for ${seconds}s. Toggle daylight mode in the MyDyson app, then use the\n` +
386
- 'physical buttons, and note the wall-clock order — anything that appears below is\n' +
387
- 'state the plugin could subscribe to instead of guessing.\n',
388
- );
389
- await sleep(seconds * 1_000);
390
- return;
391
- }
392
-
393
- if (mode === 'attrread') {
394
- // Read every attribute the app knows about. Type 0x90 asks, and whatever
395
- // the lamp answers with is captured raw. Nothing is written, so this
396
- // cannot disturb a setting whose meaning we do not know yet.
397
- const answers = new Map();
398
- const attr = session.chars[CHAR_WRITE_ATTR];
399
- attr?.on('valuechanged', (buf) => {
400
- if (buf.length >= 4 && buf[0] === 0x80) {
401
- answers.set(`${buf[1].toString(16)}:${buf.readUInt16LE(2).toString(16)}`, hex(buf));
402
- }
403
- });
404
- await attr?.startNotifications();
405
-
406
- const state = await session.readState();
407
- session.log(`Lamp is at ${state.lumens}lm / ${state.kelvin}K — look for those values below`);
408
-
409
- // Sweep the range rather than only the ids the app's light module names:
410
- // reads cannot disturb anything, and the live values have to live
411
- // somewhere. Anything matching the lamp's current state is the answer.
412
- // Range is settable: `attrread <MAC> <SERIAL> 0x2040 0x2100`.
413
- const from = rest[0] ? Number(rest[0]) : 0x2000;
414
- const to = rest[1] ? Number(rest[1]) : 0x2040;
415
- const ids = [];
416
- for (let id = from; id <= to; id++) {
417
- ids.push(id);
418
- }
419
- for (const id of ids) {
420
- answers.clear();
421
- const body = Buffer.alloc(2);
422
- body.writeUInt16LE(id);
423
- for (const fragment of fragmentMessage(0x90, body)) {
424
- await session.write(CHAR_WRITE_ATTR, fragment);
425
- }
426
- await sleep(180);
427
- const got = [...answers.values()];
428
- // The value follows attribute(2) and length(2); decode it both ways so
429
- // a 2-byte number is readable at a glance.
430
- const decoded = got.map((raw) => {
431
- const b = Buffer.from(raw.replace(/ /g, ''), 'hex');
432
- if (b.length >= 7) {
433
- // header, type, attribute(2), status, length(2), value
434
- const v = b.subarray(7);
435
- return v.length === 2 ? `${v.readUInt16LE(0)}` : v.length === 1 ? `${v[0]}` : hex(v);
436
- }
437
- return '';
438
- }).filter(Boolean);
439
- if (got.length === 0) {
440
- continue;
441
- }
442
- const hit = decoded.some(
443
- (d) => Math.abs(Number(d) - (state.kelvin ?? -1)) < 40 || Math.abs(Number(d) - (state.lumens ?? -1)) < 25,
444
- );
445
- session.log(`0x${id.toString(16)} → ${decoded.join(' ')}${hit ? ' *** MATCHES LAMP STATE ***' : ''}`);
446
- }
447
- return;
448
- }
449
-
450
- if (mode === 'charwrite') {
451
- // Read and optionally write one characteristic by its short id:
452
- // charwrite <MAC> <SERIAL> 1006 [value]
453
- // The app drives the lamp's Auto and movement switches this way rather
454
- // than through the attribute channel.
455
- const short = rest[0];
456
- const uuid = `2dd1${short}-1c37-452d-8979-d1b4a787d0a4`;
457
- const characteristic = session.chars[uuid];
458
- if (!characteristic) {
459
- session.log(`${uuid} was not discovered`);
460
- return;
461
- }
462
- characteristic.on('valuechanged', (buf) => session.log(` notify ${short}: ${hex(buf)}`));
463
- await characteristic.startNotifications().catch(() => {});
464
- session.log(`${short} reads ${hex(await characteristic.readValue())}`);
465
- if (rest[1] !== undefined) {
466
- const value = Number(rest[1]);
467
- session.log(`Writing ${short} = ${value}`);
468
- await session.write(uuid, Buffer.from([value]));
469
- await sleep(3_000);
470
- session.log(`${short} now reads ${hex(await characteristic.readValue())}`);
471
- }
472
- return;
473
- }
474
-
475
- if (mode === 'setattr') {
476
- // Write one attribute by hand: `setattr <MAC> <SERIAL> 0x2026 1`.
477
- // Reads it first and prints the old value, so it can be put back.
478
- const id = Number(rest[0]);
479
- const value = Number(rest[1]);
480
- const attr = session.chars[CHAR_WRITE_ATTR];
481
- attr?.on('valuechanged', (buf) => session.log(` attr ${hex(buf)}`));
482
- await attr?.startNotifications();
483
- await session.watchAll();
484
-
485
- const body = Buffer.alloc(2);
486
- body.writeUInt16LE(id);
487
- for (const f of fragmentMessage(0x90, body)) {
488
- await session.write(CHAR_WRITE_ATTR, f);
489
- }
490
- await sleep(600);
491
- session.log(`Writing 0x${id.toString(16)} = ${value}`);
492
- const set = Buffer.alloc(4);
493
- set.writeUInt16LE(id, 0);
494
- set.writeUInt16LE(1, 2);
495
- for (const f of fragmentMessage(0x93, Buffer.concat([set, Buffer.from([value])]))) {
496
- await session.write(CHAR_WRITE_ATTR, f);
497
- }
498
- await sleep(5_000);
499
- session.log(`State now ${JSON.stringify(await session.readState())}`);
500
- return;
501
- }
502
-
503
- if (mode === 'monitor') {
504
- // Identification without a stopwatch. Reads every attribute in a loop and
505
- // says only what changed, so whoever is at the lamp can flip a switch
506
- // whenever they like instead of inside a window someone else opened.
507
- const ids = [
508
- 0x2003, 0x2004, 0x2005, 0x2006, 0x2007, 0x2009, 0x200a, 0x200b, 0x200d,
509
- 0x2013, 0x2014, 0x2015, 0x2017, 0x2018, 0x201b, 0x201c, 0x201d, 0x201e,
510
- 0x201f, 0x2021, 0x2023, 0x2024, 0x2025, 0x2026, 0x2028, 0x2029, 0x2030,
511
- 0x2031, 0x2032,
512
- ];
513
- const attr = session.chars[CHAR_WRITE_ATTR];
514
- attr?.on('valuechanged', (buf) => {
515
- if (buf.length >= 7 && buf[1] === 0x97) {
516
- console.log(`*** LAMP REPORTS 0x${buf.readUInt16LE(2).toString(16)} = ${buf[buf.length - 1]}`);
517
- }
518
- });
519
- await attr?.startNotifications();
520
-
521
- const readOne = async (id) => {
522
- let answer;
523
- const handler = (buf) => {
524
- if (buf.length >= 8 && buf[1] === 0x91 && buf.readUInt16LE(2) === id && buf[4] === 0) {
525
- answer = hex(buf.subarray(7));
526
- }
527
- };
528
- attr?.on('valuechanged', handler);
529
- const body = Buffer.alloc(2);
530
- body.writeUInt16LE(id);
531
- for (const f of fragmentMessage(0x90, body)) {
532
- await session.write(CHAR_WRITE_ATTR, f);
533
- }
534
- await sleep(200);
535
- attr?.off('valuechanged', handler);
536
- return answer;
537
- };
538
-
539
- const known = new Map();
540
- for (const id of ids) {
541
- known.set(id, await readOne(id));
542
- }
543
- console.log(`\nWatching ${known.size} attributes for ${seconds}s. Flip the switch whenever you like.`);
544
- console.log('Anything that changes is printed the moment it is noticed.\n');
545
-
546
- const until = Date.now() + seconds * 1_000;
547
- while (Date.now() < until) {
548
- for (const id of ids) {
549
- const now = await readOne(id);
550
- if (now !== undefined && now !== known.get(id)) {
551
- console.log(`*** 0x${id.toString(16)}: ${known.get(id)} → ${now}`);
552
- known.set(id, now);
553
- }
554
- }
555
- }
556
- console.log('\nDone watching.');
557
- return;
558
- }
559
-
560
- if (mode === 'trial') {
561
- await session.watchAll();
562
- const rows = [];
563
- for (let i = 0; i < repeat; i++) {
564
- if (!manual && !noPrompt) {
565
- await ask(
566
- `\nTrial ${i + 1}/${repeat}: put the lamp into daylight mode (MyDyson app or the\n` +
567
- ' button on the lamp), confirm it is tracking, then press Enter. ',
568
- );
569
- }
570
- session.log(`--- trial ${i + 1}/${repeat} ---`);
571
- rows.push(await runTrial(session, TARGETS[i % TARGETS.length], { manual, only, gapMs }));
572
- }
573
-
574
- const valid = rows.filter((r) => !r.void);
575
- const landed = valid.filter((r) => r.kelvinLanded).length;
576
- const discarded = rows.length - valid.length;
577
- console.log(`\n${manual ? 'With' : 'Without'} the daylight-off write: ` +
578
- `colour temperature landed on target in ${landed} of ${valid.length} trials` +
579
- `${discarded ? ` (${discarded} discarded to link drops)` : ''}.`);
580
- console.log(JSON.stringify(rows, null, 2));
581
- return;
582
- }
583
-
584
- usage();
585
- } finally {
586
- await session.close();
587
- }
588
- }
589
-
590
- main().catch((e) => {
591
- console.error(`\nFailed: ${e.message}`);
592
- process.exit(1);
593
- });