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,144 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * One-time pairing helper.
4
+ *
5
+ * Logs in to the Dyson cloud, fetches the lamp's long-term key, and stores it
6
+ * where the plugin will find it. After this the plugin never needs network
7
+ * access again.
8
+ *
9
+ * Most people should use the plugin's settings page in the Homebridge UI
10
+ * instead; this exists for setups without it.
11
+ *
12
+ * Usage: dyson-morph-pair [--serial ABC-EU-…] [--country DE] [--culture de-DE]
13
+ * [--email …] [--storage …] [--debug]
14
+ */
15
+ import { createInterface } from 'node:readline/promises';
16
+ import { stdin, stdout } from 'node:process';
17
+ import { homedir } from 'node:os';
18
+ import { join } from 'node:path';
19
+ import { DysonCloud } from '../dyson/cloud.js';
20
+ import { CredentialStore } from '../dyson/credentials.js';
21
+ import { PLUGIN_NAME } from '../settings.js';
22
+ function parseArgs(argv) {
23
+ const args = {};
24
+ for (let i = 0; i < argv.length; i++) {
25
+ const match = /^--([a-z]+)(?:=(.*))?$/.exec(argv[i]);
26
+ if (!match) {
27
+ continue;
28
+ }
29
+ const key = match[1];
30
+ // --debug is a flag, not a value; don't let it swallow the next argument.
31
+ args[key] = key === 'debug' ? (match[2] ?? 'true') : (match[2] ?? argv[++i]);
32
+ }
33
+ return args;
34
+ }
35
+ /**
36
+ * Prompts the user, or replays piped input.
37
+ *
38
+ * Readline hands over every buffered line the moment stdin delivers it, so a
39
+ * question asked after the first would miss its answer when input is piped.
40
+ * For non-interactive input we therefore read stdin to EOF up front and answer
41
+ * from that queue; only a real TTY goes through readline.
42
+ */
43
+ class Prompter {
44
+ rl;
45
+ scripted;
46
+ constructor(interactive) {
47
+ this.rl = interactive ? createInterface({ input: stdin, output: stdout }) : undefined;
48
+ }
49
+ async ask(query) {
50
+ if (this.rl) {
51
+ return this.rl.question(query);
52
+ }
53
+ this.scripted ??= await readAllLines();
54
+ stdout.write(`${query}\n`);
55
+ return this.scripted.shift() ?? '';
56
+ }
57
+ /**
58
+ * Ask without echoing, so the password stays out of the terminal and its
59
+ * scrollback. Readline has no masking, so we intercept its output: the
60
+ * prompt goes through once, the keystrokes echoed after it do not.
61
+ */
62
+ async askSecret(query) {
63
+ if (!this.rl) {
64
+ return this.ask(query);
65
+ }
66
+ const internals = this.rl;
67
+ const write = internals._writeToOutput;
68
+ let promptShown = false;
69
+ internals._writeToOutput = (text) => {
70
+ if (!promptShown && text.includes(query)) {
71
+ write.call(this.rl, text);
72
+ promptShown = true;
73
+ }
74
+ };
75
+ try {
76
+ return await this.rl.question(query);
77
+ }
78
+ finally {
79
+ internals._writeToOutput = write;
80
+ stdout.write('\n');
81
+ }
82
+ }
83
+ close() {
84
+ this.rl?.close();
85
+ }
86
+ }
87
+ async function readAllLines() {
88
+ const chunks = [];
89
+ for await (const chunk of stdin) {
90
+ chunks.push(chunk);
91
+ }
92
+ return Buffer.concat(chunks).toString('utf8').split('\n');
93
+ }
94
+ /** Reject empty answers early rather than sending them to Dyson. */
95
+ function required(value, what) {
96
+ const trimmed = value.trim();
97
+ if (!trimmed) {
98
+ throw new Error(`No ${what} entered.\n\n` +
99
+ 'This tool needs a terminal it can read from. If you started it through an\n' +
100
+ 'editor, agent or CI shell, the prompts appear but your typing never reaches\n' +
101
+ 'it. Run it directly in a terminal instead.');
102
+ }
103
+ return trimmed;
104
+ }
105
+ async function main(prompter) {
106
+ const args = parseArgs(process.argv.slice(2));
107
+ if (!stdin.isTTY) {
108
+ console.error('Note: stdin is not a terminal, so answers are being read from piped input.\n');
109
+ }
110
+ const country = args.country ?? ((await prompter.ask('Country code [DE]: ')).trim() || 'DE');
111
+ const culture = args.culture ?? `${country.toLowerCase()}-${country.toUpperCase()}`;
112
+ const email = required(args.email ?? (await prompter.ask('Dyson account email: ')), 'email address');
113
+ const password = required(await prompter.askSecret('Dyson account password: '), 'password');
114
+ const serial = required(args.serial ?? (await prompter.ask('Lamp serial number: ')), 'serial number').toUpperCase();
115
+ const storagePath = args.storage ?? join(homedir(), '.homebridge');
116
+ const cloud = new DysonCloud({ country, culture, debug: args.debug === 'true' });
117
+ console.log('\nRequesting a one-time code…');
118
+ const challengeId = await cloud.beginLogin(email);
119
+ console.log('Dyson has emailed you a 6-digit code.');
120
+ const otpCode = required(await prompter.ask('Code from the email: '), 'one-time code');
121
+ const { token, accountId } = await cloud.completeLogin(email, password, challengeId, otpCode);
122
+ console.log(`Logged in. Account ${accountId}`);
123
+ console.log(`Fetching the long-term key for ${serial}…`);
124
+ const ltk = await cloud.fetchLtk(serial, token);
125
+ await new CredentialStore(storagePath, PLUGIN_NAME).set(serial, { ltk, accountId });
126
+ console.log(`Stored the key for ${serial} under ${storagePath}.`);
127
+ console.log('\nAdd this to the platforms array in your Homebridge config.json:\n');
128
+ console.log(JSON.stringify({ platform: 'DysonSolarcycleMorph', lights: [{ name: 'Dyson Morph', mac: '<the lamp BLE address>', serial }] }, null, 2));
129
+ console.log('\nThe key itself is deliberately not shown: it is a device credential, and it now\n' +
130
+ 'lives outside config.json. Pass --storage if your Homebridge storage directory is\n' +
131
+ `not ${storagePath}.`);
132
+ }
133
+ const prompter = new Prompter(Boolean(stdin.isTTY));
134
+ main(prompter)
135
+ .catch((error) => {
136
+ console.error(`\nPairing failed: ${error instanceof Error ? error.message : String(error)}`);
137
+ if (String(error).includes('404')) {
138
+ console.error('A 404 from the LTK endpoint usually means the lamp is not registered to this\n' +
139
+ 'Dyson account. Add it in the MyDyson app first, then run this again.');
140
+ }
141
+ process.exitCode = 1;
142
+ })
143
+ .finally(() => prompter.close());
144
+ //# sourceMappingURL=pair.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pair.js","sourceRoot":"","sources":["../../src/cli/pair.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,51 @@
1
+ import type { PlatformConfig } from 'homebridge';
2
+ /** One lamp, as configured in Homebridge's config.json. */
3
+ export interface LightConfig {
4
+ /** Display name in HomeKit. */
5
+ name: string;
6
+ /** BLE MAC, e.g. `AA:BB:CC:DD:EE:FF`. */
7
+ mac: string;
8
+ /** Lamp serial. Used as the HomeKit accessory identity, so it must be stable. */
9
+ serial: string;
10
+ /**
11
+ * Long-term key (hex). Normally absent: pairing stores it outside the config
12
+ * so the key is not kept in a file the UI displays. Set it here only to
13
+ * override the stored value, e.g. when migrating an existing setup.
14
+ */
15
+ ltk?: string;
16
+ /** Dyson account GUID the LTK was issued to. Stored alongside the key. */
17
+ accountId?: string;
18
+ /** Expose the built-in motion sensor as a separate HomeKit service. */
19
+ motionSensor?: boolean;
20
+ }
21
+ /** A lamp whose credentials are known, from the config or the credential store. */
22
+ export type ResolvedLightConfig = LightConfig & {
23
+ ltk: string;
24
+ accountId: string;
25
+ };
26
+ /**
27
+ * MyDyson credentials, owned by the settings page.
28
+ *
29
+ * The plugin itself never reads these: they exist so the settings page can
30
+ * remember who to authorise as between visits. The password is only needed
31
+ * while exchanging a code for a token, and the page offers to clear it after.
32
+ */
33
+ export interface DysonAccountConfig {
34
+ email?: string;
35
+ password?: string;
36
+ country?: string;
37
+ }
38
+ export interface MorphPlatformConfig extends PlatformConfig {
39
+ /** Homebridge uses this as the log prefix for everything this plugin says. */
40
+ name?: string;
41
+ lights?: LightConfig[];
42
+ dysonAccount?: DysonAccountConfig;
43
+ /** HCI adapter to use, e.g. `hci0`. Defaults to the system default adapter. */
44
+ adapter?: string;
45
+ }
46
+ /**
47
+ * Validate one configured lamp.
48
+ *
49
+ * @returns A list of human-readable problems; empty means the entry is usable.
50
+ */
51
+ export declare function validateLightConfig(light: Partial<LightConfig>, index: number): string[];
package/dist/config.js ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Validate one configured lamp.
3
+ *
4
+ * @returns A list of human-readable problems; empty means the entry is usable.
5
+ */
6
+ export function validateLightConfig(light, index) {
7
+ const problems = [];
8
+ const where = `lights[${index}]`;
9
+ if (!light.name?.trim()) {
10
+ problems.push(`${where}.name is required`);
11
+ }
12
+ if (!light.mac || !/^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/.test(light.mac)) {
13
+ problems.push(`${where}.mac must look like AA:BB:CC:DD:EE:FF (got ${JSON.stringify(light.mac)})`);
14
+ }
15
+ if (!light.serial?.trim()) {
16
+ problems.push(`${where}.serial is required`);
17
+ }
18
+ // Credentials are optional here — they normally come from the credential
19
+ // store. Only validate what was actually supplied; a missing pair is reported
20
+ // later, once the store has been consulted.
21
+ if (light.ltk !== undefined && (!/^[0-9a-fA-F]+$/.test(light.ltk) || light.ltk.length % 2 !== 0)) {
22
+ problems.push(`${where}.ltk must be an even-length hex string`);
23
+ }
24
+ if (light.accountId !== undefined && !/^[0-9a-fA-F-]{36}$/.test(light.accountId)) {
25
+ problems.push(`${where}.accountId must be a Dyson account UUID`);
26
+ }
27
+ if ((light.ltk === undefined) !== (light.accountId === undefined)) {
28
+ problems.push(`${where} sets only one of ltk/accountId — supply both to override the stored credentials, or neither`);
29
+ }
30
+ return problems;
31
+ }
32
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AA+CA;;;;GAIG;AACH,MAAM,UAAU,mBAAmB,CAAC,KAA2B,EAAE,KAAa;IAC5E,MAAM,QAAQ,GAAa,EAAE,CAAC;IAC9B,MAAM,KAAK,GAAG,UAAU,KAAK,GAAG,CAAC;IAEjC,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,IAAI,EAAE,EAAE,CAAC;QACxB,QAAQ,CAAC,IAAI,CAAC,GAAG,KAAK,mBAAmB,CAAC,CAAC;IAC7C,CAAC;IACD,IAAI,CAAC,KAAK,CAAC,GAAG,IAAI,CAAC,sCAAsC,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,CAAC;QAC1E,QAAQ,CAAC,IAAI,CAAC,GAAG,KAAK,8CAA8C,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IACpG,CAAC;IACD,IAAI,CAAC,KAAK,CAAC,MAAM,EAAE,IAAI,EAAE,EAAE,CAAC;QAC1B,QAAQ,CAAC,IAAI,CAAC,GAAG,KAAK,qBAAqB,CAAC,CAAC;IAC/C,CAAC;IACD,yEAAyE;IACzE,8EAA8E;IAC9E,4CAA4C;IAC5C,IAAI,KAAK,CAAC,GAAG,KAAK,SAAS,IAAI,CAAC,CAAC,gBAAgB,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,KAAK,CAAC,GAAG,CAAC,MAAM,GAAG,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC;QACjG,QAAQ,CAAC,IAAI,CAAC,GAAG,KAAK,wCAAwC,CAAC,CAAC;IAClE,CAAC;IACD,IAAI,KAAK,CAAC,SAAS,KAAK,SAAS,IAAI,CAAC,oBAAoB,CAAC,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,EAAE,CAAC;QACjF,QAAQ,CAAC,IAAI,CAAC,GAAG,KAAK,yCAAyC,CAAC,CAAC;IACnE,CAAC;IACD,IAAI,CAAC,KAAK,CAAC,GAAG,KAAK,SAAS,CAAC,KAAK,CAAC,KAAK,CAAC,SAAS,KAAK,SAAS,CAAC,EAAE,CAAC;QAClE,QAAQ,CAAC,IAAI,CAAC,GAAG,KAAK,8FAA8F,CAAC,CAAC;IACxH,CAAC;IACD,OAAO,QAAQ,CAAC;AAClB,CAAC"}
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Minimal Dyson cloud client — just enough to obtain the long-term key (LTK)
3
+ * that the lamp's offline handshake needs.
4
+ *
5
+ * This runs once, during pairing. Normal operation never touches the network.
6
+ */
7
+ /** One device as the account describes it. */
8
+ export interface DysonDevice {
9
+ serialNumber: string;
10
+ name: string;
11
+ /** `light`, `robot`, `ec` (air treatment), and so on. */
12
+ category?: string;
13
+ /** `lecOnly` means Bluetooth with no Wi-Fi path. */
14
+ connectionCategory?: string;
15
+ model?: string | null;
16
+ type?: string;
17
+ }
18
+ /** Devices this plugin can drive: lights reachable over Bluetooth. */
19
+ export declare function isBluetoothLight(device: DysonDevice): boolean;
20
+ export interface LoginResult {
21
+ /** Bearer token for subsequent calls. */
22
+ token: string;
23
+ /** Account GUID — goes into re-auth PayloadA verbatim. */
24
+ accountId: string;
25
+ }
26
+ export interface CloudOptions {
27
+ /** ISO 3166-1 alpha-2, e.g. `DE`. Selects the regional endpoint. */
28
+ country?: string;
29
+ /** BCP 47 culture, e.g. `de-DE`. */
30
+ culture?: string;
31
+ timeoutMs?: number;
32
+ /** Log every request and response. Never logs credentials or tokens. */
33
+ debug?: boolean;
34
+ }
35
+ export declare class DysonCloud {
36
+ private readonly country;
37
+ private readonly culture;
38
+ private readonly timeoutMs;
39
+ private readonly debug;
40
+ constructor(options?: CloudOptions);
41
+ /**
42
+ * Start a login. Dyson emails a one-time code and returns the challenge it
43
+ * belongs to.
44
+ *
45
+ * @returns The `challengeId` to pass to {@link completeLogin}.
46
+ */
47
+ beginLogin(email: string): Promise<string>;
48
+ /** Exchange the emailed one-time code for a bearer token and account GUID. */
49
+ completeLogin(email: string, password: string, challengeId: string, otpCode: string): Promise<LoginResult>;
50
+ /**
51
+ * List the devices registered to the account.
52
+ *
53
+ * This is how a light is told apart from a vacuum: both advertise the same
54
+ * Dyson BLE service and neither carries model information in its
55
+ * advertisement, so the account is the only reliable source.
56
+ */
57
+ getDevices(token: string): Promise<DysonDevice[]>;
58
+ /**
59
+ * Fetch a lamp's long-term key.
60
+ *
61
+ * Requires the lamp to already be registered to this account in the MyDyson
62
+ * app; otherwise the endpoint returns 404 and you need the physical
63
+ * fresh-pairing flow instead.
64
+ */
65
+ fetchLtk(serial: string, token: string): Promise<string>;
66
+ private request;
67
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Minimal Dyson cloud client — just enough to obtain the long-term key (LTK)
3
+ * that the lamp's offline handshake needs.
4
+ *
5
+ * This runs once, during pairing. Normal operation never touches the network.
6
+ */
7
+ /** The `X-Dyson-ApiAuthCode` the LTK endpoint accepts in place of a session code. */
8
+ const LTK_AUTH_CODE = '80541406';
9
+ /** The MyDyson Android app identifies itself with exactly this string. */
10
+ const USER_AGENT = 'android client';
11
+ /** Only China has a dedicated endpoint; everything else uses the global one. */
12
+ function apiHostname(country) {
13
+ return country.toUpperCase() === 'CN' ? 'https://appapi.cp.dyson.cn' : 'https://appapi.cp.dyson.com';
14
+ }
15
+ /** Devices this plugin can drive: lights reachable over Bluetooth. */
16
+ export function isBluetoothLight(device) {
17
+ return device.category === 'light' && device.connectionCategory !== 'wifiOnly';
18
+ }
19
+ export class DysonCloud {
20
+ country;
21
+ culture;
22
+ timeoutMs;
23
+ debug;
24
+ constructor(options = {}) {
25
+ this.country = options.country ?? 'DE';
26
+ this.culture = options.culture ?? 'de-DE';
27
+ this.timeoutMs = options.timeoutMs ?? 20_000;
28
+ this.debug = options.debug ?? false;
29
+ }
30
+ /**
31
+ * Start a login. Dyson emails a one-time code and returns the challenge it
32
+ * belongs to.
33
+ *
34
+ * @returns The `challengeId` to pass to {@link completeLogin}.
35
+ */
36
+ async beginLogin(email) {
37
+ // The API rejects clients that have not announced themselves recently.
38
+ await this.request('GET', '/v1/provisioningservice/application/Android/version');
39
+ await this.request('POST', '/v3/userregistration/email/userstatus', {
40
+ query: { country: this.country },
41
+ body: { email },
42
+ });
43
+ const data = await this.request('POST', '/v3/userregistration/email/auth', {
44
+ query: { country: this.country, culture: this.culture },
45
+ body: { email },
46
+ });
47
+ if (!data.challengeId) {
48
+ throw new Error('Dyson did not return a challengeId — is the email registered?');
49
+ }
50
+ return data.challengeId;
51
+ }
52
+ /** Exchange the emailed one-time code for a bearer token and account GUID. */
53
+ async completeLogin(email, password, challengeId, otpCode) {
54
+ const data = await this.request('POST', '/v3/userregistration/email/verify', {
55
+ query: { country: this.country, culture: this.culture },
56
+ body: { challengeId, email, otpCode, password },
57
+ });
58
+ if (!data.token || !data.account) {
59
+ throw new Error('Dyson login succeeded but returned no token/account');
60
+ }
61
+ return { token: data.token, accountId: data.account };
62
+ }
63
+ /**
64
+ * List the devices registered to the account.
65
+ *
66
+ * This is how a light is told apart from a vacuum: both advertise the same
67
+ * Dyson BLE service and neither carries model information in its
68
+ * advertisement, so the account is the only reliable source.
69
+ */
70
+ async getDevices(token) {
71
+ const devices = await this.request('GET', '/v3/manifest', {
72
+ headers: { Authorization: `Bearer ${token}` },
73
+ });
74
+ return Array.isArray(devices) ? devices : [];
75
+ }
76
+ /**
77
+ * Fetch a lamp's long-term key.
78
+ *
79
+ * Requires the lamp to already be registered to this account in the MyDyson
80
+ * app; otherwise the endpoint returns 404 and you need the physical
81
+ * fresh-pairing flow instead.
82
+ */
83
+ async fetchLtk(serial, token) {
84
+ const data = await this.request('GET', `/v1/lec/${encodeURIComponent(serial)}/ltk`, {
85
+ headers: {
86
+ Authorization: `Bearer ${token}`,
87
+ 'X-Dyson-ApiAuthCode': LTK_AUTH_CODE,
88
+ },
89
+ });
90
+ if (!data.ltk) {
91
+ throw new Error(`Dyson returned no LTK for ${serial}`);
92
+ }
93
+ return data.ltk;
94
+ }
95
+ async request(method, path, options = {}) {
96
+ const url = new URL(path, apiHostname(this.country));
97
+ for (const [k, v] of Object.entries(options.query ?? {})) {
98
+ url.searchParams.set(k, v);
99
+ }
100
+ const headers = {
101
+ 'User-Agent': USER_AGENT,
102
+ Accept: 'application/json',
103
+ ...options.headers,
104
+ };
105
+ if (options.body !== undefined) {
106
+ headers['Content-Type'] = 'application/json';
107
+ }
108
+ if (this.debug) {
109
+ console.error(`→ ${method} ${url}`);
110
+ console.error(` headers: ${JSON.stringify(redact(headers))}`);
111
+ console.error(` body: ${JSON.stringify(redact(options.body))}`);
112
+ }
113
+ const response = await fetch(url, {
114
+ method,
115
+ headers,
116
+ body: options.body === undefined ? undefined : JSON.stringify(options.body),
117
+ signal: AbortSignal.timeout(this.timeoutMs),
118
+ });
119
+ const text = await response.text();
120
+ if (this.debug) {
121
+ console.error(`← ${response.status} ${response.statusText}`);
122
+ console.error(` headers: ${JSON.stringify(Object.fromEntries(response.headers))}`);
123
+ console.error(` body: ${text.slice(0, 500)}`);
124
+ }
125
+ if (!response.ok) {
126
+ throw new Error(`Dyson API ${method} ${path} failed: ${response.status} ${response.statusText} ${text.slice(0, 300)}`);
127
+ }
128
+ if (!text) {
129
+ return {};
130
+ }
131
+ try {
132
+ return JSON.parse(text);
133
+ }
134
+ catch {
135
+ throw new Error(`Dyson API ${method} ${path} returned non-JSON: ${text.slice(0, 200)}`);
136
+ }
137
+ }
138
+ }
139
+ /** Strip anything credential-shaped so debug output is safe to paste. */
140
+ function redact(value) {
141
+ if (value === undefined || value === null || typeof value !== 'object') {
142
+ return value;
143
+ }
144
+ const secret = /password|token|otp|ltk|authorization|apiauthcode/i;
145
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, secret.test(k) ? '<redacted>' : v]));
146
+ }
147
+ //# sourceMappingURL=cloud.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cloud.js","sourceRoot":"","sources":["../../src/dyson/cloud.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,32 @@
1
+ /** What pairing produces for one lamp. */
2
+ export interface StoredCredentials {
3
+ /** Long-term key as hex. */
4
+ ltk: string;
5
+ /** Dyson account GUID the key was issued to. */
6
+ accountId: string;
7
+ /** ISO timestamp, so the UI can say how old an authorisation is. */
8
+ created: string;
9
+ }
10
+ /** A MyDyson session, kept so the device list survives a page reload. */
11
+ export interface StoredAccount {
12
+ token: string;
13
+ accountId: string;
14
+ created: string;
15
+ }
16
+ export declare class CredentialStore {
17
+ private readonly directory;
18
+ private store;
19
+ /** @param storagePath Homebridge's storage directory. */
20
+ constructor(storagePath: string, pluginName: string);
21
+ private open;
22
+ /** Serials are case-insensitive in practice; normalise so lookups agree. */
23
+ private static key;
24
+ get(serial: string): Promise<StoredCredentials | undefined>;
25
+ set(serial: string, credentials: Omit<StoredCredentials, 'created'>): Promise<void>;
26
+ delete(serial: string): Promise<void>;
27
+ getAccount(email: string): Promise<StoredAccount | undefined>;
28
+ setAccount(email: string, account: Omit<StoredAccount, 'created'>): Promise<void>;
29
+ deleteAccount(email: string): Promise<void>;
30
+ /** Serials that have stored credentials. */
31
+ list(): Promise<string[]>;
32
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Persistent storage for lamp credentials.
3
+ *
4
+ * The long-term key is a device credential, and `config.json` is the wrong
5
+ * place for it: the Homebridge UI shows that file, it gets pasted into support
6
+ * threads, and it is backed up wherever the storage directory is not. Keys live
7
+ * here instead, in the same node-persist store the custom UI writes to, so
8
+ * pairing never has to round-trip through the config at all.
9
+ */
10
+ import { join } from 'node:path';
11
+ import storage from 'node-persist';
12
+ export class CredentialStore {
13
+ directory;
14
+ store;
15
+ /** @param storagePath Homebridge's storage directory. */
16
+ constructor(storagePath, pluginName) {
17
+ this.directory = join(storagePath, pluginName, 'persist');
18
+ }
19
+ async open() {
20
+ if (!this.store) {
21
+ const store = storage.create({ dir: this.directory });
22
+ await store.init();
23
+ this.store = store;
24
+ }
25
+ return this.store;
26
+ }
27
+ /** Serials are case-insensitive in practice; normalise so lookups agree. */
28
+ static key(serial) {
29
+ return `${serial.trim().toUpperCase()}:credentials`;
30
+ }
31
+ async get(serial) {
32
+ const stored = await (await this.open()).getItem(CredentialStore.key(serial));
33
+ if (!stored || typeof stored !== 'object') {
34
+ return undefined;
35
+ }
36
+ const { ltk, accountId } = stored;
37
+ // A half-written entry is worse than none: it would fail the handshake with
38
+ // a confusing crypto error rather than an obvious "not paired yet".
39
+ return ltk && accountId ? stored : undefined;
40
+ }
41
+ async set(serial, credentials) {
42
+ await (await this.open()).setItem(CredentialStore.key(serial), {
43
+ ...credentials,
44
+ created: new Date().toISOString(),
45
+ });
46
+ }
47
+ async delete(serial) {
48
+ await (await this.open()).removeItem(CredentialStore.key(serial));
49
+ }
50
+ async getAccount(email) {
51
+ const stored = await (await this.open()).getItem(`${email.trim().toLowerCase()}:account`);
52
+ if (!stored || typeof stored !== 'object') {
53
+ return undefined;
54
+ }
55
+ const { token, accountId } = stored;
56
+ return token && accountId ? stored : undefined;
57
+ }
58
+ async setAccount(email, account) {
59
+ await (await this.open()).setItem(`${email.trim().toLowerCase()}:account`, {
60
+ ...account,
61
+ created: new Date().toISOString(),
62
+ });
63
+ }
64
+ async deleteAccount(email) {
65
+ await (await this.open()).removeItem(`${email.trim().toLowerCase()}:account`);
66
+ }
67
+ /** Serials that have stored credentials. */
68
+ async list() {
69
+ const keys = await (await this.open()).keys();
70
+ return keys.filter((k) => k.endsWith(':credentials')).map((k) => k.slice(0, -':credentials'.length));
71
+ }
72
+ }
73
+ //# sourceMappingURL=credentials.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"credentials.js","sourceRoot":"","sources":["../../src/dyson/credentials.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Derive the 16-byte AES key from the long-term key.
3
+ *
4
+ * HKDF-SHA256 with an empty salt, truncated to the first expansion block.
5
+ */
6
+ export declare function deriveAesKey(ltk: Buffer): Buffer;
7
+ /**
8
+ * Encrypt-then-MAC one or more AES blocks.
9
+ *
10
+ * @returns `IV(16) || ciphertext || HMAC-SHA256(ciphertext)(32)` — 64 bytes for
11
+ * the single-block plaintexts this protocol uses.
12
+ */
13
+ export declare function sealBlock(key: Buffer, plaintext: Buffer, iv?: Buffer): Buffer;
14
+ /**
15
+ * Build the 82-byte re-auth PayloadA (message type 0x06).
16
+ *
17
+ * Layout: `account GUID(16) || 0x00 0x00 || sealBlock(nonce)(64)`.
18
+ */
19
+ export declare function buildReauthPayloadA(accountUuid: string, key: Buffer, nonce?: Buffer): Buffer;
20
+ /**
21
+ * Build the 66-byte re-auth PayloadC (message type 0x08).
22
+ *
23
+ * Layout: `0x00 0x00 || sealBlock(challenge)(64)`.
24
+ */
25
+ export declare function buildReauthPayloadC(key: Buffer, challenge: Buffer): Buffer;
26
+ /**
27
+ * Recover the lamp's challenge from PayloadB (message type 0x07).
28
+ *
29
+ * Layout: `0x00 0x00 || IV(16) || ciphertext(32) || MAC(32)?`. The MAC is
30
+ * absent on some firmware; when present we verify it, since a mismatch is the
31
+ * earliest and clearest signal that the stored LTK is wrong.
32
+ *
33
+ * @param payload The reassembled PayloadB, type byte already stripped.
34
+ * @returns The 16-byte challenge to echo back in PayloadC.
35
+ */
36
+ export declare function parseReauthPayloadB(key: Buffer, payload: Buffer): Buffer;
37
+ /** Dyson sends account GUIDs in big-endian (RFC 4122) byte order. */
38
+ export declare function uuidToBytes(uuid: string): Buffer;