homebridge-dyson-solarcycle-morph 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,29 @@ 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
+ ## [0.3.0] — 2026-09-18
9
+
10
+ ### Added
11
+
12
+ - Daylight tracking is a switch. It reports what the lamp is actually doing,
13
+ following the button on the lamp's base and the MyDyson app as well as
14
+ HomeKit, and it can be turned on and off. Setting a colour temperature ends
15
+ the tracking, which is the lamp's own behaviour, and the switch follows that
16
+ too. Turn it off per light with `daylightSwitch`.
17
+
18
+ ### Fixed
19
+
20
+ - A write arriving immediately behind another was discarded, silently. HomeKit
21
+ sets brightness and colour temperature together whenever a scene is applied,
22
+ and the second of the two was being lost: on a CF06, colour temperature
23
+ reached its target in 1 of 8 attempts written back to back, and 8 of 8 when
24
+ spaced 100 ms apart. Control writes are now paced.
25
+ - The lamp is no longer taken out of daylight mode before every brightness and
26
+ colour-temperature write. It never needed to be — values land while the lamp
27
+ is tracking — and the write that was supposed to do it was never a valid
28
+ command in the first place. Brightness changes now leave daylight tracking
29
+ running; colour-temperature changes still end it, which is the lamp's doing.
30
+
8
31
  ## [0.2.2] — 2026-09-17
9
32
 
10
33
  ### Added
package/README.md CHANGED
@@ -180,9 +180,11 @@ succeeds roughly two times in three, whatever you do, so the plugin simply tries
180
180
  several times in quick succession. Only a cycle that never succeeds is worth
181
181
  looking at.
182
182
 
183
- **Daylight mode cannot be read back.** The lamp accepts the setting but will not
184
- report it, so the plugin re-asserts manual control before writing a value rather
185
- than guessing.
183
+ **Daylight tracking appears as its own switch.** It shows what the lamp is
184
+ actually doing, following the button on the lamp's base and the MyDyson app as
185
+ well as HomeKit. Setting a colour temperature ends the tracking — that is the
186
+ lamp's own behaviour, not the plugin's — so the switch turns itself off when you
187
+ do. Turn it off per light with `daylightSwitch` if you would rather not have it.
186
188
 
187
189
  **Setup without the Homebridge UI.** `dyson-morph-pair` does the same pairing
188
190
  from a terminal. See [CONTRIBUTING.md](CONTRIBUTING.md).
@@ -50,6 +50,12 @@
50
50
  "type": "boolean",
51
51
  "default": false,
52
52
  "description": "Adds the lamp's built-in motion detector as a HomeKit motion sensor."
53
+ },
54
+ "daylightSwitch": {
55
+ "title": "Expose daylight tracking",
56
+ "type": "boolean",
57
+ "default": true,
58
+ "description": "Adds a switch for the lamp's daylight tracking, which adjusts colour temperature to the time of day. It reports the true state, following the button on the lamp base and the MyDyson app, and setting a colour temperature by hand ends the tracking as the lamp itself decides."
53
59
  }
54
60
  }
55
61
  }
@@ -99,7 +105,8 @@
99
105
  "lights[].name",
100
106
  "lights[].mac",
101
107
  "lights[].serial",
102
- "lights[].motionSensor"
108
+ "lights[].motionSensor",
109
+ "lights[].daylightSwitch"
103
110
  ]
104
111
  }
105
112
  ]
@@ -15,6 +15,7 @@ export declare class MorphAccessory {
15
15
  private readonly config;
16
16
  private readonly lightbulb;
17
17
  private readonly motion?;
18
+ private readonly daylight?;
18
19
  private readonly lamp;
19
20
  constructor(platform: MorphPlatform, accessory: PlatformAccessory, config: ResolvedLightConfig);
20
21
  start(): Promise<void>;
package/dist/accessory.js CHANGED
@@ -15,6 +15,7 @@ export class MorphAccessory {
15
15
  config;
16
16
  lightbulb;
17
17
  motion;
18
+ daylight;
18
19
  lamp;
19
20
  constructor(platform, accessory, config) {
20
21
  this.platform = platform;
@@ -57,7 +58,24 @@ export class MorphAccessory {
57
58
  this.lightbulb.updateCharacteristic(Characteristic.On, state.on);
58
59
  this.lightbulb.updateCharacteristic(Characteristic.Brightness, state.brightness);
59
60
  this.lightbulb.updateCharacteristic(Characteristic.ColorTemperature, kelvinToMired(state.kelvin));
61
+ this.daylight?.updateCharacteristic(Characteristic.On, state.daylight);
60
62
  });
63
+ if (config.daylightSwitch !== false) {
64
+ // A switch rather than a characteristic on the lightbulb: HomeKit renders
65
+ // only the characteristics it knows, so a custom one would be invisible in
66
+ // the Home app and reachable only from third-party clients.
67
+ this.daylight =
68
+ this.accessory.getService(Service.Switch) ??
69
+ this.accessory.addService(Service.Switch, `${config.name} Daylight`, 'daylight');
70
+ this.daylight.setCharacteristic(Characteristic.Name, `${config.name} Daylight`);
71
+ this.daylight
72
+ .getCharacteristic(Characteristic.On)
73
+ .onGet(() => this.live(() => this.lamp.getState().daylight))
74
+ // Reports the mode faithfully but cannot change it; see setDaylight.
75
+ // The rejection is what makes the Home app put the switch back rather
76
+ // than leave it showing a state the lamp is not in.
77
+ .onSet((value) => this.handleSet('daylight mode', () => this.lamp.setDaylight(value)));
78
+ }
61
79
  if (config.motionSensor) {
62
80
  this.motion =
63
81
  this.accessory.getService(Service.MotionSensor) ??
@@ -73,6 +91,9 @@ export class MorphAccessory {
73
91
  this.lightbulb.updateCharacteristic(Characteristic.On, state.on);
74
92
  this.lightbulb.updateCharacteristic(Characteristic.Brightness, state.brightness);
75
93
  this.lightbulb.updateCharacteristic(Characteristic.ColorTemperature, kelvinToMired(state.kelvin));
94
+ // Follows the lamp leaving daylight mode on its own, which it does as soon
95
+ // as colour temperature is set by hand.
96
+ this.daylight?.updateCharacteristic(Characteristic.On, state.daylight);
76
97
  });
77
98
  }
78
99
  async start() {
@@ -108,6 +129,7 @@ export class MorphAccessory {
108
129
  this.lightbulb.updateCharacteristic(characteristic, error);
109
130
  }
110
131
  this.motion?.updateCharacteristic(Characteristic.MotionDetected, error);
132
+ this.daylight?.updateCharacteristic(Characteristic.On, error);
111
133
  }
112
134
  /**
113
135
  * Apply a write, reporting failure to HomeKit rather than swallowing it.
@@ -1 +1 @@
1
- {"version":3,"file":"accessory.js","sourceRoot":"","sources":["../src/accessory.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"accessory.js","sourceRoot":"","sources":["../src/accessory.ts"],"names":[],"mappings":"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"}
package/dist/config.d.ts CHANGED
@@ -17,6 +17,12 @@ export interface LightConfig {
17
17
  accountId?: string;
18
18
  /** Expose the built-in motion sensor as a separate HomeKit service. */
19
19
  motionSensor?: boolean;
20
+ /**
21
+ * Expose daylight tracking as a switch. On by default: it is the lamp's
22
+ * defining feature and there is no other way to turn it back on from HomeKit
23
+ * once setting a colour temperature has ended it.
24
+ */
25
+ daylightSwitch?: boolean;
20
26
  }
21
27
  /** A lamp whose credentials are known, from the config or the credential store. */
22
28
  export type ResolvedLightConfig = LightConfig & {
@@ -1 +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"}
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAqDA;;;;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"}
@@ -5,6 +5,14 @@ export interface LampState {
5
5
  brightness: number;
6
6
  /** Colour temperature in Kelvin, 2700-6500. */
7
7
  kelvin: number;
8
+ /**
9
+ * Whether the lamp is tracking daylight.
10
+ *
11
+ * Asked for on connecting and reported by the lamp on every change. Carried
12
+ * across reconnects rather than reset, so that a lamp which does not answer
13
+ * starts from the last value seen rather than from a guess.
14
+ */
15
+ daylight: boolean;
8
16
  }
9
17
  export interface LampOptions {
10
18
  /** BLE MAC, e.g. `AA:BB:CC:DD:EE:FF`. */
@@ -46,6 +54,9 @@ export declare class DysonMorphLamp extends EventEmitter {
46
54
  /** Serialises all GATT access. */
47
55
  private readonly operations;
48
56
  private readonly writes;
57
+ private readonly pacer;
58
+ /** Whether the lamp has stated the daylight mode, rather than it being inferred. */
59
+ private daylightReported;
49
60
  private readonly reconciles;
50
61
  private readonly settling;
51
62
  /** Whether the running scan is one we started and must clean up. */
@@ -67,7 +78,6 @@ export declare class DysonMorphLamp extends EventEmitter {
67
78
  private rssiMin;
68
79
  private rssiMax;
69
80
  private rssiReportedAt;
70
- private manualModeSetAt;
71
81
  private state;
72
82
  /** What the user last asked for. Reconciliation aims at this, not at guesses. */
73
83
  private desired;
@@ -82,6 +92,16 @@ export declare class DysonMorphLamp extends EventEmitter {
82
92
  setPower(on: boolean): Promise<void>;
83
93
  /** @param percent HomeKit brightness, 0-100 %. */
84
94
  setBrightness(percent: number): Promise<void>;
95
+ /**
96
+ * Turn daylight tracking on or off.
97
+ *
98
+ * Goes out on the attribute channel, which is the one part of this lamp that
99
+ * acknowledges a write. Not reconciled all the same: the lamp announces the
100
+ * mode itself whenever it changes, from here, from the app, from the button
101
+ * on its base, or because setting a value by hand ended the tracking, so the
102
+ * report is a better answer than anything read back would be.
103
+ */
104
+ setDaylight(on: boolean): Promise<void>;
85
105
  setColorTemperature(kelvin: number): Promise<void>;
86
106
  private writeUint16;
87
107
  /**
@@ -143,7 +163,10 @@ export declare class DysonMorphLamp extends EventEmitter {
143
163
  * match on characteristic UUID, which is unique regardless of its parent.
144
164
  */
145
165
  private discoverCharacteristics;
146
- /** Write using whichever acknowledgement mode the characteristic supports. */
166
+ /**
167
+ * Write using whichever acknowledgement mode the characteristic supports,
168
+ * never closer than {@link MIN_WRITE_GAP_MS} behind the previous one.
169
+ */
147
170
  private write;
148
171
  /**
149
172
  * LTK re-authentication: prove we hold the key without contacting the cloud.
@@ -157,12 +180,16 @@ export declare class DysonMorphLamp extends EventEmitter {
157
180
  private sendMessage;
158
181
  private waitForMessage;
159
182
  /**
160
- * Take the lamp out of daylight mode so it accepts explicit values.
183
+ * Ask the lamp whether it is tracking daylight.
184
+ *
185
+ * The characteristic has no `read` flag, but the attribute channel carries a
186
+ * request of its own, so the mode does not have to be guessed at. The answer
187
+ * arrives as a notification and is handled with the change reports.
161
188
  *
162
- * Refreshed on a TTL rather than tracked as state: the lamp offers no way to
163
- * read the current mode back, and the physical button can change it.
189
+ * Best-effort: a lamp that does not answer leaves the inference in
190
+ * {@link publishFromLamp} to work it out from the tracking instead.
164
191
  */
165
- private ensureManualMode;
192
+ private readDaylight;
166
193
  private refreshState;
167
194
  /** Read what the lamp currently reports. Values it will not give up are absent. */
168
195
  private readState;
@@ -221,6 +248,18 @@ export declare class DysonMorphLamp extends EventEmitter {
221
248
  * A value the user just set must stay where they put it; the steps the lamp
222
249
  * takes on its way there are not news, they are the command happening.
223
250
  */
251
+ /**
252
+ * Take what the lamp reports, and read daylight mode out of it where it can.
253
+ *
254
+ * A fallback for a lamp that does not answer the question asked on connecting,
255
+ * which would otherwise leave the mode unknown until something changed it.
256
+ * Colour temperature moving when nobody asked for it is the tell: with
257
+ * daylight mode off, nothing but a write moves it.
258
+ *
259
+ * Only ever concludes the mode is on. Stillness proves nothing — the lamp
260
+ * drifts a few Kelvin a minute and less than that on a plateau — and a report
261
+ * from the lamp always wins over an inference once one arrives.
262
+ */
224
263
  private publishFromLamp;
225
264
  private patchState;
226
265
  }
@@ -13,10 +13,11 @@ import { Variant } from 'dbus-next';
13
13
  import { createBluetooth } from 'node-ble';
14
14
  import { buildReauthPayloadA, buildReauthPayloadC, deriveAesKey, parseReauthPayloadB } from './crypto.js';
15
15
  import { Debouncer } from './debounce.js';
16
+ import { Pacer } from './pace.js';
16
17
  import { OperationQueue } from './queue.js';
17
18
  import { planReconciliation } from './reconcile.js';
18
19
  import { SettleWindow } from './settle.js';
19
- import { CHAR_AUTH, CHAR_BRIGHTNESS_LM, CHAR_COLOR_TEMP, CHAR_MOTION, CHAR_POWER, CHAR_RSSI, CHAR_WRITE_ATTR, DAYLIGHT_MODE_DISABLE, MAX_KELVIN, MessageAssembler, MIN_KELVIN, MsgType, fragmentMessage, lumensToPercent, percentToLumens, } from './protocol.js';
20
+ import { CHAR_AUTH, CHAR_BRIGHTNESS_LM, CHAR_COLOR_TEMP, CHAR_MOTION, CHAR_POWER, CHAR_RSSI, CHAR_WRITE_ATTR, buildDaylightRead, buildDaylightWrite, decodeAttributeReport, decodeAttributeValue, MAX_KELVIN, MessageAssembler, MIN_KELVIN, MsgType, fragmentMessage, lumensToPercent, percentToLumens, } from './protocol.js';
20
21
  /** Characteristics we look for. Anything else the lamp exposes is ignored. */
21
22
  const WANTED_CHARACTERISTICS = new Set([
22
23
  CHAR_AUTH,
@@ -57,14 +58,6 @@ const CONNECT_ATTEMPTS = 4;
57
58
  const CONNECT_RETRY_MS = 1_500;
58
59
  /** The lamp can take a while to answer the first handshake message. */
59
60
  const HANDSHAKE_TIMEOUT_MS = 30_000;
60
- /**
61
- * Re-assert manual mode if the last write was longer ago than this. Doing it
62
- * before every command wastes a write; never doing it means the lamp ignores us
63
- * after someone used the physical daylight button.
64
- */
65
- const MANUAL_MODE_TTL_MS = 60_000;
66
- /** The lamp needs a moment to apply a mode change before the next write. */
67
- const MODE_SETTLE_MS = 200;
68
61
  /** Signal strength worth warning about, so a weak link is visible in the log. */
69
62
  const WEAK_RSSI_DBM = -80;
70
63
  /**
@@ -88,6 +81,15 @@ const NOISY_SPREAD_DB = 20;
88
81
  * values a second while dragging; only the one it stops on matters.
89
82
  */
90
83
  const WRITE_DEBOUNCE_MS = 400;
84
+ /**
85
+ * Minimum spacing between control writes; see {@link Pacer} for the measurement
86
+ * behind it. 150 ms keeps a margin over the 100 ms that tested clean, and is
87
+ * still far below what anyone notices in a lamp.
88
+ *
89
+ * Not applied to the auth channel, whose fragments are written back to back by
90
+ * design and have never shown the problem.
91
+ */
92
+ const MIN_WRITE_GAP_MS = 150;
91
93
  /**
92
94
  * How long after the last command to check the lamp agrees. Verification cannot
93
95
  * sit in the command's own path: the write costs a millisecond, reading it back
@@ -136,6 +138,9 @@ export class DysonMorphLamp extends EventEmitter {
136
138
  /** Serialises all GATT access. */
137
139
  operations = new OperationQueue();
138
140
  writes = new Debouncer(WRITE_DEBOUNCE_MS, (task, key) => this.enqueue(task, key));
141
+ pacer = new Pacer(MIN_WRITE_GAP_MS);
142
+ /** Whether the lamp has stated the daylight mode, rather than it being inferred. */
143
+ daylightReported = false;
139
144
  reconciles = new Debouncer(RECONCILE_DELAY_MS, (task, key) => this.enqueue(task, key));
140
145
  settling = new SettleWindow(SETTLE_MS);
141
146
  /** Whether the running scan is one we started and must clean up. */
@@ -157,8 +162,7 @@ export class DysonMorphLamp extends EventEmitter {
157
162
  rssiMin = 0;
158
163
  rssiMax = 0;
159
164
  rssiReportedAt = 0;
160
- manualModeSetAt = 0;
161
- state = { on: false, brightness: 100, kelvin: 2700 };
165
+ state = { on: false, brightness: 100, kelvin: 2700, daylight: false };
162
166
  /** What the user last asked for. Reconciliation aims at this, not at guesses. */
163
167
  desired = {};
164
168
  constructor(options) {
@@ -247,6 +251,28 @@ export class DysonMorphLamp extends EventEmitter {
247
251
  // that went missing.
248
252
  await this.writes.schedule(CHAR_BRIGHTNESS_LM, () => this.writeUint16(CHAR_BRIGHTNESS_LM, lumens));
249
253
  }
254
+ /**
255
+ * Turn daylight tracking on or off.
256
+ *
257
+ * Goes out on the attribute channel, which is the one part of this lamp that
258
+ * acknowledges a write. Not reconciled all the same: the lamp announces the
259
+ * mode itself whenever it changes, from here, from the app, from the button
260
+ * on its base, or because setting a value by hand ended the tracking, so the
261
+ * report is a better answer than anything read back would be.
262
+ */
263
+ async setDaylight(on) {
264
+ this.requireConnection();
265
+ if (!this.chars[CHAR_WRITE_ATTR]) {
266
+ throw new Error('this lamp does not expose the attribute channel');
267
+ }
268
+ this.log.debug(`Daylight mode ${on ? 'on' : 'off'} requested`);
269
+ this.patchState({ daylight: on });
270
+ await this.writes.schedule(CHAR_WRITE_ATTR, async () => {
271
+ for (const fragment of buildDaylightWrite(on)) {
272
+ await this.write(CHAR_WRITE_ATTR, fragment);
273
+ }
274
+ });
275
+ }
250
276
  async setColorTemperature(kelvin) {
251
277
  const clamped = Math.min(MAX_KELVIN, Math.max(MIN_KELVIN, Math.round(kelvin)));
252
278
  this.requireConnection();
@@ -258,7 +284,6 @@ export class DysonMorphLamp extends EventEmitter {
258
284
  await this.writes.schedule(CHAR_COLOR_TEMP, () => this.writeUint16(CHAR_COLOR_TEMP, clamped));
259
285
  }
260
286
  async writeUint16(uuid, value) {
261
- await this.ensureManualMode();
262
287
  const buffer = Buffer.alloc(2);
263
288
  buffer.writeUInt16LE(value);
264
289
  await this.write(uuid, buffer);
@@ -361,7 +386,8 @@ export class DysonMorphLamp extends EventEmitter {
361
386
  }
362
387
  await this.stopOurDiscovery();
363
388
  this.connected = true;
364
- this.manualModeSetAt = 0;
389
+ this.pacer.reset();
390
+ this.daylightReported = false;
365
391
  this.settling.clear();
366
392
  await this.refreshState();
367
393
  // The lamp is the authority on where it is. Anything asked for before the
@@ -369,6 +395,7 @@ export class DysonMorphLamp extends EventEmitter {
369
395
  // "correct" the lamp to a state nobody is asking for any more.
370
396
  this.desired = { ...this.state };
371
397
  await this.subscribeToState();
398
+ await this.readDaylight();
372
399
  await this.subscribeToSignal();
373
400
  this.startPolling();
374
401
  const rssi = this.lastRssi;
@@ -491,8 +518,14 @@ export class DysonMorphLamp extends EventEmitter {
491
518
  throw new Error(`${this.mac} is missing required characteristics: ${missing.join(', ')} — is this a Dyson Morph?`);
492
519
  }
493
520
  }
494
- /** Write using whichever acknowledgement mode the characteristic supports. */
521
+ /**
522
+ * Write using whichever acknowledgement mode the characteristic supports,
523
+ * never closer than {@link MIN_WRITE_GAP_MS} behind the previous one.
524
+ */
495
525
  async write(uuid, value) {
526
+ if (uuid !== CHAR_AUTH) {
527
+ await this.pacer.pace();
528
+ }
496
529
  await this.characteristic(uuid).writeValue(value, { type: this.writeTypes[uuid] ?? 'command' });
497
530
  }
498
531
  /**
@@ -548,21 +581,27 @@ export class DysonMorphLamp extends EventEmitter {
548
581
  });
549
582
  }
550
583
  /**
551
- * Take the lamp out of daylight mode so it accepts explicit values.
584
+ * Ask the lamp whether it is tracking daylight.
585
+ *
586
+ * The characteristic has no `read` flag, but the attribute channel carries a
587
+ * request of its own, so the mode does not have to be guessed at. The answer
588
+ * arrives as a notification and is handled with the change reports.
552
589
  *
553
- * Refreshed on a TTL rather than tracked as state: the lamp offers no way to
554
- * read the current mode back, and the physical button can change it.
590
+ * Best-effort: a lamp that does not answer leaves the inference in
591
+ * {@link publishFromLamp} to work it out from the tracking instead.
555
592
  */
556
- async ensureManualMode() {
557
- if (Date.now() - this.manualModeSetAt < MANUAL_MODE_TTL_MS) {
558
- return;
559
- }
593
+ async readDaylight() {
560
594
  if (!this.chars[CHAR_WRITE_ATTR]) {
561
595
  return;
562
596
  }
563
- await this.write(CHAR_WRITE_ATTR, DAYLIGHT_MODE_DISABLE);
564
- await sleep(MODE_SETTLE_MS);
565
- this.manualModeSetAt = Date.now();
597
+ try {
598
+ for (const fragment of buildDaylightRead()) {
599
+ await this.write(CHAR_WRITE_ATTR, fragment);
600
+ }
601
+ }
602
+ catch (error) {
603
+ this.log.debug(`Could not ask for the daylight mode: ${describeError(error)}`);
604
+ }
566
605
  }
567
606
  async refreshState() {
568
607
  this.patchState(await this.readState());
@@ -705,6 +744,23 @@ export class DysonMorphLamp extends EventEmitter {
705
744
  },
706
745
  ],
707
746
  [CHAR_COLOR_TEMP, (value) => (value.length >= 2 ? { kelvin: value.readUInt16LE(0) } : undefined)],
747
+ // Daylight mode arrives here twice over: the reply to the question asked
748
+ // on connecting, and a report whenever it changes.
749
+ [
750
+ CHAR_WRITE_ATTR,
751
+ (value) => {
752
+ // Anything the lamp says about the mode outranks what we inferred.
753
+ // Logged raw: this channel carries attributes that are not decoded,
754
+ // and a report that arrives but says nothing looks exactly like one
755
+ // that never arrived.
756
+ this.log.debug(`Attribute report ${value.toString('hex')}`);
757
+ const report = decodeAttributeReport(value) ?? decodeAttributeValue(value);
758
+ if (report) {
759
+ this.daylightReported = true;
760
+ }
761
+ return report;
762
+ },
763
+ ],
708
764
  ];
709
765
  for (const [uuid, decode] of sources) {
710
766
  const characteristic = this.chars[uuid];
@@ -812,8 +868,28 @@ export class DysonMorphLamp extends EventEmitter {
812
868
  * A value the user just set must stay where they put it; the steps the lamp
813
869
  * takes on its way there are not news, they are the command happening.
814
870
  */
871
+ /**
872
+ * Take what the lamp reports, and read daylight mode out of it where it can.
873
+ *
874
+ * A fallback for a lamp that does not answer the question asked on connecting,
875
+ * which would otherwise leave the mode unknown until something changed it.
876
+ * Colour temperature moving when nobody asked for it is the tell: with
877
+ * daylight mode off, nothing but a write moves it.
878
+ *
879
+ * Only ever concludes the mode is on. Stillness proves nothing — the lamp
880
+ * drifts a few Kelvin a minute and less than that on a plateau — and a report
881
+ * from the lamp always wins over an inference once one arrives.
882
+ */
815
883
  publishFromLamp(patch) {
816
- this.patchState(this.settling.filter(patch));
884
+ const filtered = this.settling.filter(patch);
885
+ if (!this.daylightReported &&
886
+ !this.state.daylight &&
887
+ filtered.kelvin !== undefined &&
888
+ filtered.kelvin !== this.state.kelvin) {
889
+ this.log.debug(`Colour temperature moved to ${filtered.kelvin}K unasked — the lamp is tracking daylight`);
890
+ filtered.daylight = true;
891
+ }
892
+ this.patchState(filtered);
817
893
  }
818
894
  patchState(patch) {
819
895
  if (Object.keys(patch).length === 0) {
@@ -829,7 +905,10 @@ export class DysonMorphLamp extends EventEmitter {
829
905
  }
830
906
  }
831
907
  function describeState(state) {
832
- return `${state.on ? 'on' : 'off'}, ${state.brightness}%, ${state.kelvin}K`;
908
+ // Daylight only when it is on: naming it every time would put "no daylight"
909
+ // on most lines of the log for a mode most lamps spend most of their time out of.
910
+ const daylight = state.daylight ? ', daylight' : '';
911
+ return `${state.on ? 'on' : 'off'}, ${state.brightness}%, ${state.kelvin}K${daylight}`;
833
912
  }
834
913
  function sleep(ms) {
835
914
  return new Promise((resolve) => setTimeout(resolve, ms));