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.
- package/.github/workflows/ci.yml +24 -1
- package/CHANGELOG.md +16 -0
- package/README.md +9 -1
- package/dist/accessories/fan.accessory.d.ts +20 -20
- package/dist/accessories/fan.accessory.d.ts.map +1 -1
- package/dist/accessories/fan.accessory.js +191 -241
- package/dist/accessories/fan.accessory.js.map +1 -1
- package/dist/types/device.types.d.ts +14 -2
- package/dist/types/device.types.d.ts.map +1 -1
- package/dist/utils/api-proxy.d.ts.map +1 -1
- package/dist/utils/api-proxy.js +13 -1
- package/dist/utils/api-proxy.js.map +1 -1
- package/package.json +4 -3
- package/scripts/tower-fan-smoke.cjs +81 -0
- package/src/__tests__/devices/fan.test.ts +280 -175
- package/src/accessories/fan.accessory.ts +184 -314
- package/src/types/device.types.ts +14 -2
- package/src/utils/api-proxy.ts +13 -2
package/.github/workflows/ci.yml
CHANGED
|
@@ -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:
|
|
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
|
|
8
|
-
private
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
protected updateDeviceSpecificStates(
|
|
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
|
|
17
|
-
private
|
|
18
|
-
|
|
19
|
-
private
|
|
20
|
-
private
|
|
21
|
-
private
|
|
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,
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
183
|
-
return
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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
|
-
|
|
198
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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
|
|
213
|
+
const characteristic = this.platform.Characteristic.LockPhysicalControls;
|
|
211
214
|
if (!this.supportsChildLock()) {
|
|
212
|
-
|
|
213
|
-
|
|
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(
|
|
220
|
-
|
|
221
|
-
|
|
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;
|