homebridge-tsvesync 1.6.1 → 1.7.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.
@@ -28,11 +28,34 @@ jobs:
28
28
  uses: actions/setup-node@v6
29
29
  with:
30
30
  node-version: ${{ matrix.node-version }}
31
+ - name: Check out companion library
32
+ uses: actions/checkout@v6
33
+ with:
34
+ repository: mickgiles/tsvesync
35
+ path: .companion-tsvesync
36
+ - name: Build matching companion package
37
+ working-directory: .companion-tsvesync
38
+ env:
39
+ COMPANION_BRANCH: ${{ github.head_ref || github.ref_name }}
40
+ run: |
41
+ if git ls-remote --exit-code --heads origin "$COMPANION_BRANCH" >/dev/null; then
42
+ git fetch --depth=1 origin "$COMPANION_BRANCH"
43
+ git checkout --detach FETCH_HEAD
44
+ fi
45
+ npm install
46
+ npm run build
47
+ npm run test:tower-fan
48
+ npm pack --pack-destination "$RUNNER_TEMP"
31
49
  - name: Install dependencies
32
- run: npm install
50
+ run: |
51
+ version=$(node -p 'require("./package.json").dependencies.tsvesync')
52
+ test "$(node -p 'require("./.companion-tsvesync/package.json").version')" = "$version"
53
+ npm install --no-save --package-lock=false "$RUNNER_TEMP/tsvesync-$version.tgz"
33
54
  - name: Lint
34
55
  run: npm run lint
35
56
  - name: Build
36
57
  run: npm run build
37
58
  - name: Test
38
59
  run: npm test -- --runInBand
60
+ - name: Offline Homebridge smoke test
61
+ run: npm run test:fan-smoke
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.7.0 (2026-11-05)
4
+
5
+ ### Fixed
6
+ - **Tower Fan Oscillation And Display** ([#5](https://github.com/mickgiles/homebridge-tsvesync/issues/5)): Correct the cloud requests through tsvesync 1.7.0, synchronize native Swing Mode and the legacy direction control, and use HomeKit's actual direction constants. Replace stale display Lightbulb controls with a binary Display switch.
7
+ - **Tower Fan Mode Controls**: Reuse the existing Fan Mode service as a boolean Turbo Mode switch and add separate Auto Mode and Sleep Mode switches. Disabling the active mode returns to Normal. Polling and successful commands update every mode switch without invoking setters.
8
+ - **Fan Speed And Power**: Round-trip all 12 levels correctly, power on for nonzero speeds or enabled modes, return manual slider changes to Normal, and coalesce scene writes so a zero-speed value does not undo an explicit power-on or mode. Explicit power-off wins.
9
+ - **Failures Reach HomeKit**: Reject failed power, speed, mode, oscillation, display, child-lock, and timer writes instead of acknowledging them as successful.
10
+
11
+ ### Added
12
+ - **Tower Fan Temperature Sensor**: Report Celsius through HomeKit's temperature service, converting the device's Fahrenheit-times-ten value. Missing readings report unavailable rather than a fabricated zero.
13
+ - **Working Timer Switch**: Turning Timer on schedules a power change in one hour (off when running, on when stopped); turning it off cancels the actual timer ID. Controllers supporting Set Duration can select 60–43200 seconds, and Remaining Duration reports the countdown.
14
+ - **Real HomeKit Regression Coverage**: Exercise actual HAP services and the real tsvesync request builder with an offline transport, including cached-accessory upgrades, scene batches, polling, and rejected device commands.
15
+
16
+ ### Dependencies
17
+ - **tsvesync**: Pin to 1.7.0; both packages remain on the same version.
18
+
3
19
  ## 1.6.1 (2026-07-27)
4
20
 
5
21
  ### Fixed
package/README.md CHANGED
@@ -107,7 +107,15 @@ All outlets are exposed as HomeKit outlets with the following features:
107
107
  * On/Off control
108
108
  * Fan speed control (1-12)
109
109
  * Mode selection (Normal, Auto, Sleep, Turbo)
110
- * Oscillation control
110
+ * Oscillation control (native Swing Mode and legacy direction control)
111
+ * Display on/off switch (no brightness slider)
112
+ * Room temperature sensor (automatically converted to Celsius; Home shows your preferred unit)
113
+ * Timer switch: defaults to one hour, schedules off while running or on while stopped
114
+ * Timer duration (60–43200 seconds) and countdown in controllers supporting Set Duration / Remaining Duration
115
+
116
+ The existing Fan Mode switch becomes Turbo Mode and keeps its service identity. Auto Mode and Sleep Mode have separate switches. Turning off the active mode returns to Normal; moving the speed slider also selects Normal. Existing display brightness tiles are replaced with the Display switch on upgrade. LTF-F422S fans do not support a night light or child lock.
117
+
118
+ Tower-fan API behavior is checked against pyvesync and offline request fixtures. Physical fan validation is still needed for firmware-specific behavior; a Homebridge installation without a tower fan cannot establish that hardware validation.
111
119
 
112
120
  ### Wall Switches
113
121
  - ESWL01
@@ -4,30 +4,30 @@ import { TSVESyncPlatform } from '../platform';
4
4
  import { DeviceCapabilities, VeSyncFan } from '../types/device.types';
5
5
  export declare class FanAccessory extends BaseAccessory {
6
6
  protected readonly device: VeSyncFan;
7
- private readonly capabilities;
8
- private readonly speedLevels;
7
+ private writeChain;
8
+ private pendingWrite;
9
+ private pendingPromise;
9
10
  constructor(platform: TSVESyncPlatform, accessory: PlatformAccessory, device: VeSyncFan);
11
+ private get maxSpeed();
12
+ private get currentMode();
13
+ private get oscillationEnabled();
14
+ private supports;
15
+ private supportsMode;
16
+ private findSwitch;
17
+ private setupSwitch;
10
18
  protected setupService(): void;
11
- /**
12
- * Update device states based on the latest details
13
- */
14
- protected updateDeviceSpecificStates(details: any): Promise<void>;
19
+ private get timerDuration();
20
+ private get currentTemperature();
21
+ private get rotationSpeed();
22
+ protected updateDeviceSpecificStates(_details: any): Promise<void>;
15
23
  protected getDeviceCapabilities(): DeviceCapabilities;
16
- private getActive;
17
- private setActive;
18
- private handleSetRotationSpeed;
19
- private getRotationSpeed;
20
- private getRotationDirection;
21
- private setRotationDirection;
22
- private getSwingMode;
23
- private setSwingMode;
24
+ private requireSuccess;
25
+ private enqueue;
26
+ /** Coalesce scene writes so an explicit mode/on is not undone by a 0% slider. */
27
+ private queueWrite;
28
+ private applyWrite;
29
+ private setOscillation;
24
30
  private supportsChildLock;
25
31
  private setupChildLock;
26
- private getLockPhysicalControls;
27
- private setLockPhysicalControls;
28
- private getModeValue;
29
- private getModeString;
30
- private getMode;
31
- private setMode;
32
32
  }
33
33
  //# sourceMappingURL=fan.accessory.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"fan.accessory.d.ts","sourceRoot":"","sources":["../../src/accessories/fan.accessory.ts"],"names":[],"mappings":"AAAA,OAAO,EAAuB,iBAAiB,EAAE,MAAM,YAAY,CAAC;AACpE,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACjD,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAC/C,OAAO,EAAE,kBAAkB,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAqCtE,qBAAa,YAAa,SAAQ,aAAa;IAC7C,SAAS,CAAC,QAAQ,CAAC,MAAM,EAAE,SAAS,CAAC;IACrC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAqB;IAClD,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAW;gBAGrC,QAAQ,EAAE,gBAAgB,EAC1B,SAAS,EAAE,iBAAiB,EAC5B,MAAM,EAAE,SAAS;IAQnB,SAAS,CAAC,YAAY,IAAI,IAAI;IAsD9B;;OAEG;cACa,0BAA0B,CAAC,OAAO,EAAE,GAAG,GAAG,OAAO,CAAC,IAAI,CAAC;IAsDvE,SAAS,CAAC,qBAAqB,IAAI,kBAAkB;YAcvC,SAAS;YAIT,SAAS;YAiBT,sBAAsB;YAkBtB,gBAAgB;YAiBhB,oBAAoB;YAMpB,oBAAoB;YAmBpB,YAAY;YAIZ,YAAY;IAmB1B,OAAO,CAAC,iBAAiB;IASzB,OAAO,CAAC,cAAc;YAmBR,uBAAuB;YAIvB,uBAAuB;IAmBrC,OAAO,CAAC,YAAY;IAUpB,OAAO,CAAC,aAAa;YASP,OAAO;YAIP,OAAO;CActB"}
1
+ {"version":3,"file":"fan.accessory.d.ts","sourceRoot":"","sources":["../../src/accessories/fan.accessory.ts"],"names":[],"mappings":"AAAA,OAAO,EAAuB,iBAAiB,EAAW,MAAM,YAAY,CAAC;AAC7E,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACjD,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAC/C,OAAO,EAAE,kBAAkB,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAetE,qBAAa,YAAa,SAAQ,aAAa;IAC7C,SAAS,CAAC,QAAQ,CAAC,MAAM,EAAE,SAAS,CAAC;IACrC,OAAO,CAAC,UAAU,CAAoC;IACtD,OAAO,CAAC,YAAY,CAAyB;IAC7C,OAAO,CAAC,cAAc,CAA8B;gBAExC,QAAQ,EAAE,gBAAgB,EAAE,SAAS,EAAE,iBAAiB,EAAE,MAAM,EAAE,SAAS;IAKvF,OAAO,KAAK,QAAQ,GAEnB;IAED,OAAO,KAAK,WAAW,GAEtB;IAED,OAAO,KAAK,kBAAkB,GAE7B;IAED,OAAO,CAAC,QAAQ;IAIhB,OAAO,CAAC,YAAY;IAIpB,OAAO,CAAC,UAAU;IAMlB,OAAO,CAAC,WAAW;IAQnB,SAAS,CAAC,YAAY,IAAI,IAAI;IAgE9B,OAAO,KAAK,aAAa,GAGxB;IAED,OAAO,KAAK,kBAAkB,GAG7B;IAED,OAAO,KAAK,aAAa,GAExB;cAEe,0BAA0B,CAAC,QAAQ,EAAE,GAAG,GAAG,OAAO,CAAC,IAAI,CAAC;IAqBxE,SAAS,CAAC,qBAAqB,IAAI,kBAAkB;IAKrD,OAAO,CAAC,cAAc;IAItB,OAAO,CAAC,OAAO;IAgBf,iFAAiF;IACjF,OAAO,CAAC,UAAU;YAoBJ,UAAU;IAiBxB,OAAO,CAAC,cAAc;IAItB,OAAO,CAAC,iBAAiB;IAIzB,OAAO,CAAC,cAAc;CAUvB"}
@@ -2,273 +2,223 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.FanAccessory = void 0;
4
4
  const base_accessory_1 = require("./base.accessory");
5
- // Constants for fan direction
6
- const CLOCKWISE = 1;
7
- const COUNTER_CLOCKWISE = 0;
8
- // Constants for fan modes
9
- const MODE_NORMAL = 0;
10
- const MODE_AUTO = 1;
11
- const MODE_SLEEP = 2;
12
- const MODE_TURBO = 3;
13
- // Fan speed levels for different device types
14
- const FAN_SPEED_LEVELS = {
15
- 'LTF-F422S-KEU': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
16
- 'LTF-F422S-WUSR': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
17
- 'LTF-F422_WJP': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
18
- 'LTF-F422S-WUS': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
19
- 'Core200S': [1, 2, 3],
20
- 'Core300S': [1, 2, 3, 4],
21
- 'Core400S': [1, 2, 3, 4],
22
- 'Core600S': [1, 2, 3, 4],
23
- 'LAP-C201S-AUSR': [1, 2, 3],
24
- 'LAP-C202S-WUSR': [1, 2, 3],
25
- 'LAP-C301S-WJP': [1, 2, 3, 4],
26
- 'LAP-C302S-WUSB': [1, 2, 3, 4],
27
- 'LAP-C301S-WAAA': [1, 2, 3, 4],
28
- 'LAP-C401S-WJP': [1, 2, 3, 4],
29
- 'LAP-C401S-WUSR': [1, 2, 3, 4],
30
- 'LAP-C401S-WAAA': [1, 2, 3, 4],
31
- 'LAP-C601S-WUS': [1, 2, 3, 4],
32
- 'LAP-C601S-WUSR': [1, 2, 3, 4],
33
- 'LAP-C601S-WEU': [1, 2, 3, 4],
34
- 'LV-PUR131S': [1, 2, 3],
35
- 'LV-RH131S': [1, 2, 3]
36
- };
5
+ const MODES = [
6
+ // Reuse the old Fan Mode service identity so existing automations survive.
7
+ { mode: 'turbo', name: 'Turbo Mode', subtype: 'fan-mode' },
8
+ { mode: 'auto', name: 'Auto Mode', subtype: 'auto-mode' },
9
+ { mode: 'sleep', name: 'Sleep Mode', subtype: 'sleep-mode' },
10
+ ];
37
11
  class FanAccessory extends base_accessory_1.BaseAccessory {
38
12
  constructor(platform, accessory, device) {
39
13
  super(platform, accessory, device);
14
+ this.writeChain = Promise.resolve();
15
+ this.pendingWrite = null;
16
+ this.pendingPromise = null;
40
17
  this.device = device;
41
- this.capabilities = this.getDeviceCapabilities();
42
- this.speedLevels = FAN_SPEED_LEVELS[this.device.deviceType] || [1, 2, 3, 4];
43
18
  }
44
- setupService() {
45
- // Get or create the fan service
46
- this.service = this.accessory.getService(this.platform.Service.Fanv2) ||
47
- this.accessory.addService(this.platform.Service.Fanv2);
48
- // Set up required characteristics
49
- this.setupCharacteristic(this.platform.Characteristic.Active, this.getActive.bind(this), this.setActive.bind(this));
50
- // Set up speed control
51
- this.setupCharacteristic(this.platform.Characteristic.RotationSpeed, this.getRotationSpeed.bind(this), this.handleSetRotationSpeed.bind(this));
52
- // Set up mode control
53
- this.setupCharacteristic(this.platform.Characteristic.SwingMode, this.getSwingMode.bind(this), this.setSwingMode.bind(this));
54
- // Set up rotation direction
55
- this.setupCharacteristic(this.platform.Characteristic.RotationDirection, this.getRotationDirection.bind(this), this.setRotationDirection.bind(this));
56
- // Set up child lock for devices that support it. LTF tower fans do not -
57
- // their API neither reports nor accepts child lock, so the profile no
58
- // longer advertises the feature.
59
- this.setupChildLock();
60
- // Add mode control service
61
- const modeService = this.accessory.getService('Fan Mode') ||
62
- this.accessory.addService(this.platform.Service.Switch, 'Fan Mode', 'fan-mode');
63
- // Set up mode characteristics
64
- modeService.getCharacteristic(this.platform.Characteristic.On)
65
- .onGet(() => this.getMode())
66
- .onSet((value) => this.setMode(value));
67
- // Add Name characteristic
68
- this.setupCharacteristic(this.platform.Characteristic.Name, async () => this.sanitizedDeviceName);
19
+ get maxSpeed() {
20
+ var _a, _b, _c;
21
+ return (_c = (_b = (_a = this.device).getMaxFanSpeed) === null || _b === void 0 ? void 0 : _b.call(_a)) !== null && _c !== void 0 ? _c : (this.device.deviceType.startsWith('LTF-F422') ? 12 : 4);
69
22
  }
70
- /**
71
- * Update device states based on the latest details
72
- */
73
- async updateDeviceSpecificStates(details) {
74
- const fanDetails = details;
75
- // Update active state
76
- const isActive = fanDetails.deviceStatus === 'on';
77
- this.updateCharacteristicValue(this.platform.Characteristic.Active, isActive ? 1 : 0);
78
- // Update current state (INACTIVE = 0, IDLE = 1, BLOWING_AIR = 2)
79
- this.updateCharacteristicValue(this.platform.Characteristic.CurrentFanState, isActive ? 2 : 0);
80
- // Update rotation speed
81
- let rotationSpeed = 0;
82
- if (isActive && fanDetails.speed !== undefined) {
83
- const speedIndex = this.speedLevels.indexOf(fanDetails.speed);
84
- if (speedIndex !== -1) {
85
- const maxLevel = this.speedLevels.length;
86
- const levelSize = 100 / maxLevel;
87
- rotationSpeed = Math.round((speedIndex + 1) * levelSize);
88
- }
89
- }
90
- this.updateCharacteristicValue(this.platform.Characteristic.RotationSpeed, rotationSpeed);
91
- // Update oscillation status
92
- if ('oscillationState' in fanDetails) {
93
- this.updateCharacteristicValue(this.platform.Characteristic.SwingMode, fanDetails.oscillationState ? 1 : 0);
94
- }
95
- // Update mode
96
- const modeService = this.accessory.getService('Fan Mode');
97
- if (modeService && fanDetails.mode) {
98
- modeService.setCharacteristic(this.platform.Characteristic.On, this.getModeValue(fanDetails.mode));
99
- }
23
+ get currentMode() {
24
+ return this.device.mode === 'advancedSleep' ? 'sleep' : this.device.mode || 'normal';
100
25
  }
101
- getDeviceCapabilities() {
102
- return {
103
- hasBrightness: false,
104
- hasColorTemp: false,
105
- hasColor: false,
106
- hasSpeed: true,
107
- hasHumidity: false,
108
- hasAirQuality: false,
109
- hasWaterLevel: false,
110
- hasChildLock: true,
111
- hasSwingMode: true,
112
- };
26
+ get oscillationEnabled() {
27
+ var _a;
28
+ return (_a = this.device.oscillationEnabled) !== null && _a !== void 0 ? _a : this.device.oscillationState;
113
29
  }
114
- async getActive() {
115
- return this.device.deviceStatus === 'on' ? 1 : 0;
30
+ supports(feature) {
31
+ var _a, _b, _c;
32
+ return (_c = (_b = (_a = this.device).hasFeature) === null || _b === void 0 ? void 0 : _b.call(_a, feature)) !== null && _c !== void 0 ? _c : this.device.deviceType.startsWith('LTF-F422');
116
33
  }
117
- async setActive(value) {
118
- try {
119
- const isOn = value === 1;
120
- const success = isOn ? await this.device.turnOn() : await this.device.turnOff();
121
- if (!success) {
122
- throw new Error(`Failed to turn ${isOn ? 'on' : 'off'} device`);
123
- }
124
- // Update device state and characteristics
125
- await this.updateDeviceSpecificStates(this.device);
126
- await this.persistDeviceState('deviceStatus', isOn ? 'on' : 'off');
127
- }
128
- catch (error) {
129
- this.handleDeviceError('set active state', error);
130
- }
34
+ supportsMode(mode) {
35
+ var _a, _b, _c;
36
+ return (_c = (_b = (_a = this.device).getSupportedModes) === null || _b === void 0 ? void 0 : _b.call(_a).includes(mode)) !== null && _c !== void 0 ? _c : true;
131
37
  }
132
- async handleSetRotationSpeed(value) {
133
- const percentage = value;
134
- if (percentage === 0) {
135
- // Turn off the device instead of setting speed to 0
136
- await this.device.turnOff();
137
- return;
138
- }
139
- // Convert HomeKit percentage to device speed level
140
- const maxLevel = this.speedLevels.length;
141
- const levelSize = 100 / maxLevel;
142
- const levelIndex = Math.min(Math.floor(percentage / levelSize), maxLevel - 1);
143
- const speed = this.speedLevels[levelIndex];
144
- await this.device.changeFanSpeed(speed);
145
- }
146
- async getRotationSpeed() {
147
- if (this.device.speed === undefined || this.device.speed === null) {
148
- return 0;
149
- }
150
- // Convert device speed to HomeKit percentage
151
- const currentSpeed = this.device.speed;
152
- const speedIndex = this.speedLevels.indexOf(currentSpeed);
153
- if (speedIndex === -1) {
154
- return 0;
155
- }
156
- const maxLevel = this.speedLevels.length;
157
- const levelSize = 100 / maxLevel;
158
- return Math.round((speedIndex + 1) * levelSize);
38
+ findSwitch(name, subtype) {
39
+ return this.accessory.getServiceById(this.platform.Service.Switch, subtype) ||
40
+ this.accessory.getService(name) ||
41
+ (subtype === 'fan-mode' ? this.accessory.getService('Fan Mode') : undefined);
159
42
  }
160
- async getRotationDirection() {
161
- return 'oscillation' in this.device && this.device.oscillation
162
- ? CLOCKWISE
163
- : COUNTER_CLOCKWISE;
43
+ setupSwitch(name, subtype, get, set) {
44
+ const service = this.findSwitch(name, subtype) || this.accessory.addService(this.platform.Service.Switch, name, subtype);
45
+ service.displayName = name;
46
+ service.setCharacteristic(this.platform.Characteristic.Name, name);
47
+ this.setupCharacteristic(this.platform.Characteristic.On, get, set, undefined, service);
48
+ return service;
164
49
  }
165
- async setRotationDirection(value) {
166
- try {
167
- if (!('setOscillation' in this.device)) {
168
- throw new Error('Device does not support oscillation');
169
- }
170
- // Map CLOCKWISE to oscillation ON, COUNTER_CLOCKWISE to oscillation OFF
171
- const shouldOscillate = value === CLOCKWISE;
172
- const success = await this.device.setOscillation(shouldOscillate);
173
- if (!success) {
174
- throw new Error(`Failed to ${shouldOscillate ? 'enable' : 'disable'} oscillation`);
50
+ setupService() {
51
+ this.service = this.accessory.getService(this.platform.Service.Fanv2) || this.accessory.addService(this.platform.Service.Fanv2);
52
+ this.setupCharacteristic(this.platform.Characteristic.Active, async () => this.device.deviceStatus === 'on' ? 1 : 0, value => this.queueWrite('active', Number(value)));
53
+ this.setupCharacteristic(this.platform.Characteristic.RotationSpeed, async () => this.rotationSpeed, value => this.queueWrite('speed', Number(value)), { minValue: 0, maxValue: 100, minStep: 1 });
54
+ this.setupCharacteristic(this.platform.Characteristic.SwingMode, async () => Number(this.oscillationEnabled), value => this.setOscillation(Number(value) === 1));
55
+ // Legacy clients used direction to toggle oscillation. Keep both controls in sync.
56
+ this.setupCharacteristic(this.platform.Characteristic.RotationDirection, async () => this.oscillationEnabled ? this.platform.Characteristic.RotationDirection.CLOCKWISE : this.platform.Characteristic.RotationDirection.COUNTER_CLOCKWISE, value => this.setOscillation(value === this.platform.Characteristic.RotationDirection.CLOCKWISE));
57
+ this.setupCharacteristic(this.platform.Characteristic.Name, async () => this.sanitizedDeviceName);
58
+ this.setupChildLock();
59
+ for (const { mode, name, subtype } of MODES) {
60
+ if (!this.supportsMode(mode))
61
+ continue;
62
+ this.setupSwitch(name, subtype, async () => this.device.deviceStatus === 'on' && this.currentMode === mode, value => this.queueWrite('modes', { [mode]: Boolean(value) }));
63
+ }
64
+ if (this.supports('display') && this.device.setDisplay) {
65
+ // Old cached Lightbulb/brightness controls cannot represent a binary display.
66
+ for (const service of [...this.accessory.services]) {
67
+ if (service.UUID === this.platform.Service.Lightbulb.UUID &&
68
+ (service.subtype === 'display' || /^(display|night light)$/i.test(service.displayName))) {
69
+ this.accessory.removeService(service);
70
+ }
175
71
  }
176
- }
177
- catch (error) {
178
- this.handleDeviceError('set rotation direction', error);
179
- throw error;
72
+ this.setupSwitch('Display', 'display', async () => Boolean(this.device.displayEnabled), value => this.enqueue(async () => {
73
+ this.requireSuccess(await this.device.setDisplay(Boolean(value)), 'set display');
74
+ }));
75
+ }
76
+ if (this.supports('timer') && this.device.setTimer && this.device.clearTimer) {
77
+ const timer = this.setupSwitch('Timer', 'timer', async () => { var _a; return ((_a = this.device.timerRemaining) !== null && _a !== void 0 ? _a : 0) > 0; }, value => this.enqueue(async () => {
78
+ this.requireSuccess(value ? await this.device.setTimer(this.timerDuration / 3600) : await this.device.clearTimer(), 'set timer');
79
+ }));
80
+ timer.addOptionalCharacteristic(this.platform.Characteristic.SetDuration);
81
+ timer.addOptionalCharacteristic(this.platform.Characteristic.RemainingDuration);
82
+ timer.getCharacteristic(this.platform.Characteristic.SetDuration).updateValue(this.timerDuration);
83
+ // Home shows the on/off switch; controllers such as Eve can also set the duration.
84
+ this.setupCharacteristic(this.platform.Characteristic.SetDuration, async () => this.timerDuration, value => this.enqueue(async () => {
85
+ var _a;
86
+ const seconds = Number(value);
87
+ if (!Number.isInteger(seconds) || seconds < 60 || seconds > 43200)
88
+ throw new Error('Timer duration must be 60–43200 seconds');
89
+ if (((_a = this.device.timerRemaining) !== null && _a !== void 0 ? _a : 0) > 0)
90
+ this.requireSuccess(await this.device.setTimer(seconds / 3600), 'set timer duration');
91
+ this.accessory.context.fanTimerDuration = seconds;
92
+ }), { minValue: 60, maxValue: 43200, minStep: 1 }, timer);
93
+ this.setupCharacteristic(this.platform.Characteristic.RemainingDuration, async () => { var _a; return (_a = this.device.timerRemaining) !== null && _a !== void 0 ? _a : 0; }, undefined, { minValue: 0, maxValue: 43200, minStep: 1 }, timer);
94
+ }
95
+ if (this.supports('temperature') && 'temperatureCelsius' in this.device) {
96
+ const temperature = this.accessory.getService(this.platform.Service.TemperatureSensor) ||
97
+ this.accessory.addService(this.platform.Service.TemperatureSensor, 'Temperature', 'temperature');
98
+ this.setupCharacteristic(this.platform.Characteristic.CurrentTemperature, async () => {
99
+ const value = this.currentTemperature;
100
+ if (value === undefined)
101
+ throw new Error('Temperature is not available');
102
+ return value;
103
+ }, undefined, { minValue: -50, maxValue: 100, minStep: 0.1 }, temperature);
104
+ }
105
+ }
106
+ get timerDuration() {
107
+ const value = this.accessory.context.fanTimerDuration;
108
+ return Number.isInteger(value) && value >= 60 && value <= 43200 ? value : 3600;
109
+ }
110
+ get currentTemperature() {
111
+ const value = this.device.temperatureCelsius;
112
+ return value !== undefined && Number.isFinite(value) && value >= -50 && value <= 100 ? value : undefined;
113
+ }
114
+ get rotationSpeed() {
115
+ var _a;
116
+ return this.device.deviceStatus === 'on' ? Math.round(Math.max(0, Math.min(this.maxSpeed, (_a = this.device.speed) !== null && _a !== void 0 ? _a : 0)) / this.maxSpeed * 100) : 0;
117
+ }
118
+ async updateDeviceSpecificStates(_details) {
119
+ var _a, _b, _c, _d, _e;
120
+ const c = this.platform.Characteristic;
121
+ this.updateCharacteristicValue(c.Active, this.device.deviceStatus === 'on' ? 1 : 0);
122
+ this.updateCharacteristicValue(c.CurrentFanState, this.device.deviceStatus === 'on' ? 2 : 0);
123
+ this.updateCharacteristicValue(c.RotationSpeed, this.rotationSpeed);
124
+ this.updateCharacteristicValue(c.SwingMode, Number(this.oscillationEnabled));
125
+ this.updateCharacteristicValue(c.RotationDirection, this.oscillationEnabled ? c.RotationDirection.CLOCKWISE : c.RotationDirection.COUNTER_CLOCKWISE);
126
+ if (this.supportsChildLock())
127
+ this.updateCharacteristicValue(c.LockPhysicalControls, this.device.childLock ? 1 : 0);
128
+ for (const { mode, name, subtype } of MODES) {
129
+ (_a = this.findSwitch(name, subtype)) === null || _a === void 0 ? void 0 : _a.updateCharacteristic(c.On, this.device.deviceStatus === 'on' && this.currentMode === mode);
130
+ }
131
+ (_b = this.findSwitch('Display', 'display')) === null || _b === void 0 ? void 0 : _b.updateCharacteristic(c.On, Boolean(this.device.displayEnabled));
132
+ const timer = this.findSwitch('Timer', 'timer');
133
+ timer === null || timer === void 0 ? void 0 : timer.updateCharacteristic(c.On, ((_c = this.device.timerRemaining) !== null && _c !== void 0 ? _c : 0) > 0);
134
+ timer === null || timer === void 0 ? void 0 : timer.updateCharacteristic(c.RemainingDuration, (_d = this.device.timerRemaining) !== null && _d !== void 0 ? _d : 0);
135
+ const temperature = this.currentTemperature;
136
+ if (temperature !== undefined) {
137
+ (_e = this.accessory.getService(this.platform.Service.TemperatureSensor)) === null || _e === void 0 ? void 0 : _e.updateCharacteristic(c.CurrentTemperature, temperature);
180
138
  }
181
139
  }
182
- async getSwingMode() {
183
- return this.device.oscillationState ? 1 : 0;
184
- }
185
- async setSwingMode(value) {
186
- try {
187
- if (!this.device.setOscillation) {
188
- throw new Error('Device does not support oscillation');
140
+ getDeviceCapabilities() {
141
+ return { hasBrightness: false, hasColorTemp: false, hasColor: false, hasSpeed: true, hasHumidity: false,
142
+ hasAirQuality: false, hasWaterLevel: false, hasChildLock: this.supportsChildLock(), hasSwingMode: true };
143
+ }
144
+ requireSuccess(success, action) {
145
+ if (!success)
146
+ throw new Error(`Failed to ${action}`);
147
+ }
148
+ enqueue(action) {
149
+ const write = this.writeChain.catch(() => undefined).then(async () => {
150
+ try {
151
+ await action();
152
+ await this.updateDeviceSpecificStates(this.device);
153
+ this.cacheDeviceDetails(this.device);
154
+ await this.persistDeviceState('mode', this.currentMode);
189
155
  }
190
- const enabled = value === 1;
191
- const success = await this.device.setOscillation(enabled);
192
- if (!success) {
193
- throw new Error(`Failed to ${enabled ? 'enable' : 'disable'} oscillation`);
156
+ catch (error) {
157
+ await this.handleDeviceError('set fan state', error);
158
+ throw error;
194
159
  }
195
- await this.persistDeviceState('oscillationState', enabled);
160
+ });
161
+ this.writeChain = write;
162
+ return write;
163
+ }
164
+ /** Coalesce scene writes so an explicit mode/on is not undone by a 0% slider. */
165
+ queueWrite(key, value) {
166
+ if (!this.pendingWrite)
167
+ this.pendingWrite = {};
168
+ if (key === 'modes') {
169
+ this.pendingWrite.modes = { ...this.pendingWrite.modes, ...value };
170
+ }
171
+ else {
172
+ this.pendingWrite[key] = value;
173
+ }
174
+ if (!this.pendingPromise) {
175
+ this.pendingPromise = new Promise((resolve, reject) => {
176
+ setTimeout(() => {
177
+ var _a;
178
+ const batch = (_a = this.pendingWrite) !== null && _a !== void 0 ? _a : {};
179
+ this.pendingWrite = null;
180
+ this.pendingPromise = null;
181
+ this.enqueue(() => this.applyWrite(batch)).then(resolve, reject);
182
+ }, 50);
183
+ });
184
+ }
185
+ return this.pendingPromise;
186
+ }
187
+ async applyWrite({ active, speed, modes }) {
188
+ var _a;
189
+ const enabledMode = Object.keys(modes !== null && modes !== void 0 ? modes : {}).reverse().find(mode => modes[mode]);
190
+ if (active === 0 || (speed === 0 && active !== 1 && !enabledMode)) {
191
+ this.requireSuccess(await this.device.turnOff(), 'turn off fan');
192
+ return;
196
193
  }
197
- catch (error) {
198
- this.handleDeviceError('set oscillation', error);
194
+ const selectedMode = (_a = enabledMode !== null && enabledMode !== void 0 ? enabledMode : ((modes === null || modes === void 0 ? void 0 : modes[this.currentMode]) === false ? 'normal' : undefined)) !== null && _a !== void 0 ? _a : (speed !== undefined && speed > 0 ? 'normal' : undefined);
195
+ const shouldPowerOn = active === 1 || enabledMode !== undefined || (speed !== undefined && speed > 0);
196
+ if (shouldPowerOn && this.device.deviceStatus !== 'on')
197
+ this.requireSuccess(await this.device.turnOn(), 'turn on fan');
198
+ if (selectedMode && selectedMode !== this.currentMode)
199
+ this.requireSuccess(await this.device.setMode(selectedMode), `set ${selectedMode} mode`);
200
+ if (speed !== undefined && speed > 0 && !enabledMode) {
201
+ const level = Math.max(1, Math.min(this.maxSpeed, Math.round(speed / 100 * this.maxSpeed)));
202
+ this.requireSuccess(await this.device.changeFanSpeed(level), 'set fan speed');
199
203
  }
200
204
  }
205
+ setOscillation(enabled) {
206
+ return this.enqueue(async () => this.requireSuccess(await this.device.setOscillation(enabled), 'set oscillation'));
207
+ }
201
208
  supportsChildLock() {
202
- const device = this.device;
203
- if (typeof device.setChildLock !== 'function') {
204
- return false;
205
- }
206
- // Older tsvesync versions have no hasFeature; fall back to the setter
207
- return typeof device.hasFeature === 'function' ? device.hasFeature('child_lock') : true;
209
+ var _a, _b, _c;
210
+ return typeof this.device.setChildLock === 'function' && ((_c = (_b = (_a = this.device).hasFeature) === null || _b === void 0 ? void 0 : _b.call(_a, 'child_lock')) !== null && _c !== void 0 ? _c : true);
208
211
  }
209
212
  setupChildLock() {
210
- const lockCharacteristic = this.platform.Characteristic.LockPhysicalControls;
213
+ const characteristic = this.platform.Characteristic.LockPhysicalControls;
211
214
  if (!this.supportsChildLock()) {
212
- // Drop a stale characteristic left behind by an older cached accessory
213
- if (typeof this.service.testCharacteristic === 'function' &&
214
- this.service.testCharacteristic(lockCharacteristic)) {
215
- this.service.removeCharacteristic(this.service.getCharacteristic(lockCharacteristic));
216
- }
215
+ if (this.service.testCharacteristic(characteristic))
216
+ this.service.removeCharacteristic(this.service.getCharacteristic(characteristic));
217
217
  return;
218
218
  }
219
- this.setupCharacteristic(lockCharacteristic, this.getLockPhysicalControls.bind(this), this.setLockPhysicalControls.bind(this));
220
- }
221
- async getLockPhysicalControls() {
222
- return this.device.childLock ? 1 : 0;
223
- }
224
- async setLockPhysicalControls(value) {
225
- try {
226
- if (!this.device.setChildLock) {
227
- throw new Error('Device does not support child lock');
228
- }
229
- const enabled = value === 1;
230
- const success = await this.device.setChildLock(enabled);
231
- if (!success) {
232
- throw new Error(`Failed to ${enabled ? 'enable' : 'disable'} child lock`);
233
- }
234
- await this.persistDeviceState('child_lock', enabled);
235
- }
236
- catch (error) {
237
- this.handleDeviceError('set child lock', error);
238
- }
239
- }
240
- getModeValue(mode) {
241
- switch (mode) {
242
- case 'normal': return MODE_NORMAL;
243
- case 'auto': return MODE_AUTO;
244
- case 'sleep': return MODE_SLEEP;
245
- case 'turbo': return MODE_TURBO;
246
- default: return MODE_NORMAL;
247
- }
248
- }
249
- getModeString(value) {
250
- switch (value) {
251
- case MODE_AUTO: return 'auto';
252
- case MODE_SLEEP: return 'sleep';
253
- case MODE_TURBO: return 'turbo';
254
- default: return 'normal';
255
- }
256
- }
257
- async getMode() {
258
- return this.getModeValue(this.device.mode || 'normal');
259
- }
260
- async setMode(value) {
261
- try {
262
- const mode = this.getModeString(value);
263
- const success = await this.device.setMode(mode);
264
- if (!success) {
265
- throw new Error(`Failed to set mode to ${mode}`);
266
- }
267
- await this.persistDeviceState('mode', mode);
268
- }
269
- catch (error) {
270
- this.handleDeviceError('set mode', error);
271
- }
219
+ this.setupCharacteristic(characteristic, async () => this.device.childLock ? 1 : 0, value => this.enqueue(async () => {
220
+ this.requireSuccess(await this.device.setChildLock(Number(value) === 1), 'set child lock');
221
+ }));
272
222
  }
273
223
  }
274
224
  exports.FanAccessory = FanAccessory;