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
package/CHANGELOG.md ADDED
@@ -0,0 +1,52 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+
5
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] — 2026-09-17
9
+
10
+ First release. Verified end to end against a Solarcycle Morph desk light
11
+ (model CD06) on Raspberry Pi OS Bookworm, BlueZ 5.66, Homebridge 2.4.
12
+
13
+ ### Added
14
+
15
+ - A settings page in the Homebridge UI that scans for nearby lights, authorises
16
+ a MyDyson account and pairs them, replacing the need to run anything by hand.
17
+ - Credentials are stored in the plugin's storage directory rather than in
18
+ `config.json`, so the file the UI displays holds nothing sensitive. Setting
19
+ `ltk` and `accountId` in the config still works and takes precedence.
20
+ - Signal strength is logged on connect, with a warning below −80 dBm — the point
21
+ where BLE supervision timeouts make a link drop repeatedly.
22
+
23
+ - HomeKit lightbulb with on/off, brightness and colour temperature (2700–6500 K).
24
+ - Optional motion sensor from the lamp's built-in detector.
25
+ - Offline LTK re-authentication, run on every connect. No network access after
26
+ pairing.
27
+ - `dyson-morph-pair`, the same flow from a terminal, for setups without the
28
+ Homebridge UI.
29
+ - `tools/ble-probe.js`, a standalone GATT dump for inspecting a lamp without
30
+ installing the plugin.
31
+ - Reconnect with backoff, state followed through notifications, and the lamp
32
+ reported as unreachable in HomeKit while there is no link.
33
+ - Power commands are checked and resent if they did not land. Brightness and
34
+ colour temperature are not: the lamp ramps and trims them itself, so a reading
35
+ that differs from the request is usually the lamp working.
36
+
37
+ ### Requirements
38
+
39
+ - Homebridge 2.x. The plugin is ESM, matching Homebridge 2's own module format.
40
+
41
+ ### Notes on the protocol
42
+
43
+ Probing real hardware contradicted the published protocol notes in two places,
44
+ both handled here rather than assumed away:
45
+
46
+ - Characteristics are spread across three GATT services, not gathered under one.
47
+ Discovery sweeps every service and matches on characteristic UUID.
48
+ - Acknowledged writes are described as mandatory, but this lamp advertises no
49
+ `write` flag at all and unacknowledged writes do take effect. The write mode
50
+ is taken from each characteristic's advertised flags.
51
+
52
+ See [docs/PROTOCOL.md](docs/PROTOCOL.md).
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Oliver Rummeyer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
package/README.md ADDED
@@ -0,0 +1,259 @@
1
+ # homebridge-dyson-solarcycle-morph
2
+
3
+ Homebridge plugin for the **Dyson Solarcycle Morph** and **Lightcycle Morph**
4
+ task lights (model CD06).
5
+
6
+ These lamps have no Wi-Fi. Everything goes over Bluetooth LE, so the machine
7
+ running Homebridge has to be within Bluetooth range of the lamp — there is no
8
+ cloud path to fall back on.
9
+
10
+ Exposes a HomeKit lightbulb with on/off, brightness and colour temperature
11
+ (2700–6500 K), plus the lamp's built-in motion sensor if you want it.
12
+
13
+ ## Before you start
14
+
15
+ **Your lamp must already be registered in the MyDyson app, on the account you
16
+ are going to log in with.** This is not optional and it is worth explaining why,
17
+ because it is the one thing that can stop you dead.
18
+
19
+ The lamp ignores every command until a challenge-response handshake proves you
20
+ hold its *long-term key* (LTK). That key is issued by Dyson, tied to the account
21
+ the lamp is registered to, and there is no way to derive it from the device
22
+ itself. Pairing here fetches it once from the Dyson cloud. If the lamp is not on
23
+ your account, the endpoint returns 404 and there is nothing this plugin can do.
24
+
25
+ The only other way to get an LTK is Dyson's "fresh pairing" handshake, which
26
+ needs a physical button press on the lamp *and* a working cloud session — this
27
+ plugin does not implement it. So: open the MyDyson app, make sure the lamp is
28
+ there, then continue.
29
+
30
+ Everything after pairing is local. The plugin never touches the network again.
31
+
32
+ ## Requirements
33
+
34
+ - A Linux host with BlueZ — a Raspberry Pi running Homebridge is the usual case.
35
+ This uses [`node-ble`](https://github.com/chrvadala/node-ble), which talks to
36
+ BlueZ over D-Bus, so **macOS and Windows will not work**.
37
+ - Node.js 20.19+ / 22.12+ / 24+.
38
+ - Homebridge 2.x.
39
+
40
+ Developed and verified against a Solarcycle Morph desk light on Raspberry Pi OS
41
+ Bookworm, BlueZ 5.66, Homebridge 2.4.
42
+
43
+ ## 1. Find the lamp
44
+
45
+ ```sh
46
+ sudo bluetoothctl --timeout 15 scan le
47
+ ```
48
+
49
+ Look for a device whose name is a Dyson serial, something like
50
+ `ABC-EU-XXXXXXXX`. **The lamp advertises its serial number as its Bluetooth
51
+ name**, so this one command gives you both values you need:
52
+
53
+ ```sh
54
+ bluetoothctl info AA:BB:CC:DD:EE:FF
55
+ ```
56
+
57
+ ```
58
+ Device AA:BB:CC:DD:EE:FF (random)
59
+ Name: ABC-EU-XXXXXXXX ← serial number
60
+ UUID: Vendor specific (2dd10010-1c37-452d-8979-d1b4a787d0a4)
61
+ ```
62
+
63
+ That `2dd10010` service UUID confirms it is a Dyson light speaking the protocol
64
+ this plugin implements.
65
+
66
+ ## 2. Install
67
+
68
+ ```sh
69
+ sudo npm install -g homebridge-dyson-solarcycle-morph
70
+ ```
71
+
72
+ On a Homebridge installation managed by `hb-service` (the official Raspberry Pi
73
+ image and apt package), plugins live in the Homebridge storage directory rather
74
+ than in npm's global prefix. Install it there instead:
75
+
76
+ ```sh
77
+ cd /var/lib/homebridge
78
+ sudo -u homebridge /opt/homebridge/bin/npm install homebridge-dyson-solarcycle-morph
79
+ ```
80
+
81
+ Installing into the wrong prefix is a quiet failure: npm reports success and
82
+ Homebridge never sees the plugin.
83
+
84
+ ## 3. Pair, in the Homebridge UI
85
+
86
+ Open the plugin's settings. The page walks through three things:
87
+
88
+ 1. **Scan for lights.** Finds nearby Dyson lights and adds them to the
89
+ configuration, filling in the address and serial number for you. You can skip
90
+ this and type them by hand if you already did step 1.
91
+ 2. **Authorise your MyDyson account.** Enter your email, password and country,
92
+ request a code, and enter the code Dyson emails you.
93
+ 3. **Check the result.** Paired lights are listed with the date they were
94
+ authorised, and can be un-paired again.
95
+
96
+ Save the configuration and restart Homebridge.
97
+
98
+ **The key never enters `config.json`.** It is written to the plugin's own
99
+ storage directory, so the file you can open in the UI — and paste into a support
100
+ thread — contains nothing sensitive.
101
+
102
+ ### Without the Homebridge UI
103
+
104
+ `dyson-morph-pair` does the same thing from a terminal:
105
+
106
+ ```sh
107
+ dyson-morph-pair --serial ABC-EU-XXXXXXXX --country DE
108
+ ```
109
+
110
+ Pass `--storage` if your Homebridge storage directory is not `~/.homebridge`,
111
+ and `--debug` to see every request and response.
112
+
113
+ **Run it in a real terminal.** It reads from stdin interactively, so launching
114
+ it from an editor, an agent or a CI shell prints the prompts but never receives
115
+ your answers — every field arrives empty and Dyson rejects the login with a
116
+ misleading `401 Unable to authenticate user`.
117
+
118
+ ## 4. Configure
119
+
120
+ Scanning writes this for you; by hand it looks like:
121
+
122
+ ```json
123
+ {
124
+ "platform": "DysonSolarcycleMorph",
125
+ "lights": [
126
+ {
127
+ "name": "Desk Light",
128
+ "mac": "AA:BB:CC:DD:EE:FF",
129
+ "serial": "ABC-EU-XXXXXXXX",
130
+ "motionSensor": true
131
+ }
132
+ ]
133
+ }
134
+ ```
135
+
136
+ | Field | |
137
+ |---|---|
138
+ | `name` | What the lamp is called in HomeKit. |
139
+ | `mac` | BLE address from the scan. |
140
+ | `serial` | Identifies the accessory to HomeKit and keys its stored credentials. |
141
+ | `motionSensor` | Expose the lamp's motion detector as a HomeKit sensor. Default `false`. |
142
+
143
+ Credentials are not configured here. For an unattended setup you may still set
144
+ `ltk` and `accountId` explicitly, and they then take precedence over anything
145
+ stored — supply both or neither.
146
+
147
+ **Running it as a child bridge is recommended.** Bluetooth links drop and
148
+ reconnect; in a child bridge that churn stays isolated from your other
149
+ accessories. In the Homebridge UI it is one toggle under the plugin's settings.
150
+
151
+ ## Troubleshooting
152
+
153
+ **`le-connection-abort-by-local` a few times at startup.** Expected. BlueZ
154
+ commonly aborts the first two or three attempts before one sticks. The plugin
155
+ retries with backoff (2 s, 5 s, 15 s, 30 s, then 60 s) and normally connects
156
+ within a minute. Only worry if it never succeeds.
157
+
158
+ **It connects, drops after a few seconds, and reconnects, over and over.**
159
+ Usually the radio link, not the software. The plugin logs the signal strength
160
+ when it connects and warns below about −80 dBm; at that level BLE hits its
161
+ supervision timeout and the link dies. Check it directly with:
162
+
163
+ ```sh
164
+ sudo bluetoothctl info AA:BB:CC:DD:EE:FF | grep RSSI
165
+ ```
166
+
167
+ −60 dBm is comfortable, −75 is workable, −85 will not hold. Move the lamp or the
168
+ Homebridge host closer to each other, or put a Bluetooth adapter nearer the lamp.
169
+
170
+ **It keeps failing with `le-connection-abort-by-local` and never connects.**
171
+ The lamp's Bluetooth stack can reach a state where it advertises normally — a
172
+ scan finds it, at a good signal strength — but refuses every connection.
173
+ **Disconnecting the lamp from power for ten seconds clears it**, reliably, and
174
+ nothing on the Homebridge side does. The plugin keeps retrying and thins the
175
+ attempts out to five minutes apart, so it will pick the lamp up again by itself
176
+ once you have done that.
177
+
178
+ **Everything reports `Operation Not Authorized`.** The handshake did not
179
+ complete. Look for `Handshake with … complete` in the log.
180
+
181
+ **`… is not paired yet`.** No key is stored for that serial. Open the plugin's
182
+ settings in the Homebridge UI and authorise your account there. Check the serial
183
+ matches the lamp exactly — it is what the stored key is filed under.
184
+
185
+ **`PayloadB failed HMAC verification`.** The stored LTK is wrong or corrupted.
186
+ Re-run `dyson-morph-pair`.
187
+
188
+ **404 when fetching the LTK.** The lamp is not registered to that Dyson account.
189
+ See *Before you start*.
190
+
191
+ **`Not authorized` from D-Bus, or nothing connects at all.** Only relevant if
192
+ your distribution restricts BlueZ access — Debian Bookworm and Raspberry Pi OS
193
+ already allow it for all users, so this is usually *not* your problem. If it is,
194
+ create `/etc/dbus-1/system.d/node-ble.conf`, replacing `homebridge` with the
195
+ user Homebridge runs as, then `sudo systemctl restart dbus`:
196
+
197
+ ```xml
198
+ <!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN"
199
+ "http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
200
+ <busconfig>
201
+ <policy user="homebridge">
202
+ <allow own="org.bluez"/>
203
+ <allow send_destination="org.bluez"/>
204
+ <allow send_interface="org.bluez.GattCharacteristic1"/>
205
+ <allow send_interface="org.bluez.GattDescriptor1"/>
206
+ <allow send_interface="org.freedesktop.DBus.ObjectManager"/>
207
+ <allow send_interface="org.freedesktop.DBus.Properties"/>
208
+ </policy>
209
+ </busconfig>
210
+ ```
211
+
212
+ **The Home app shows the lamp as "No Response".** There is no Bluetooth link
213
+ right now. Commands are not queued while that is the case: they fail, so you
214
+ know they did not happen. The plugin reconnects on its own and reads the lamp's
215
+ real state when it does.
216
+
217
+ **The brightness I set is not exactly what the lamp shows.** Expected. The lamp
218
+ ramps towards a value rather than jumping, and trims it to track daylight from
219
+ its location and the time. Only power is checked and resent, because it is the
220
+ only value whose outcome is unambiguous — see
221
+ [docs/PROTOCOL.md](docs/PROTOCOL.md).
222
+
223
+ **Daylight mode changed at the lamp is not reflected.** The lamp lets you write
224
+ that setting but not read it back, so the plugin cannot know. It re-asserts
225
+ manual mode before writes to keep control predictable.
226
+
227
+ ### Inspecting the lamp yourself
228
+
229
+ `tools/ble-probe.js` dumps every service, characteristic, flag and notification,
230
+ with no build step and no plugin install:
231
+
232
+ ```sh
233
+ npm install node-ble
234
+ node tools/ble-probe.js AA:BB:CC:DD:EE:FF
235
+ ```
236
+
237
+ Useful for checking a model this plugin has not been tried against.
238
+
239
+ ## Protocol
240
+
241
+ See [docs/PROTOCOL.md](docs/PROTOCOL.md) for the GATT layout, the message
242
+ framing and the handshake, including where a real lamp disagreed with the
243
+ published notes.
244
+
245
+ Reverse-engineered from the MyDyson Android app by S-Termi and documented in
246
+ [cmgrayb/hass-dyson](https://github.com/cmgrayb/hass-dyson). This plugin is an
247
+ independent TypeScript implementation, cross-checked against that reference with
248
+ `npm test`.
249
+
250
+ ## Development
251
+
252
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for the build, how to try a change on
253
+ real hardware, and how releases are published.
254
+
255
+ Not affiliated with or endorsed by Dyson.
256
+
257
+ ## License
258
+
259
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,118 @@
1
+ {
2
+ "pluginAlias": "DysonSolarcycleMorph",
3
+ "pluginType": "platform",
4
+ "singular": true,
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.",
6
+ "schema": {
7
+ "type": "object",
8
+ "properties": {
9
+ "name": {
10
+ "title": "Name",
11
+ "type": "string",
12
+ "default": "Dyson Solarcycle Morph",
13
+ "required": true,
14
+ "description": "Shown in the Homebridge log in front of this plugin\u2019s messages."
15
+ },
16
+ "adapter": {
17
+ "title": "Bluetooth adapter",
18
+ "type": "string",
19
+ "required": false,
20
+ "placeholder": "hci0",
21
+ "description": "Leave empty to use the system default adapter."
22
+ },
23
+ "lights": {
24
+ "title": "Lights",
25
+ "type": "array",
26
+ "items": {
27
+ "type": "object",
28
+ "properties": {
29
+ "name": {
30
+ "title": "Name",
31
+ "type": "string",
32
+ "required": true,
33
+ "default": "Dyson Morph"
34
+ },
35
+ "mac": {
36
+ "title": "BLE MAC address",
37
+ "type": "string",
38
+ "required": true,
39
+ "placeholder": "AA:BB:CC:DD:EE:FF",
40
+ "pattern": "^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$"
41
+ },
42
+ "serial": {
43
+ "title": "Serial number",
44
+ "type": "string",
45
+ "required": true,
46
+ "description": "Printed on the lamp base and shown in the MyDyson app."
47
+ },
48
+ "motionSensor": {
49
+ "title": "Expose the motion sensor",
50
+ "type": "boolean",
51
+ "default": false,
52
+ "description": "Adds the lamp's built-in motion detector as a HomeKit motion sensor."
53
+ }
54
+ }
55
+ }
56
+ },
57
+ "dysonAccount": {
58
+ "type": "object",
59
+ "properties": {
60
+ "email": {
61
+ "type": "string",
62
+ "title": "MyDyson email"
63
+ },
64
+ "password": {
65
+ "type": "string",
66
+ "title": "MyDyson password"
67
+ },
68
+ "country": {
69
+ "type": "string",
70
+ "title": "Country",
71
+ "maxLength": 2,
72
+ "default": "GB"
73
+ }
74
+ }
75
+ }
76
+ }
77
+ },
78
+ "customUi": true,
79
+ "customUiPath": "./homebridge-ui",
80
+ "footerDisplay": "Lamp keys are stored in the plugin's storage directory, never in config.json. Your MyDyson password is only needed while authorising \u2014 clear it afterwards.",
81
+ "layout": [
82
+ "name",
83
+ {
84
+ "type": "fieldset",
85
+ "title": "Lights",
86
+ "expandable": true,
87
+ "expanded": true,
88
+ "items": [
89
+ {
90
+ "key": "lights",
91
+ "type": "array",
92
+ "add": "Add light",
93
+ "items": [
94
+ {
95
+ "type": "div",
96
+ "displayFlex": true,
97
+ "flex-direction": "column",
98
+ "items": [
99
+ "lights[].name",
100
+ "lights[].mac",
101
+ "lights[].serial",
102
+ "lights[].motionSensor"
103
+ ]
104
+ }
105
+ ]
106
+ }
107
+ ]
108
+ },
109
+ {
110
+ "type": "fieldset",
111
+ "title": "Advanced",
112
+ "expandable": true,
113
+ "items": [
114
+ "adapter"
115
+ ]
116
+ }
117
+ ]
118
+ }
@@ -0,0 +1,41 @@
1
+ import type { PlatformAccessory } from 'homebridge';
2
+ import type { ResolvedLightConfig } from './config.js';
3
+ import type { MorphPlatform } from './platform.js';
4
+ /**
5
+ * Bridges one lamp's BLE session to its HomeKit services.
6
+ *
7
+ * Writes are optimistic — HomeKit's 10-second characteristic timeout is shorter
8
+ * than a BLE reconnect, so a command issued while the lamp is away would
9
+ * otherwise surface as an error in the Home app. Instead we let the lamp's own
10
+ * state events correct us once it comes back.
11
+ */
12
+ export declare class MorphAccessory {
13
+ private readonly platform;
14
+ private readonly accessory;
15
+ private readonly config;
16
+ private readonly lightbulb;
17
+ private readonly motion?;
18
+ private readonly lamp;
19
+ constructor(platform: MorphPlatform, accessory: PlatformAccessory, config: ResolvedLightConfig);
20
+ start(): Promise<void>;
21
+ stop(): Promise<void>;
22
+ /**
23
+ * Answer only while the lamp is actually reachable.
24
+ *
25
+ * Without this HomeKit keeps showing whatever was last known — a lamp that
26
+ * has been unreachable for minutes still reads as on at 100%, which is worse
27
+ * than saying nothing. Throwing a communication failure is how an accessory
28
+ * reports itself as unavailable, and the Home app shows "No Response".
29
+ */
30
+ private live;
31
+ /** Push "No Response" to every characteristic at once. */
32
+ private markUnreachable;
33
+ /**
34
+ * Apply a write, reporting failure to HomeKit rather than swallowing it.
35
+ *
36
+ * A command that cannot be delivered is not retried later, so the Home app
37
+ * has to say so — otherwise it shows the new value as though it had taken
38
+ * effect and the user has no way to tell that nothing happened.
39
+ */
40
+ private handleSet;
41
+ }
@@ -0,0 +1,129 @@
1
+ import { describeError } from './errors.js';
2
+ import { DysonMorphLamp } from './dyson/lamp.js';
3
+ import { kelvinToMired, miredToKelvin, MAX_KELVIN, MIN_KELVIN } from './dyson/protocol.js';
4
+ /**
5
+ * Bridges one lamp's BLE session to its HomeKit services.
6
+ *
7
+ * Writes are optimistic — HomeKit's 10-second characteristic timeout is shorter
8
+ * than a BLE reconnect, so a command issued while the lamp is away would
9
+ * otherwise surface as an error in the Home app. Instead we let the lamp's own
10
+ * state events correct us once it comes back.
11
+ */
12
+ export class MorphAccessory {
13
+ platform;
14
+ accessory;
15
+ config;
16
+ lightbulb;
17
+ motion;
18
+ lamp;
19
+ constructor(platform, accessory, config) {
20
+ this.platform = platform;
21
+ this.accessory = accessory;
22
+ this.config = config;
23
+ const { Service, Characteristic } = this.platform.api.hap;
24
+ this.lamp = new DysonMorphLamp({
25
+ mac: config.mac,
26
+ ltk: config.ltk,
27
+ accountId: config.accountId,
28
+ adapter: platform.config.adapter,
29
+ log: platform.log,
30
+ });
31
+ (this.accessory.getService(Service.AccessoryInformation) ?? this.accessory.addService(Service.AccessoryInformation))
32
+ .setCharacteristic(Characteristic.Manufacturer, 'Dyson')
33
+ .setCharacteristic(Characteristic.Model, 'Solarcycle Morph')
34
+ .setCharacteristic(Characteristic.SerialNumber, config.serial);
35
+ this.lightbulb =
36
+ this.accessory.getService(Service.Lightbulb) ?? this.accessory.addService(Service.Lightbulb, config.name);
37
+ this.lightbulb.setCharacteristic(Characteristic.Name, config.name);
38
+ this.lightbulb
39
+ .getCharacteristic(Characteristic.On)
40
+ .onGet(() => this.live(() => this.lamp.getState().on))
41
+ .onSet((value) => this.handleSet('power', () => this.lamp.setPower(value)));
42
+ this.lightbulb
43
+ .getCharacteristic(Characteristic.Brightness)
44
+ .onGet(() => this.live(() => this.lamp.getState().brightness))
45
+ .onSet((value) => this.handleSet('brightness', () => this.lamp.setBrightness(value)));
46
+ this.lightbulb
47
+ .getCharacteristic(Characteristic.ColorTemperature)
48
+ // HomeKit works in mireds, which run inverse to Kelvin.
49
+ .setProps({ minValue: kelvinToMired(MAX_KELVIN), maxValue: kelvinToMired(MIN_KELVIN) })
50
+ .onGet(() => this.live(() => kelvinToMired(this.lamp.getState().kelvin)))
51
+ .onSet((value) => this.handleSet('colour temperature', () => this.lamp.setColorTemperature(miredToKelvin(value))));
52
+ // Tell HomeKit the moment the link goes, rather than leaving it showing the
53
+ // last value as though it were current.
54
+ this.lamp.on('disconnected', () => this.markUnreachable());
55
+ this.lamp.on('connected', () => {
56
+ const state = this.lamp.getState();
57
+ this.lightbulb.updateCharacteristic(Characteristic.On, state.on);
58
+ this.lightbulb.updateCharacteristic(Characteristic.Brightness, state.brightness);
59
+ this.lightbulb.updateCharacteristic(Characteristic.ColorTemperature, kelvinToMired(state.kelvin));
60
+ });
61
+ if (config.motionSensor) {
62
+ this.motion =
63
+ this.accessory.getService(Service.MotionSensor) ??
64
+ this.accessory.addService(Service.MotionSensor, `${config.name} Motion`);
65
+ this.motion
66
+ .getCharacteristic(Characteristic.MotionDetected)
67
+ .onGet(() => this.live(() => this.motion?.getCharacteristic(Characteristic.MotionDetected).value ?? false));
68
+ this.lamp.on('motion', (detected) => {
69
+ this.motion?.updateCharacteristic(Characteristic.MotionDetected, detected);
70
+ });
71
+ }
72
+ this.lamp.on('state', (state) => {
73
+ this.lightbulb.updateCharacteristic(Characteristic.On, state.on);
74
+ this.lightbulb.updateCharacteristic(Characteristic.Brightness, state.brightness);
75
+ this.lightbulb.updateCharacteristic(Characteristic.ColorTemperature, kelvinToMired(state.kelvin));
76
+ });
77
+ }
78
+ async start() {
79
+ try {
80
+ await this.lamp.start();
81
+ }
82
+ catch (error) {
83
+ this.platform.log.error(`Could not start BLE session for ${this.config.name}: ${describeError(error)}`);
84
+ }
85
+ }
86
+ async stop() {
87
+ await this.lamp.stop();
88
+ }
89
+ /**
90
+ * Answer only while the lamp is actually reachable.
91
+ *
92
+ * Without this HomeKit keeps showing whatever was last known — a lamp that
93
+ * has been unreachable for minutes still reads as on at 100%, which is worse
94
+ * than saying nothing. Throwing a communication failure is how an accessory
95
+ * reports itself as unavailable, and the Home app shows "No Response".
96
+ */
97
+ live(read) {
98
+ if (!this.lamp.isConnected()) {
99
+ throw new this.platform.api.hap.HapStatusError(-70402 /* this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
100
+ }
101
+ return read();
102
+ }
103
+ /** Push "No Response" to every characteristic at once. */
104
+ markUnreachable() {
105
+ const { Characteristic } = this.platform.api.hap;
106
+ const error = new Error(`${this.config.name} is not reachable over Bluetooth`);
107
+ for (const characteristic of [Characteristic.On, Characteristic.Brightness, Characteristic.ColorTemperature]) {
108
+ this.lightbulb.updateCharacteristic(characteristic, error);
109
+ }
110
+ this.motion?.updateCharacteristic(Characteristic.MotionDetected, error);
111
+ }
112
+ /**
113
+ * Apply a write, reporting failure to HomeKit rather than swallowing it.
114
+ *
115
+ * A command that cannot be delivered is not retried later, so the Home app
116
+ * has to say so — otherwise it shows the new value as though it had taken
117
+ * effect and the user has no way to tell that nothing happened.
118
+ */
119
+ async handleSet(what, apply) {
120
+ try {
121
+ await apply();
122
+ }
123
+ catch (error) {
124
+ this.platform.log.warn(`Setting ${what} on ${this.config.name} failed: ${describeError(error)}`);
125
+ throw new this.platform.api.hap.HapStatusError(-70402 /* this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
126
+ }
127
+ }
128
+ }
129
+ //# sourceMappingURL=accessory.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"accessory.js","sourceRoot":"","sources":["../src/accessory.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};