homebridge-dyson-solarcycle-morph 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/LICENSE +22 -0
  3. package/README.md +259 -0
  4. package/config.schema.json +118 -0
  5. package/dist/accessory.d.ts +41 -0
  6. package/dist/accessory.js +129 -0
  7. package/dist/accessory.js.map +1 -0
  8. package/dist/cli/pair.d.ts +2 -0
  9. package/dist/cli/pair.js +144 -0
  10. package/dist/cli/pair.js.map +1 -0
  11. package/dist/config.d.ts +51 -0
  12. package/dist/config.js +32 -0
  13. package/dist/config.js.map +1 -0
  14. package/dist/dyson/cloud.d.ts +67 -0
  15. package/dist/dyson/cloud.js +147 -0
  16. package/dist/dyson/cloud.js.map +1 -0
  17. package/dist/dyson/credentials.d.ts +32 -0
  18. package/dist/dyson/credentials.js +73 -0
  19. package/dist/dyson/credentials.js.map +1 -0
  20. package/dist/dyson/crypto.d.ts +38 -0
  21. package/dist/dyson/crypto.js +98 -0
  22. package/dist/dyson/crypto.js.map +1 -0
  23. package/dist/dyson/debounce.d.ts +33 -0
  24. package/dist/dyson/debounce.js +59 -0
  25. package/dist/dyson/debounce.js.map +1 -0
  26. package/dist/dyson/lamp.d.ts +193 -0
  27. package/dist/dyson/lamp.js +692 -0
  28. package/dist/dyson/lamp.js.map +1 -0
  29. package/dist/dyson/protocol.d.ts +91 -0
  30. package/dist/dyson/protocol.js +156 -0
  31. package/dist/dyson/protocol.js.map +1 -0
  32. package/dist/dyson/queue.d.ts +24 -0
  33. package/dist/dyson/queue.js +51 -0
  34. package/dist/dyson/queue.js.map +1 -0
  35. package/dist/dyson/reconcile.d.ts +38 -0
  36. package/dist/dyson/reconcile.js +30 -0
  37. package/dist/dyson/reconcile.js.map +1 -0
  38. package/dist/dyson/settle.d.ts +30 -0
  39. package/dist/dyson/settle.js +56 -0
  40. package/dist/dyson/settle.js.map +1 -0
  41. package/dist/errors.d.ts +8 -0
  42. package/dist/errors.js +20 -0
  43. package/dist/errors.js.map +1 -0
  44. package/dist/index.d.ts +4 -0
  45. package/dist/index.js +7 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/platform.d.ts +27 -0
  48. package/dist/platform.js +106 -0
  49. package/dist/platform.js.map +1 -0
  50. package/dist/settings.d.ts +4 -0
  51. package/dist/settings.js +5 -0
  52. package/dist/settings.js.map +1 -0
  53. package/docs/PROTOCOL.md +153 -0
  54. package/homebridge-ui/public/index.html +396 -0
  55. package/homebridge-ui/server.js +297 -0
  56. package/package.json +68 -0
  57. package/tools/ble-probe.js +132 -0
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Cryptography for the Dyson LTK re-authentication handshake.
3
+ *
4
+ * Despite what Dyson's own protocol notes claim, this is not AES-GCM: the app
5
+ * derives an AES-128 key from the long-term key via HKDF-SHA256 and then uses
6
+ * unpadded AES-CBC with a separate HMAC-SHA256 over the ciphertext
7
+ * (encrypt-then-MAC), with the same key serving both roles.
8
+ */
9
+ import { createCipheriv, createDecipheriv, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
10
+ /** HKDF `info` string, matching `g20/b.d` in the MyDyson Android app. */
11
+ const HKDF_INFO = Buffer.from('USER_AUTH_AES\x00\x00\x00', 'binary');
12
+ /** Every encrypted block in this protocol is exactly one AES block. */
13
+ const BLOCK = 16;
14
+ /**
15
+ * Derive the 16-byte AES key from the long-term key.
16
+ *
17
+ * HKDF-SHA256 with an empty salt, truncated to the first expansion block.
18
+ */
19
+ export function deriveAesKey(ltk) {
20
+ const prk = createHmac('sha256', Buffer.alloc(0)).update(ltk).digest();
21
+ const block1 = createHmac('sha256', prk)
22
+ .update(Buffer.concat([HKDF_INFO, Buffer.from([0x01])]))
23
+ .digest();
24
+ return block1.subarray(0, BLOCK);
25
+ }
26
+ /**
27
+ * Encrypt-then-MAC one or more AES blocks.
28
+ *
29
+ * @returns `IV(16) || ciphertext || HMAC-SHA256(ciphertext)(32)` — 64 bytes for
30
+ * the single-block plaintexts this protocol uses.
31
+ */
32
+ export function sealBlock(key, plaintext, iv = randomBytes(BLOCK)) {
33
+ if (plaintext.length % BLOCK !== 0) {
34
+ throw new Error(`plaintext must be a multiple of ${BLOCK} bytes, got ${plaintext.length}`);
35
+ }
36
+ const cipher = createCipheriv('aes-128-cbc', key, iv);
37
+ cipher.setAutoPadding(false);
38
+ const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]);
39
+ const mac = createHmac('sha256', key).update(ct).digest();
40
+ return Buffer.concat([iv, ct, mac]);
41
+ }
42
+ /** Unpadded AES-128-CBC decryption. */
43
+ function openBlock(key, iv, ciphertext) {
44
+ const decipher = createDecipheriv('aes-128-cbc', key, iv);
45
+ decipher.setAutoPadding(false);
46
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
47
+ }
48
+ /**
49
+ * Build the 82-byte re-auth PayloadA (message type 0x06).
50
+ *
51
+ * Layout: `account GUID(16) || 0x00 0x00 || sealBlock(nonce)(64)`.
52
+ */
53
+ export function buildReauthPayloadA(accountUuid, key, nonce = randomBytes(BLOCK)) {
54
+ return Buffer.concat([uuidToBytes(accountUuid), Buffer.from([0x00, 0x00]), sealBlock(key, nonce)]);
55
+ }
56
+ /**
57
+ * Build the 66-byte re-auth PayloadC (message type 0x08).
58
+ *
59
+ * Layout: `0x00 0x00 || sealBlock(challenge)(64)`.
60
+ */
61
+ export function buildReauthPayloadC(key, challenge) {
62
+ return Buffer.concat([Buffer.from([0x00, 0x00]), sealBlock(key, challenge)]);
63
+ }
64
+ /**
65
+ * Recover the lamp's challenge from PayloadB (message type 0x07).
66
+ *
67
+ * Layout: `0x00 0x00 || IV(16) || ciphertext(32) || MAC(32)?`. The MAC is
68
+ * absent on some firmware; when present we verify it, since a mismatch is the
69
+ * earliest and clearest signal that the stored LTK is wrong.
70
+ *
71
+ * @param payload The reassembled PayloadB, type byte already stripped.
72
+ * @returns The 16-byte challenge to echo back in PayloadC.
73
+ */
74
+ export function parseReauthPayloadB(key, payload) {
75
+ if (payload.length < 50) {
76
+ throw new Error(`PayloadB too short (${payload.length} bytes, need >= 50) — the lamp likely rejected the handshake`);
77
+ }
78
+ const iv = payload.subarray(2, 18);
79
+ const ct = payload.subarray(18, 50);
80
+ const mac = payload.subarray(50, 82);
81
+ if (mac.length === 32) {
82
+ const expected = createHmac('sha256', key).update(ct).digest();
83
+ if (!timingSafeEqual(mac, expected)) {
84
+ throw new Error('PayloadB failed HMAC verification — the stored LTK is wrong or corrupted');
85
+ }
86
+ }
87
+ // Plaintext is our echoed nonce followed by the lamp's own challenge.
88
+ return openBlock(key, iv, ct).subarray(BLOCK, 2 * BLOCK);
89
+ }
90
+ /** Dyson sends account GUIDs in big-endian (RFC 4122) byte order. */
91
+ export function uuidToBytes(uuid) {
92
+ const hex = uuid.replace(/-/g, '');
93
+ if (!/^[0-9a-fA-F]{32}$/.test(hex)) {
94
+ throw new Error(`invalid account UUID: ${uuid}`);
95
+ }
96
+ return Buffer.from(hex, 'hex');
97
+ }
98
+ //# sourceMappingURL=crypto.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"crypto.js","sourceRoot":"","sources":["../../src/dyson/crypto.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Coalesces rapid updates so only the value a control settles on is acted on.
3
+ */
4
+ export declare class Debouncer {
5
+ private readonly pending;
6
+ private readonly delayMs;
7
+ private readonly run;
8
+ /**
9
+ * @param delayMs How long a key must stay unchanged before its task runs.
10
+ * @param run Executes the winning task, typically through a serialising
11
+ * queue. The key is passed on so the queue can apply the same grouping.
12
+ */
13
+ constructor(delayMs: number, run: (task: () => Promise<void>, key: string) => Promise<unknown>);
14
+ /**
15
+ * Schedule `task` for `key`, replacing anything already waiting under it.
16
+ *
17
+ * A superseded call resolves immediately instead of waiting for the task that
18
+ * replaced it. Callers here are HomeKit characteristic handlers, which have a
19
+ * ten-second budget; holding every intermediate slider position open until the
20
+ * final write lands would spend that budget to report a value that is already
21
+ * stale. Callers update their own state optimistically, so nothing is lost.
22
+ */
23
+ schedule(key: string, task: () => Promise<void>): Promise<void>;
24
+ /**
25
+ * Whether a task is waiting under this key.
26
+ *
27
+ * Lets a running task notice that its own result is already obsolete, so it
28
+ * can stop rather than act on a value the caller has moved past.
29
+ */
30
+ isPending(key: string): boolean;
31
+ /** Drop everything waiting, resolving the callers. Nothing is executed. */
32
+ cancelAll(): void;
33
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Coalesces rapid updates so only the value a control settles on is acted on.
3
+ */
4
+ export class Debouncer {
5
+ pending = new Map();
6
+ delayMs;
7
+ run;
8
+ /**
9
+ * @param delayMs How long a key must stay unchanged before its task runs.
10
+ * @param run Executes the winning task, typically through a serialising
11
+ * queue. The key is passed on so the queue can apply the same grouping.
12
+ */
13
+ constructor(delayMs, run) {
14
+ this.delayMs = delayMs;
15
+ this.run = run;
16
+ }
17
+ /**
18
+ * Schedule `task` for `key`, replacing anything already waiting under it.
19
+ *
20
+ * A superseded call resolves immediately instead of waiting for the task that
21
+ * replaced it. Callers here are HomeKit characteristic handlers, which have a
22
+ * ten-second budget; holding every intermediate slider position open until the
23
+ * final write lands would spend that budget to report a value that is already
24
+ * stale. Callers update their own state optimistically, so nothing is lost.
25
+ */
26
+ schedule(key, task) {
27
+ this.pending.get(key)?.supersede();
28
+ return new Promise((resolve, reject) => {
29
+ const timer = setTimeout(() => {
30
+ this.pending.delete(key);
31
+ this.run(task, key).then(() => resolve(), reject);
32
+ }, this.delayMs);
33
+ this.pending.set(key, {
34
+ timer,
35
+ supersede: () => {
36
+ clearTimeout(timer);
37
+ this.pending.delete(key);
38
+ resolve();
39
+ },
40
+ });
41
+ });
42
+ }
43
+ /**
44
+ * Whether a task is waiting under this key.
45
+ *
46
+ * Lets a running task notice that its own result is already obsolete, so it
47
+ * can stop rather than act on a value the caller has moved past.
48
+ */
49
+ isPending(key) {
50
+ return this.pending.has(key);
51
+ }
52
+ /** Drop everything waiting, resolving the callers. Nothing is executed. */
53
+ cancelAll() {
54
+ for (const { supersede } of [...this.pending.values()]) {
55
+ supersede();
56
+ }
57
+ }
58
+ }
59
+ //# sourceMappingURL=debounce.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"debounce.js","sourceRoot":"","sources":["../../src/dyson/debounce.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,MAAM,OAAO,SAAS;IACH,OAAO,GAAG,IAAI,GAAG,EAA4D,CAAC;IAE9E,OAAO,CAAS;IAChB,GAAG,CAA+D;IAEnF;;;;OAIG;IACH,YAAY,OAAe,EAAE,GAAiE;QAC5F,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC;QACvB,IAAI,CAAC,GAAG,GAAG,GAAG,CAAC;IACjB,CAAC;IAED;;;;;;;;OAQG;IACH,QAAQ,CAAC,GAAW,EAAE,IAAyB;QAC7C,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,SAAS,EAAE,CAAC;QACnC,OAAO,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;YAC3C,MAAM,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE;gBAC5B,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;gBACzB,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,OAAO,EAAE,EAAE,MAAM,CAAC,CAAC;YACpD,CAAC,EAAE,IAAI,CAAC,OAAO,CAAC,CAAC;YACjB,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,EAAE;gBACpB,KAAK;gBACL,SAAS,EAAE,GAAG,EAAE;oBACd,YAAY,CAAC,KAAK,CAAC,CAAC;oBACpB,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;oBACzB,OAAO,EAAE,CAAC;gBACZ,CAAC;aACF,CAAC,CAAC;QACL,CAAC,CAAC,CAAC;IACL,CAAC;IAED;;;;;OAKG;IACH,SAAS,CAAC,GAAW;QACnB,OAAO,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IAC/B,CAAC;IAED,2EAA2E;IAC3E,SAAS;QACP,KAAK,MAAM,EAAE,SAAS,EAAE,IAAI,CAAC,GAAG,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC,EAAE,CAAC;YACvD,SAAS,EAAE,CAAC;QACd,CAAC;IACH,CAAC;CACF"}
@@ -0,0 +1,193 @@
1
+ import { EventEmitter } from 'node:events';
2
+ export interface LampState {
3
+ on: boolean;
4
+ /** HomeKit-style brightness, 0-100 %. */
5
+ brightness: number;
6
+ /** Colour temperature in Kelvin, 2700-6500. */
7
+ kelvin: number;
8
+ }
9
+ export interface LampOptions {
10
+ /** BLE MAC, e.g. `AA:BB:CC:DD:EE:FF`. */
11
+ mac: string;
12
+ /** Long-term key as a hex string, from {@link DysonCloud.fetchLtk}. */
13
+ ltk: string;
14
+ /** Dyson account GUID that the LTK was issued to. */
15
+ accountId: string;
16
+ /** Optional HCI adapter name, e.g. `hci0`. Defaults to the system default. */
17
+ adapter?: string;
18
+ log?: Logger;
19
+ }
20
+ export interface Logger {
21
+ debug(message: string, ...args: unknown[]): void;
22
+ info(message: string, ...args: unknown[]): void;
23
+ warn(message: string, ...args: unknown[]): void;
24
+ error(message: string, ...args: unknown[]): void;
25
+ }
26
+ export declare interface DysonMorphLamp {
27
+ on(event: 'connected', listener: () => void): this;
28
+ on(event: 'disconnected', listener: () => void): this;
29
+ on(event: 'state', listener: (state: LampState) => void): this;
30
+ on(event: 'motion', listener: (detected: boolean) => void): this;
31
+ }
32
+ export declare class DysonMorphLamp extends EventEmitter {
33
+ private readonly mac;
34
+ private readonly aesKey;
35
+ private readonly accountId;
36
+ private readonly adapterName;
37
+ private readonly log;
38
+ private bluetooth?;
39
+ private adapter?;
40
+ private device?;
41
+ private chars;
42
+ /** Per-characteristic write mode, derived from the flags the lamp advertises. */
43
+ private writeTypes;
44
+ private readonly assembler;
45
+ private readonly waiters;
46
+ /** Serialises all GATT access. */
47
+ private readonly operations;
48
+ private readonly writes;
49
+ private readonly reconciles;
50
+ private readonly settling;
51
+ /** Whether the running scan is one we started and must clean up. */
52
+ private discoveryIsOurs;
53
+ /** So the "try power-cycling" hint is given once, not on every attempt. */
54
+ private hintedStuck;
55
+ private running;
56
+ private connected;
57
+ private reconnectAttempt;
58
+ private reconnectTimer?;
59
+ private manualModeSetAt;
60
+ private state;
61
+ /** What the user last asked for. Reconciliation aims at this, not at guesses. */
62
+ private desired;
63
+ constructor(options: LampOptions);
64
+ /** Last known state. Updated by notifications and polling. */
65
+ getState(): Readonly<LampState>;
66
+ isConnected(): boolean;
67
+ /** Begin connecting, and keep reconnecting until {@link stop} is called. */
68
+ start(): Promise<void>;
69
+ /** Disconnect and release the D-Bus connection. */
70
+ stop(): Promise<void>;
71
+ setPower(on: boolean): Promise<void>;
72
+ /** @param percent HomeKit brightness, 0-100 %. */
73
+ setBrightness(percent: number): Promise<void>;
74
+ setColorTemperature(kelvin: number): Promise<void>;
75
+ private writeUint16;
76
+ /**
77
+ * Check the lamp ended up where it was asked to, once commands stop arriving.
78
+ *
79
+ * Control writes are unacknowledged — the characteristics offer no `write`
80
+ * flag at all — so one can be dropped with nothing to say so. The lamp
81
+ * notifies on change, but a lost write changes nothing and therefore notifies
82
+ * nothing, which is exactly the case that leaves HomeKit showing a state the
83
+ * lamp is not in. Checking once at the end costs one read of each value
84
+ * instead of one per command, and none of it in the path the user waits on.
85
+ */
86
+ private scheduleReconcile;
87
+ /**
88
+ * Refuse a command there is no way to deliver.
89
+ *
90
+ * Holding it until the link returns sounds helpful and is not: the lamp is
91
+ * already reported as unreachable, so the user knows it did not happen, and
92
+ * replaying a handful of presses minutes later switches the lamp around on
93
+ * its own. Failing plainly leaves them in control.
94
+ */
95
+ private requireConnection;
96
+ private reconcile;
97
+ private connectLoop;
98
+ private connectOnce;
99
+ /**
100
+ * Connect, with a scan running, retrying briskly.
101
+ *
102
+ * Two things came out of measuring this. A scan has to be running and must
103
+ * stay running through the connect — stopping it first roughly halves the
104
+ * success rate. And no arrangement is reliable: the best managed two
105
+ * successes in three, so the answer is repetition rather than a cleverer
106
+ * sequence.
107
+ */
108
+ private connectDevice;
109
+ /**
110
+ * Stop the scan we started, now that it has served its purpose.
111
+ *
112
+ * Leaving it running makes the radio time-slice between scan windows and
113
+ * connection events for as long as the session lasts.
114
+ */
115
+ private stopOurDiscovery;
116
+ /**
117
+ * Ask BlueZ to hold on to this device.
118
+ *
119
+ * An untrusted, unbonded device is dropped from the cache once it is neither
120
+ * connected nor being discovered, and the address then stops resolving at all
121
+ * — `bluetoothctl` reports it as "not available". Trusting it keeps the
122
+ * record, so a reconnect does not have to rediscover the lamp first.
123
+ */
124
+ private markTrusted;
125
+ /**
126
+ * Locate the characteristics we need, wherever the firmware puts them.
127
+ *
128
+ * The published protocol notes place everything under one service, but the
129
+ * Solarcycle Morph spreads them across three (`2dd10010` for auth and RSSI,
130
+ * `2dd10020` for attribute writes, `2dd1fff0` for the control values). Rather
131
+ * than hard-coding a layout that varies by model, we sweep every service and
132
+ * match on characteristic UUID, which is unique regardless of its parent.
133
+ */
134
+ private discoverCharacteristics;
135
+ /** Write using whichever acknowledgement mode the characteristic supports. */
136
+ private write;
137
+ /**
138
+ * LTK re-authentication: prove we hold the key without contacting the cloud.
139
+ *
140
+ * We send an encrypted nonce, the lamp returns it alongside a challenge of
141
+ * its own, and we send that challenge back encrypted. Until the lamp answers
142
+ * with `CONNECTION_ESTABLISHED` it silently discards every control write.
143
+ */
144
+ private authenticate;
145
+ private handleAuthFragment;
146
+ private sendMessage;
147
+ private waitForMessage;
148
+ /**
149
+ * Take the lamp out of daylight mode so it accepts explicit values.
150
+ *
151
+ * Refreshed on a TTL rather than tracked as state: the lamp offers no way to
152
+ * read the current mode back, and the physical button can change it.
153
+ */
154
+ private ensureManualMode;
155
+ private refreshState;
156
+ /** Read what the lamp currently reports. Values it will not give up are absent. */
157
+ private readState;
158
+ /**
159
+ * Put the link quality in the log. A weak signal and a bug in the plugin
160
+ * produce the same symptom — repeated disconnects — and only the number
161
+ * distinguishes them.
162
+ */
163
+ private reportSignalStrength;
164
+ /**
165
+ * Follow the lamp's own state changes instead of polling for them.
166
+ *
167
+ * There is no need to poll to hold the link open — a BLE connection is
168
+ * maintained by the link layer, not by ATT traffic — and polling actively
169
+ * hurt: a periodic read would eventually come back `ATT error 0x0e` and take
170
+ * the connection down with it. Notifications also pick up changes made at the
171
+ * lamp itself, which polling only caught on its next tick.
172
+ */
173
+ private subscribeToState;
174
+ private handleDisconnect;
175
+ private teardown;
176
+ private characteristic;
177
+ /**
178
+ * Run `task` after every previously queued GATT operation has settled.
179
+ *
180
+ * @param key Marks the operation replaceable: queueing another under the same
181
+ * key drops this one. Used for value writes, where an older value is worthless
182
+ * once a newer one is waiting, and omitted for power so it always runs.
183
+ */
184
+ private enqueue;
185
+ /**
186
+ * Publish what the lamp reports, minus the echo of our own commands.
187
+ *
188
+ * A value the user just set must stay where they put it; the steps the lamp
189
+ * takes on its way there are not news, they are the command happening.
190
+ */
191
+ private publishFromLamp;
192
+ private patchState;
193
+ }