homebridge-tsvesync 1.5.0 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,6 +8,22 @@ class AirPurifierAccessory extends base_accessory_1.BaseAccessory {
8
8
  this.lastSetSpeed = 0; // Track the last speed we set
9
9
  this.lastSetPercentage = 0; // Track the last percentage we set
10
10
  this.skipNextUpdate = false; // Flag to skip the next update
11
+ // Coalesced write state. HomeKit scenes and automations send Active,
12
+ // TargetAirPurifierState and RotationSpeed in a single HAP request, and
13
+ // HAP-NodeJS dispatches those writes concurrently. Applying them
14
+ // independently makes them race and clobber each other, so they are
15
+ // buffered here and resolved into one intent. See resolveWrite().
16
+ this.pendingWrite = null;
17
+ this.pendingWriteTimer = null;
18
+ this.pendingWritePromise = null;
19
+ // Batches are applied one at a time. Dragging the slider produces a burst of
20
+ // writes spaced further apart than the coalesce window, so several batches
21
+ // can be in flight at once; without this their API calls interleave and the
22
+ // device can settle on whichever happens to land last rather than the last
23
+ // value the user chose.
24
+ this.writeChain = Promise.resolve();
25
+ this._lastCommandedMode = null;
26
+ this._lastCommandedModeTime = 0;
11
27
  this.device = device;
12
28
  this.capabilities = this.getDeviceCapabilities();
13
29
  // Detect device class based on model number patterns
@@ -217,20 +233,32 @@ class AirPurifierAccessory extends base_accessory_1.BaseAccessory {
217
233
  isCore200S() {
218
234
  return (this.device.deviceType || '').toUpperCase().includes('CORE200S');
219
235
  }
236
+ isCore300S() {
237
+ return (this.device.deviceType || '').toUpperCase().includes('CORE300S');
238
+ }
239
+ /**
240
+ * The real number of manual fan speeds, which is not always what the library
241
+ * advertises. tsvesync declares `levels: [1,2,3,4]` for the Core 200S and
242
+ * Core 300S, but both hardware families only have three manual speeds -
243
+ * verified against a Core300S, where changeFanSpeed(4) reports success and
244
+ * the device settles at 3. Trusting the declared 4 produces a dead top notch
245
+ * where 80% and 100% both mean speed 3.
246
+ */
247
+ getEffectiveMaxFanSpeed() {
248
+ if (this.isCore200S() || this.isCore300S()) {
249
+ return 3;
250
+ }
251
+ return this.getMaxFanSpeed();
252
+ }
220
253
  /**
221
254
  * Calculate the appropriate step size for discrete speed levels
222
255
  */
223
256
  calculateRotationSpeedStep() {
224
- // Expose Sleep as the first notch when supported.
225
- // Core 200S uses 3 manual speeds (25% step). 4-speed models use 20% step.
257
+ // Expose Sleep as the first notch when supported. The step is simply the
258
+ // width of one notch, derived from the notch count so the slider grid and
259
+ // the percentage->notch mapping can never disagree.
226
260
  if (this.hasFeature('sleep_mode')) {
227
- if (this.hasFeature('turbo_mode')) {
228
- return 20;
229
- }
230
- if (this.isCore200S())
231
- return 25;
232
- const max = this.getMaxFanSpeed();
233
- return max >= 4 ? 20 : 25;
261
+ return 100 / this.getTotalNotches();
234
262
  }
235
263
  const maxSpeed = this.getMaxFanSpeed();
236
264
  if (maxSpeed === 3)
@@ -239,6 +267,32 @@ class AirPurifierAccessory extends base_accessory_1.BaseAccessory {
239
267
  return 25;
240
268
  return 1;
241
269
  }
270
+ /**
271
+ * Total slider notches: sleep, each manual speed, and turbo when supported.
272
+ */
273
+ getTotalNotches() {
274
+ const max = this.getEffectiveMaxFanSpeed();
275
+ return max + (this.hasFeature('turbo_mode') ? 2 : 1);
276
+ }
277
+ /**
278
+ * Map a percentage to a slider notch by which equal-width range it falls in,
279
+ * rather than by rounding against our own step.
280
+ *
281
+ * This has to stay correct even when the controller disagrees with us about
282
+ * the step. HomeKit caches characteristic metadata on the home hub, so after
283
+ * an upgrade that changes minStep a controller can keep sending values on the
284
+ * *old* grid for an unknown period. Rounding against our step maps those to
285
+ * the wrong speed (a 60% on an old 20% grid became Low instead of Medium);
286
+ * range mapping gets it right on either grid.
287
+ */
288
+ percentageToNotch(percentage) {
289
+ if (typeof percentage !== 'number' || isNaN(percentage) || percentage <= 0) {
290
+ return 0;
291
+ }
292
+ const totalNotches = this.getTotalNotches();
293
+ const sliceWidth = 100 / totalNotches;
294
+ return Math.max(1, Math.min(totalNotches, Math.ceil(percentage / sliceWidth)));
295
+ }
242
296
  /**
243
297
  * Convert device speed to percentage
244
298
  */
@@ -252,7 +306,7 @@ class AirPurifierAccessory extends base_accessory_1.BaseAccessory {
252
306
  if (typeof speed !== 'number' || speed <= 0)
253
307
  return 0;
254
308
  const step = this.calculateRotationSpeedStep();
255
- const max = this.isCore200S() ? 3 : this.getMaxFanSpeed();
309
+ const max = this.getEffectiveMaxFanSpeed();
256
310
  // Manual speeds occupy notches 2..(max+1)
257
311
  const notch = Math.max(2, Math.min(max + 1, speed + 1));
258
312
  return Math.min(100, notch * step);
@@ -315,11 +369,10 @@ class AirPurifierAccessory extends base_accessory_1.BaseAccessory {
315
369
  if (typeof percentage !== 'number')
316
370
  return 1;
317
371
  percentage = Math.min(100, Math.max(0, percentage));
318
- const step = this.calculateRotationSpeedStep();
319
- const max = this.isCore200S() ? 3 : this.getMaxFanSpeed();
372
+ const max = this.getEffectiveMaxFanSpeed();
320
373
  const supportsTurbo = this.hasFeature('turbo_mode');
321
- const totalNotches = max + (supportsTurbo ? 2 : 1); // sleep + manual + optional turbo
322
- const notch = Math.max(0, Math.min(totalNotches, Math.round(percentage / step)));
374
+ const totalNotches = this.getTotalNotches(); // sleep + manual + optional turbo
375
+ const notch = this.percentageToNotch(percentage);
323
376
  if (supportsTurbo && notch === totalNotches) {
324
377
  return max;
325
378
  }
@@ -469,7 +522,7 @@ class AirPurifierAccessory extends base_accessory_1.BaseAccessory {
469
522
  */
470
523
  async getPetMode() {
471
524
  const extendedDevice = this.device;
472
- return (extendedDevice.mode || '').toLowerCase() === 'pet';
525
+ return this.resolveEffectiveMode(extendedDevice.mode) === 'pet';
473
526
  }
474
527
  /**
475
528
  * Set pet mode. Enabling switches the device into pet mode; disabling
@@ -511,6 +564,8 @@ class AirPurifierAccessory extends base_accessory_1.BaseAccessory {
511
564
  if (!success) {
512
565
  throw new Error(`Failed to ${value ? 'enable' : 'disable'} pet mode`);
513
566
  }
567
+ // Hold the commanded mode against stale API reads
568
+ this.rememberCommandedMode(value ? 'pet' : (this.hasFeature('auto_mode') ? 'auto' : 'manual'));
514
569
  // Refresh characteristics so the main service and switch stay consistent
515
570
  await this.updateDeviceSpecificStates(this.device);
516
571
  }
@@ -586,17 +641,17 @@ class AirPurifierAccessory extends base_accessory_1.BaseAccessory {
586
641
  */
587
642
  async updateDeviceSpecificStates(details) {
588
643
  var _a, _b;
589
- // Update power state (treat sleep as on for some VeSync models)
644
+ // Update power state (treat sleep as on for some VeSync models).
645
+ // Prefer the mode we last commanded - a poll that lands while VeSync is
646
+ // still reporting the previous mode must not flip the tile back.
590
647
  const extended = this.device;
591
- const mode = (extended.mode || details.mode || '').toLowerCase();
648
+ const mode = this.resolveEffectiveMode(extended.mode || details.mode || '');
592
649
  const isSleep = mode === 'sleep';
593
650
  const isTurbo = this.hasFeature('turbo_mode') && mode === 'turbo';
594
651
  const isOn = isSleep || details.enabled || details.deviceStatus === 'on';
595
652
  this.service.updateCharacteristic(this.platform.Characteristic.Active, isOn ? 1 : 0);
596
653
  // Update current state (INACTIVE = 0, IDLE = 1, PURIFYING_AIR = 2)
597
654
  this.service.updateCharacteristic(this.platform.Characteristic.CurrentAirPurifierState, isOn ? 2 : 0);
598
- // Get the extended device to access mode information
599
- const extendedDevice = this.device;
600
655
  // Update target state (MANUAL = 0, AUTO = 1)
601
656
  // Special handling for Core200S
602
657
  let targetState = 0; // Default to MANUAL
@@ -604,11 +659,10 @@ class AirPurifierAccessory extends base_accessory_1.BaseAccessory {
604
659
  // Core200S always reports MANUAL mode
605
660
  targetState = 0; // MANUAL
606
661
  }
607
- else if (extendedDevice.mode) {
608
- // For other devices, including Core300S, use the reported mode.
609
- // Pet mode is an automatic mode, so surface it as AUTO on the main service.
610
- const reportedMode = (extendedDevice.mode || '').toLowerCase();
611
- targetState = (reportedMode === 'auto' || reportedMode === 'pet') ? 1 : 0;
662
+ else if (mode) {
663
+ // For other devices, including Core300S, use the effective mode resolved
664
+ // above. Pet mode is an automatic mode, so surface it as AUTO.
665
+ targetState = this.modeToTargetState(mode);
612
666
  }
613
667
  this.service.updateCharacteristic(this.platform.Characteristic.TargetAirPurifierState, targetState);
614
668
  // Keep the Pet Mode switch (if present) in sync with the device mode
@@ -680,6 +734,166 @@ class AirPurifierAccessory extends base_accessory_1.BaseAccessory {
680
734
  (_b = this.service) === null || _b === void 0 ? void 0 : _b.updateCharacteristic(this.platform.Characteristic.FilterLifeLevel, Math.min(100, Math.max(0, filterLife)));
681
735
  }
682
736
  }
737
+ /**
738
+ * Buffer a characteristic write and return a promise that settles once the
739
+ * whole batch has been applied. Every characteristic in the batch shares the
740
+ * same promise, so HomeKit gets a success/failure per write as usual.
741
+ */
742
+ queueWrite(key, value) {
743
+ if (!this.pendingWrite) {
744
+ this.pendingWrite = {};
745
+ }
746
+ this.pendingWrite[key] = value;
747
+ if (!this.pendingWritePromise) {
748
+ this.pendingWritePromise = new Promise((resolve, reject) => {
749
+ this.pendingWriteTimer = setTimeout(() => {
750
+ var _a;
751
+ const batch = (_a = this.pendingWrite) !== null && _a !== void 0 ? _a : {};
752
+ this.pendingWrite = null;
753
+ this.pendingWritePromise = null;
754
+ this.pendingWriteTimer = null;
755
+ // Queue behind any batch still being applied so commands reach the
756
+ // device in the order the user made them.
757
+ this.writeChain = this.writeChain
758
+ .catch(() => undefined)
759
+ .then(() => this.resolveWrite(batch));
760
+ this.writeChain.then(resolve, reject);
761
+ }, AirPurifierAccessory.WRITE_COALESCE_MS);
762
+ // NB: deliberately not unref()'d - an unref'd timer lets the process
763
+ // exit before the buffered write is ever applied.
764
+ });
765
+ }
766
+ return this.pendingWritePromise;
767
+ }
768
+ /**
769
+ * Turn one buffered batch of HomeKit writes into a single device intent.
770
+ *
771
+ * The ordering rules that matter (issue #42):
772
+ * - An explicit AUTO write wins over the mode implied by the speed slider.
773
+ * This accessory encodes sleep/manual/turbo into RotationSpeed, so every
774
+ * speed write is implicitly a mode write; without this rule an automation
775
+ * that sets "on + auto + 60%" ends up in manual.
776
+ * - RotationSpeed 0 only means "turn off" when the same batch didn't also
777
+ * ask for the device to be on or set a mode. Otherwise "on + auto + 0%"
778
+ * turns the device on and straight back off.
779
+ */
780
+ async resolveWrite(batch) {
781
+ const { active, targetState } = batch;
782
+ let { rotationSpeed } = batch;
783
+ this.platform.log.debug(`${this.device.deviceName}: HomeKit write batch ${JSON.stringify(batch)} ` +
784
+ `(device reports mode=${this.device.mode}, ` +
785
+ `status=${this.device.deviceStatus}, speed=${this.device.speed})`);
786
+ // An explicit power-off wins outright - nothing else in the batch matters.
787
+ if (active === 0) {
788
+ await this.applyActive(0);
789
+ return;
790
+ }
791
+ if (rotationSpeed === 0) {
792
+ if (active !== 1 && targetState === undefined) {
793
+ // Slider dragged to zero on its own - the long-standing "0% means off".
794
+ await this.applyActive(0);
795
+ return;
796
+ }
797
+ // Something else in the batch asked for the device to be on or set a
798
+ // mode, so the 0% slider is stale state rather than an off request.
799
+ this.platform.log.debug(`${this.device.deviceName}: Ignoring 0% speed - batch also requested on/mode`);
800
+ rotationSpeed = undefined;
801
+ }
802
+ // Power on first so the mode and speed commands land on a running device.
803
+ if (active === 1) {
804
+ await this.applyActive(1);
805
+ }
806
+ // Explicit AUTO beats the slider. In auto the device picks its own speed,
807
+ // so the requested percentage is advisory and deliberately not applied.
808
+ if (targetState === 1) {
809
+ await this.applyTargetState(1);
810
+ return;
811
+ }
812
+ // Otherwise the slider carries the mode (sleep / manual / turbo).
813
+ if (rotationSpeed !== undefined) {
814
+ await this.applyRotationSpeed(rotationSpeed);
815
+ return;
816
+ }
817
+ if (targetState === 0) {
818
+ await this.applyTargetState(0);
819
+ }
820
+ }
821
+ /**
822
+ * Write a commanded speed/mode back into the device's cached details.
823
+ *
824
+ * The bypass purifiers (Core series) don't update their own cache after a
825
+ * successful changeFanSpeed - unlike the bypassV2 models, which do - so
826
+ * device.speed stays stale until the next poll, and the poll interval has a
827
+ * two minute floor. That stale value makes getRotationSpeed() disagree with
828
+ * what we just set and snaps the slider back to the wrong notch.
829
+ */
830
+ cacheCommandedSpeed(speed, mode) {
831
+ const device = this.device;
832
+ // NB: `speed` and `mode` are getter-only on VeSyncFan and read straight
833
+ // through to `details`. Assigning to them is a no-op in sloppy mode and a
834
+ // TypeError under the strict-mode build, so write to `details` instead.
835
+ if (device.details) {
836
+ device.details.speed = speed;
837
+ if (mode) {
838
+ device.details.mode = mode;
839
+ }
840
+ }
841
+ if (mode) {
842
+ this.rememberCommandedMode(mode);
843
+ }
844
+ }
845
+ /**
846
+ * Record a mode we just commanded so a stale API read can't undo it.
847
+ */
848
+ rememberCommandedMode(mode) {
849
+ this._lastCommandedMode = (mode || '').toLowerCase();
850
+ this._lastCommandedModeTime = Date.now();
851
+ }
852
+ /**
853
+ * Resolve the mode to report to HomeKit, preferring what we last commanded
854
+ * over what the device currently reports. The override is dropped as soon as
855
+ * the device catches up, or once it expires - so a mode changed in the VeSync
856
+ * app still comes through.
857
+ */
858
+ resolveEffectiveMode(reportedMode) {
859
+ const reported = (reportedMode || '').toLowerCase();
860
+ if (this._lastCommandedMode === null) {
861
+ return reported;
862
+ }
863
+ const elapsed = Date.now() - this._lastCommandedModeTime;
864
+ if (reported === this._lastCommandedMode ||
865
+ elapsed > AirPurifierAccessory.COMMANDED_MODE_TTL_MS) {
866
+ // Device caught up, or we've waited long enough - stop overriding.
867
+ this._lastCommandedMode = null;
868
+ return reported;
869
+ }
870
+ this.platform.log.debug(`${this.device.deviceName}: Using commanded mode '${this._lastCommandedMode}' ` +
871
+ `instead of device-reported '${reported}'`);
872
+ return this._lastCommandedMode;
873
+ }
874
+ /**
875
+ * Map a VeSync mode to the HomeKit TargetAirPurifierState value.
876
+ * Pet is an automatic mode, so it surfaces as AUTO like auto itself.
877
+ */
878
+ modeToTargetState(mode) {
879
+ const normalised = (mode || '').toLowerCase();
880
+ return (normalised === 'auto' || normalised === 'pet') ? 1 : 0;
881
+ }
882
+ /**
883
+ * Push the mode back to HomeKit immediately after we change it.
884
+ *
885
+ * The RotationSpeed slider implicitly carries a mode (sleep/manual/turbo),
886
+ * so dragging it can move the device out of auto. Without this the tile
887
+ * keeps showing the old mode until the next poll - and the poll interval has
888
+ * a two minute floor, so "Auto" would linger long after the device had
889
+ * actually switched to Manual.
890
+ */
891
+ syncTargetStateToHomeKit(mode) {
892
+ this.rememberCommandedMode(mode);
893
+ const targetState = this.modeToTargetState(mode);
894
+ this.service.updateCharacteristic(this.platform.Characteristic.TargetAirPurifierState, targetState);
895
+ this.platform.log.debug(`${this.device.deviceName}: Synced TargetAirPurifierState to ${targetState} for mode '${mode}'`);
896
+ }
683
897
  /**
684
898
  * Get target state (MANUAL = 0, AUTO = 1)
685
899
  */
@@ -690,14 +904,18 @@ class AirPurifierAccessory extends base_accessory_1.BaseAccessory {
690
904
  }
691
905
  const extendedDevice = this.device;
692
906
  // Pet mode is an automatic mode, so report it as AUTO on the main service.
693
- const reportedMode = (extendedDevice.mode || '').toLowerCase();
694
- const targetState = (reportedMode === 'auto' || reportedMode === 'pet') ? 1 : 0;
695
- return targetState;
907
+ // Prefer the mode we last commanded so a stale API read can't flip the tile.
908
+ const effectiveMode = this.resolveEffectiveMode(extendedDevice.mode);
909
+ return this.modeToTargetState(effectiveMode);
696
910
  }
697
911
  /**
698
- * Set target state (AUTO = 0, MANUAL = 1)
912
+ * Set target state (MANUAL = 0, AUTO = 1). Buffered so it can be reconciled
913
+ * with any Active/RotationSpeed writes sent in the same HomeKit request.
699
914
  */
700
915
  async setTargetState(value) {
916
+ return this.queueWrite('targetState', value);
917
+ }
918
+ async applyTargetState(value) {
701
919
  try {
702
920
  const targetState = value;
703
921
  const extendedDevice = this.device;
@@ -731,6 +949,9 @@ class AirPurifierAccessory extends base_accessory_1.BaseAccessory {
731
949
  if (!success) {
732
950
  throw new Error(`Failed to set mode to ${mode}`);
733
951
  }
952
+ // Remember what we commanded so the stale-read guard holds the tile on
953
+ // the new mode until VeSync catches up.
954
+ this.rememberCommandedMode(mode);
734
955
  // Update device state and characteristics
735
956
  await this.updateDeviceSpecificStates(this.device);
736
957
  }
@@ -743,17 +964,28 @@ class AirPurifierAccessory extends base_accessory_1.BaseAccessory {
743
964
  */
744
965
  async getRotationSpeed() {
745
966
  const extended = this.device;
746
- if (this.hasFeature('turbo_mode') && (extended.mode || '').toLowerCase() === 'turbo') {
967
+ const effectiveMode = this.resolveEffectiveMode(extended.mode);
968
+ if (this.hasFeature('turbo_mode') && effectiveMode === 'turbo') {
747
969
  return 100;
748
970
  }
971
+ // Sleep occupies the first notch. This has to be checked before the
972
+ // on/off gate below: while the device is ON the gate falls through to the
973
+ // speed conversion, which would report the last manual speed instead of
974
+ // the sleep notch.
975
+ if (this.hasFeature('sleep_mode') && effectiveMode === 'sleep') {
976
+ if (this.device.deviceStatus !== 'on') {
977
+ return 0;
978
+ }
979
+ // Prefer the value the controller sent, so a controller on a stale grid
980
+ // isn't dragged to our canonical notch position.
981
+ return this.lastSetPercentage > 0
982
+ ? this.lastSetPercentage
983
+ : this.calculateRotationSpeedStep();
984
+ }
749
985
  // If device is off or speed is not defined, return 0
750
986
  if (this.device.deviceStatus !== 'on' ||
751
987
  this.device.speed === undefined ||
752
988
  this.device.speed === null) {
753
- // If in sleep mode, surface the first notch
754
- if (this.hasFeature('sleep_mode') && (extended.mode || '').toLowerCase() === 'sleep') {
755
- return this.calculateRotationSpeedStep();
756
- }
757
989
  return 0;
758
990
  }
759
991
  // If we have a last set percentage, use that instead of calculating from device speed
@@ -767,31 +999,25 @@ class AirPurifierAccessory extends base_accessory_1.BaseAccessory {
767
999
  return percentage;
768
1000
  }
769
1001
  /**
770
- * Set rotation speed
1002
+ * Set rotation speed. Buffered so it can be reconciled with any
1003
+ * Active/TargetAirPurifierState writes sent in the same HomeKit request -
1004
+ * the slider implicitly carries a mode, so it must not clobber an explicit
1005
+ * AUTO written alongside it.
771
1006
  */
772
1007
  async setRotationSpeed(value) {
1008
+ let percentage = value;
1009
+ if (typeof percentage !== 'number' || isNaN(percentage)) {
1010
+ this.platform.log.warn(`Invalid rotation speed value: ${value} for device: ${this.device.deviceName}`);
1011
+ return;
1012
+ }
1013
+ percentage = Math.min(100, Math.max(0, percentage));
1014
+ return this.queueWrite('rotationSpeed', percentage);
1015
+ }
1016
+ async applyRotationSpeed(value) {
773
1017
  try {
774
- // Ensure value is a valid number
775
- let percentage = value;
776
- if (isNaN(percentage) || percentage === null || percentage === undefined) {
777
- this.platform.log.warn(`Invalid rotation speed value: ${value} for device: ${this.device.deviceName}`);
778
- return;
779
- }
780
- // Ensure percentage is between 0 and 100
781
- percentage = Math.min(100, Math.max(0, percentage));
1018
+ // Value is already validated and clamped by setRotationSpeed()
1019
+ const percentage = value;
782
1020
  this.platform.log.debug(`Setting rotation speed to ${percentage}% for device: ${this.device.deviceName}`);
783
- if (percentage === 0) {
784
- // Turn off the device instead of setting speed to 0
785
- this.platform.log.debug(`Turning off device ${this.device.deviceName} due to 0% rotation speed`);
786
- const success = await this.device.turnOff();
787
- if (!success) {
788
- throw new Error('Failed to turn off device');
789
- }
790
- // Reset tracking variables when turning off
791
- this.lastSetSpeed = 0;
792
- this.lastSetPercentage = 0;
793
- return;
794
- }
795
1021
  const extendedDevice = this.device;
796
1022
  // Check if device is off and turn it on first
797
1023
  if (this.device.deviceStatus !== 'on') {
@@ -813,11 +1039,14 @@ class AirPurifierAccessory extends base_accessory_1.BaseAccessory {
813
1039
  }
814
1040
  // When using sleep-as-first-notch, map slider notches robustly by rounding
815
1041
  if (this.hasFeature('sleep_mode')) {
816
- const max = this.isCore200S() ? 3 : this.getMaxFanSpeed();
817
- const step = this.calculateRotationSpeedStep();
1042
+ const max = this.getEffectiveMaxFanSpeed();
818
1043
  const supportsTurbo = this.hasFeature('turbo_mode');
819
- const totalNotches = max + (supportsTurbo ? 2 : 1); // sleep + manual + optional turbo
820
- const notch = Math.max(0, Math.min(totalNotches, Math.round(percentage / step)));
1044
+ const totalNotches = this.getTotalNotches(); // sleep + manual + optional turbo
1045
+ const notch = this.percentageToNotch(percentage);
1046
+ // Echo back the value the controller actually sent rather than our own
1047
+ // canonical notch position. If its cached step differs from ours the
1048
+ // two are not equal, and echoing ours makes the slider visibly jump.
1049
+ const echoPct = percentage;
821
1050
  if (notch <= 1) {
822
1051
  // Sleep
823
1052
  this.platform.log.info(`Changing mode to sleep for device: ${this.device.deviceName}`);
@@ -832,11 +1061,12 @@ class AirPurifierAccessory extends base_accessory_1.BaseAccessory {
832
1061
  throw new Error('Failed to set sleep mode');
833
1062
  }
834
1063
  // Immediate UI feedback
835
- this.service.updateCharacteristic(this.platform.Characteristic.RotationSpeed, step);
1064
+ this.service.updateCharacteristic(this.platform.Characteristic.RotationSpeed, echoPct);
836
1065
  this.service.updateCharacteristic(this.platform.Characteristic.Active, 1);
837
1066
  this.service.updateCharacteristic(this.platform.Characteristic.CurrentAirPurifierState, 2);
1067
+ this.syncTargetStateToHomeKit('sleep');
838
1068
  this.lastSetSpeed = 0;
839
- this.lastSetPercentage = step;
1069
+ this.lastSetPercentage = echoPct;
840
1070
  this.skipNextUpdate = true;
841
1071
  return;
842
1072
  }
@@ -855,6 +1085,7 @@ class AirPurifierAccessory extends base_accessory_1.BaseAccessory {
855
1085
  this.service.updateCharacteristic(this.platform.Characteristic.RotationSpeed, 100);
856
1086
  this.service.updateCharacteristic(this.platform.Characteristic.Active, 1);
857
1087
  this.service.updateCharacteristic(this.platform.Characteristic.CurrentAirPurifierState, 2);
1088
+ this.syncTargetStateToHomeKit('turbo');
858
1089
  this.lastSetSpeed = max;
859
1090
  this.lastSetPercentage = 100;
860
1091
  this.skipNextUpdate = true;
@@ -868,18 +1099,23 @@ class AirPurifierAccessory extends base_accessory_1.BaseAccessory {
868
1099
  const modeNow = (extendedDevice.mode || '').toLowerCase();
869
1100
  if (modeNow === 'auto' || modeNow === 'sleep' || (supportsTurbo && modeNow === 'turbo')) {
870
1101
  this.platform.log.info(`Changing mode to manual for device: ${this.device.deviceName}`);
1102
+ let modeOk = false;
871
1103
  if (typeof extendedDevice.manualMode === 'function') {
872
- await extendedDevice.manualMode();
1104
+ modeOk = await extendedDevice.manualMode();
873
1105
  }
874
1106
  else if (typeof extendedDevice.setMode === 'function') {
875
- await extendedDevice.setMode('manual');
1107
+ modeOk = await extendedDevice.setMode('manual');
1108
+ }
1109
+ if (!modeOk) {
1110
+ // Don't silently push a speed at a device still sitting in auto
1111
+ throw new Error('Failed to set manual mode');
876
1112
  }
877
1113
  }
878
- // Update UI early
879
- const uiPct = Math.min(100, manualNotch * step);
880
- this.service.updateCharacteristic(this.platform.Characteristic.RotationSpeed, uiPct);
1114
+ // Update UI early, echoing the controller's own value
1115
+ this.service.updateCharacteristic(this.platform.Characteristic.RotationSpeed, echoPct);
1116
+ this.syncTargetStateToHomeKit('manual');
881
1117
  this.lastSetSpeed = desiredSpeed;
882
- this.lastSetPercentage = uiPct;
1118
+ this.lastSetPercentage = echoPct;
883
1119
  this.skipNextUpdate = true;
884
1120
  const ok = await this.device.changeFanSpeed(desiredSpeed);
885
1121
  if (!ok) {
@@ -887,6 +1123,9 @@ class AirPurifierAccessory extends base_accessory_1.BaseAccessory {
887
1123
  this.lastSetPercentage = 0;
888
1124
  throw new Error(`Failed to set speed to ${desiredSpeed}`);
889
1125
  }
1126
+ // The library leaves its cache stale for these models - fix it up so
1127
+ // the slider doesn't snap back to the previously reported speed.
1128
+ this.cacheCommandedSpeed(desiredSpeed, 'manual');
890
1129
  return;
891
1130
  }
892
1131
  // Check if fan speed control is supported in current mode
@@ -913,12 +1152,18 @@ class AirPurifierAccessory extends base_accessory_1.BaseAccessory {
913
1152
  // For devices without sleep notch, ensure manual mode if required
914
1153
  const modeNow = (extendedDevice.mode || '').toLowerCase();
915
1154
  if (modeNow === 'auto') {
1155
+ let modeOk = false;
916
1156
  if (typeof extendedDevice.manualMode === 'function') {
917
- await extendedDevice.manualMode();
1157
+ modeOk = await extendedDevice.manualMode();
918
1158
  }
919
1159
  else if (typeof extendedDevice.setMode === 'function') {
920
- await extendedDevice.setMode('manual');
1160
+ modeOk = await extendedDevice.setMode('manual');
1161
+ }
1162
+ if (!modeOk) {
1163
+ throw new Error('Failed to set manual mode');
921
1164
  }
1165
+ // Reflect the mode change in HomeKit right away
1166
+ this.syncTargetStateToHomeKit('manual');
922
1167
  }
923
1168
  // Convert percentage to device speed using our consistent conversion method
924
1169
  const speed = this.percentageToSpeed(percentage);
@@ -943,6 +1188,7 @@ class AirPurifierAccessory extends base_accessory_1.BaseAccessory {
943
1188
  this.lastSetPercentage = 0;
944
1189
  throw new Error(`Failed to set speed to ${speed}`);
945
1190
  }
1191
+ this.cacheCommandedSpeed(speed, 'manual');
946
1192
  this.platform.log.debug(`Successfully set fan speed to ${speed} (${percentage}%) for device: ${this.device.deviceName}`);
947
1193
  // Update device state and characteristics
948
1194
  // Skip this to avoid overriding our UI update
@@ -955,7 +1201,15 @@ class AirPurifierAccessory extends base_accessory_1.BaseAccessory {
955
1201
  async getActive() {
956
1202
  return this.device.deviceStatus === 'on' ? 1 : 0;
957
1203
  }
1204
+ /**
1205
+ * Set active state. Buffered so it can be reconciled with any
1206
+ * RotationSpeed/TargetAirPurifierState writes sent in the same HomeKit
1207
+ * request - notably so a 0% slider can't turn the device straight back off.
1208
+ */
958
1209
  async setActive(value) {
1210
+ return this.queueWrite('active', value === 1 ? 1 : 0);
1211
+ }
1212
+ async applyActive(value) {
959
1213
  try {
960
1214
  const isOn = value === 1;
961
1215
  this.platform.log.debug(`Setting device ${this.device.deviceName} to ${isOn ? 'on' : 'off'}`);
@@ -1015,6 +1269,20 @@ class AirPurifierAccessory extends base_accessory_1.BaseAccessory {
1015
1269
  }
1016
1270
  }
1017
1271
  exports.AirPurifierAccessory = AirPurifierAccessory;
1272
+ /**
1273
+ * How long to buffer characteristic writes before applying them. HAP-NodeJS
1274
+ * dispatches every characteristic in one request within the same tick, so a
1275
+ * short window is enough to see the whole batch; it stays far below HAP's
1276
+ * 3s slow-write warning threshold.
1277
+ */
1278
+ AirPurifierAccessory.WRITE_COALESCE_MS = 50;
1279
+ // Tracks the last mode we commanded, to protect against stale API responses.
1280
+ // VeSync is eventually consistent: for a few seconds after a mode change
1281
+ // getDetails() still reports the *previous* mode, which would otherwise flip
1282
+ // the HomeKit tile straight back (auto->manual, or manual->auto). Mirrors the
1283
+ // guard the humidifier accessory already uses. Expires so an out-of-band
1284
+ // change made in the VeSync app is not masked indefinitely.
1285
+ AirPurifierAccessory.COMMANDED_MODE_TTL_MS = 30000;
1018
1286
  /**
1019
1287
  * Pet mode is exposed as a dedicated Switch service because HomeKit's
1020
1288
  * AirPurifier service has no native representation for it. Only added for