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.
- package/CHANGELOG.md +29 -0
- package/dist/accessories/air-purifier.accessory.d.ts +109 -2
- package/dist/accessories/air-purifier.accessory.d.ts.map +1 -1
- package/dist/accessories/air-purifier.accessory.js +336 -68
- package/dist/accessories/air-purifier.accessory.js.map +1 -1
- package/dist/accessories/humidifier.accessory.d.ts +32 -0
- package/dist/accessories/humidifier.accessory.d.ts.map +1 -1
- package/dist/accessories/humidifier.accessory.js +92 -9
- package/dist/accessories/humidifier.accessory.js.map +1 -1
- package/dist/platform.d.ts +7 -0
- package/dist/platform.d.ts.map +1 -1
- package/dist/platform.js +39 -12
- package/dist/platform.js.map +1 -1
- package/dist/utils/session-store.d.ts +2 -0
- package/dist/utils/session-store.d.ts.map +1 -1
- package/dist/utils/session-store.js +3 -0
- package/dist/utils/session-store.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/accessories/air-purifier-batch-write.test.ts +531 -0
- package/src/__tests__/accessories/humidifier-batch-write.test.ts +174 -0
- package/src/__tests__/session-hydration.test.ts +196 -0
- package/src/accessories/air-purifier.accessory.ts +400 -77
- package/src/accessories/humidifier.accessory.ts +121 -11
- package/src/platform.ts +37 -9
- package/src/utils/session-store.ts +7 -0
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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.
|
|
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
|
|
319
|
-
const max = this.isCore200S() ? 3 : this.getMaxFanSpeed();
|
|
372
|
+
const max = this.getEffectiveMaxFanSpeed();
|
|
320
373
|
const supportsTurbo = this.hasFeature('turbo_mode');
|
|
321
|
-
const totalNotches =
|
|
322
|
-
const notch =
|
|
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
|
|
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 || '')
|
|
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 (
|
|
608
|
-
// For other devices, including Core300S, use the
|
|
609
|
-
// Pet mode is an automatic mode, so surface it as AUTO
|
|
610
|
-
|
|
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
|
-
|
|
694
|
-
const
|
|
695
|
-
return
|
|
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 (
|
|
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
|
-
|
|
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
|
-
//
|
|
775
|
-
|
|
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.
|
|
817
|
-
const step = this.calculateRotationSpeedStep();
|
|
1042
|
+
const max = this.getEffectiveMaxFanSpeed();
|
|
818
1043
|
const supportsTurbo = this.hasFeature('turbo_mode');
|
|
819
|
-
const totalNotches =
|
|
820
|
-
const notch =
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
880
|
-
this.
|
|
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 =
|
|
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
|