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.
@@ -2,310 +2,183 @@ import { VeSyncAirBaseV2 } from './airBaseV2';
2
2
  import { VeSync } from '../vesync';
3
3
  import { logger } from '../logger';
4
4
 
5
- /**
6
- * VeSync Tower Fan Implementation
7
- */
5
+ /** LTF-F422S tower fans use different commands from bypassV2 purifiers. */
8
6
  export class VeSyncTowerFan extends VeSyncAirBaseV2 {
9
- protected readonly towerModes = ['normal', 'advancedSleep', 'off'] as const;
7
+ protected readonly towerModes = ['normal', 'auto', 'advancedSleep', 'turbo', 'off'] as const;
8
+ private timerId?: number;
9
+ private timerDeadline?: number;
10
10
 
11
11
  constructor(details: Record<string, any>, manager: VeSync) {
12
12
  super(details, manager);
13
- logger.debug(`Initialized VeSyncTowerFan device: ${this.deviceName}`);
14
13
  }
15
14
 
16
- /**
17
- * Get device details
18
- */
19
- async getDetails(): Promise<Boolean> {
20
- logger.debug(`Getting details for device: ${this.deviceName}`);
21
- const [head, body] = this.buildApiDict('getTowerFanStatus');
22
-
23
- const [response, status] = await this.callApi(
24
- '/cloud/v2/deviceManaged/bypassV2',
25
- 'post',
26
- body,
27
- head
28
- );
29
-
30
- if (!this.checkResponse([response, status], 'getDetails') || !response?.result) {
31
- logger.debug('Error getting tower fan details');
32
- this.connectionStatus = 'offline';
33
- return false;
34
- }
35
-
36
- const innerResponse = response.result;
37
- if (innerResponse.code !== 0 || !innerResponse.result) {
38
- logger.debug('Error in inner response from tower fan');
39
- this.connectionStatus = 'offline';
15
+ /** A successful cloud envelope can still contain a rejected device command. */
16
+ protected override checkResponse(response: [any, number], method: string): boolean {
17
+ if (!super.checkResponse(response, method)) return false;
18
+ const result = response[0]?.result;
19
+ if (!result || result.code !== 0) {
20
+ this.logError(method, `Device error code ${result?.code ?? 'missing'}: ${result?.msg ?? 'Invalid device response'}`);
40
21
  return false;
41
22
  }
42
-
43
- const deviceData = innerResponse.result;
44
- this.deviceStatus = deviceData.powerSwitch === 1 ? 'on' : 'off';
45
- this.details = {
46
- powerSwitch: deviceData.powerSwitch,
47
- workMode: deviceData.workMode,
48
- manualSpeedLevel: deviceData.manualSpeedLevel,
49
- fanSpeedLevel: deviceData.fanSpeedLevel,
50
- screenState: deviceData.screenState,
51
- screenSwitch: deviceData.screenSwitch,
52
- oscillationState: deviceData.oscillationState,
53
- oscillationSwitch: deviceData.oscillationSwitch,
54
- timerRemain: deviceData.timerRemain,
55
- temperature: deviceData.temperature || 0,
56
- humidity: deviceData.humidity || 0,
57
- thermalComfort: deviceData.thermalComfort,
58
- sleepPreference: deviceData.sleepPreference || {},
59
- scheduleCount: deviceData.scheduleCount,
60
- displayingType: deviceData.displayingType,
61
- errorCode: deviceData.errorCode
62
- };
63
-
64
- logger.debug(`Successfully got details for device: ${this.deviceName}`);
65
23
  return true;
66
24
  }
67
25
 
68
- /**
69
- * Toggle device power
70
- */
71
- async toggleSwitch(toggle: boolean): Promise<boolean> {
72
- if (typeof toggle !== 'boolean') {
73
- logger.debug('Invalid toggle value for tower fan switch');
74
- return false;
75
- }
76
-
77
- const [head, body] = this.buildApiDict('setSwitch');
78
- body.payload.data = {
79
- powerSwitch: toggle ? 1 : 0,
80
- switchIdx: 0
81
- };
82
-
83
- const [response, status] = await this.callApi(
84
- '/cloud/v2/deviceManaged/bypassV2',
85
- 'post',
86
- body,
87
- head
88
- );
89
-
90
- const success = this.checkResponse([response, status], 'toggleSwitch');
91
- if (success) {
92
- this.deviceStatus = toggle ? 'on' : 'off';
93
- } else {
94
- logger.debug('Error toggling tower fan power');
95
- }
96
- return success;
97
- }
98
-
99
- /**
100
- * Turn device on
101
- */
102
- async turnOn(): Promise<boolean> {
103
- logger.debug(`Turning on device: ${this.deviceName}`);
104
- return this.toggleSwitch(true);
26
+ private async request(method: string, data: Record<string, any> = {}): Promise<Record<string, any> | null> {
27
+ const [head, body] = this.buildApiDict(method);
28
+ body.payload.data = data;
29
+ const response = await this.callApi('/cloud/v2/deviceManaged/bypassV2', 'post', body, head);
30
+ if (!this.checkResponse(response, method)) return null;
31
+ this.connectionStatus = 'online';
32
+ return response[0].result.result ?? {};
105
33
  }
106
34
 
107
- /**
108
- * Turn device off
109
- */
110
- async turnOff(): Promise<boolean> {
111
- logger.debug(`Turning off device: ${this.deviceName}`);
112
- return this.toggleSwitch(false);
113
- }
114
-
115
- /**
116
- * Set tower fan mode
117
- */
118
- async mode_toggle(mode: string): Promise<boolean> {
119
- if (!this.towerModes.includes(mode as any)) {
120
- logger.debug(`Invalid mode: ${mode}. Must be one of: ${this.towerModes.join(', ')}`);
35
+ async getDetails(): Promise<boolean> {
36
+ const data = await this.request('getTowerFanStatus');
37
+ if (!data || (data.powerSwitch !== 0 && data.powerSwitch !== 1)) {
38
+ this.connectionStatus = 'offline';
121
39
  return false;
122
40
  }
123
-
124
- if (mode === 'off') {
125
- return this.turnOff();
126
- }
127
-
128
- const [head, body] = this.buildApiDict('setTowerFanMode');
129
- body.deviceId = this.cid;
130
- body.payload.data = {
131
- workMode: mode
132
- };
133
-
134
- const [response, status] = await this.callApi(
135
- '/cloud/v2/deviceManaged/bypassV2',
136
- 'post',
137
- body,
138
- head
139
- );
140
-
141
- const success = this.checkResponse([response, status], 'mode_toggle');
142
- if (success) {
143
- this.details.workMode = mode;
144
- } else {
145
- logger.error(`Failed to set ${mode} mode for device: ${this.deviceName}`);
146
- }
147
- return success;
148
- }
149
-
150
- /**
151
- * Change fan speed
152
- */
153
- async changeFanSpeed(speed: number): Promise<boolean> {
154
- // const speeds = this.config.levels ?? [];
155
- // if (!speeds.includes(speed)) {
156
- // logger.debug(`Invalid speed: ${speed}. Must be one of: ${speeds.join(', ')}`);
157
- // return false;
158
- // }
159
-
160
- const [head, body] = this.buildApiDict('setLevel');
161
- body.payload.data = {
162
- levelIdx: 0,
163
- levelType: 'wind',
164
- manualSpeedLevel: speed
165
- };
166
-
167
- const [response, status] = await this.callApi(
168
- '/cloud/v2/deviceManaged/bypassV2',
169
- 'post',
170
- body,
171
- head
172
- );
173
-
174
- const success = this.checkResponse([response, status], 'changeFanSpeed');
175
- if (success) {
176
- this.details.manualSpeedLevel = speed;
177
- } else {
178
- logger.debug('Error setting fan speed');
179
- }
180
- return success;
181
- }
182
-
183
- /**
184
- * Set normal mode
185
- */
186
- async normal_mode(): Promise<boolean> {
187
- logger.debug(`Setting normal mode for device: ${this.deviceName}`);
188
- return this.mode_toggle('normal');
41
+ this.deviceStatus = data.powerSwitch === 1 ? 'on' : 'off';
42
+ this.details = { ...data };
43
+ this.mode = data.workMode ?? 'normal';
44
+ // A status poll cannot identify the timer. It may have been replaced in
45
+ // the app, so never carry an older command's id into cancellation.
46
+ this.timerId = undefined;
47
+ this.updateTimerState(data.timerRemain ?? 0, this.deviceStatus === 'on' ? 'off' : 'on');
48
+ return true;
189
49
  }
190
50
 
191
- /**
192
- * Set manual mode - adapter to set mode to normal
193
- */
194
- async manual_mode(): Promise<boolean> {
195
- logger.debug(`Setting manual mode for device: ${this.deviceName}`);
196
- return this.normal_mode();
51
+ async toggleSwitch(toggle: boolean): Promise<boolean> {
52
+ if (typeof toggle !== 'boolean') return false;
53
+ if (!await this.request('setSwitch', { powerSwitch: Number(toggle), switchIdx: 0 })) return false;
54
+ this.deviceStatus = toggle ? 'on' : 'off';
55
+ this.details.powerSwitch = Number(toggle);
56
+ return true;
197
57
  }
198
58
 
199
- /**
200
- * Set advanced sleep mode
201
- */
202
- async advanced_sleep_mode(): Promise<boolean> {
203
- logger.debug(`Setting advanced sleep mode for device: ${this.deviceName}`);
204
- return this.mode_toggle('advancedSleep');
205
- }
59
+ async turnOn(): Promise<boolean> { return this.toggleSwitch(true); }
60
+ async turnOff(): Promise<boolean> { return this.toggleSwitch(false); }
206
61
 
207
- /**
208
- * Set sleep mode - adapter for advanced sleep mode
209
- */
210
- async sleep_mode(): Promise<boolean> {
211
- logger.debug(`Setting sleep mode for device: ${this.deviceName}`);
212
- return this.advanced_sleep_mode();
62
+ async mode_toggle(mode: string): Promise<boolean> {
63
+ const apiMode = mode === 'sleep' ? 'advancedSleep' : mode === 'manual' ? 'normal' : mode;
64
+ if (!this.towerModes.includes(apiMode as typeof this.towerModes[number])) return false;
65
+ if (apiMode === 'off') return this.turnOff();
66
+ if (!await this.request('setTowerFanMode', { workMode: apiMode })) return false;
67
+ this.mode = apiMode;
68
+ return true;
213
69
  }
214
70
 
215
- /**
216
- * Override setMode to handle tower fan specific modes
217
- */
218
- async setMode(mode: string): Promise<boolean> {
219
- return this.mode_toggle(mode);
220
- }
71
+ async setMode(mode: string): Promise<boolean> { return this.mode_toggle(mode); }
72
+ async normal_mode(): Promise<boolean> { return this.setMode('normal'); }
73
+ async manual_mode(): Promise<boolean> { return this.normal_mode(); }
74
+ async advanced_sleep_mode(): Promise<boolean> { return this.setMode('sleep'); }
75
+ async sleep_mode(): Promise<boolean> { return this.advanced_sleep_mode(); }
221
76
 
222
- /**
223
- * Get current speed
224
- */
225
- get speed(): number {
226
- return this.details.manualSpeedLevel || 0;
77
+ async changeFanSpeed(speed: number): Promise<boolean> {
78
+ if (!Number.isInteger(speed) || !(this.config.levels ?? []).includes(speed)) return false;
79
+ if (!await this.request('setLevel', { levelIdx: 0, levelType: 'wind', manualSpeedLevel: speed })) return false;
80
+ this.details.manualSpeedLevel = speed;
81
+ return true;
227
82
  }
228
83
 
229
- /**
230
- * Get display status
231
- */
232
- get displayState(): boolean {
233
- return Boolean(this.details.screenState);
84
+ async setOscillation(toggle: boolean): Promise<boolean> {
85
+ if (typeof toggle !== 'boolean') return false;
86
+ if (!await this.request('setOscillationSwitch', { oscillationSwitch: Number(toggle) })) return false;
87
+ this.details.oscillationSwitch = Number(toggle);
88
+ this.details.oscillationState = Number(toggle);
89
+ return true;
234
90
  }
235
91
 
236
- /**
237
- * Get oscillation status
238
- */
239
- get oscillationState(): boolean {
240
- return Boolean(this.details.oscillationState);
241
- }
92
+ async setOscillationOn(): Promise<boolean> { return this.setOscillation(true); }
93
+ async setOscillationOff(): Promise<boolean> { return this.setOscillation(false); }
242
94
 
243
- /**
244
- * Get mute status
245
- */
246
- get muteState(): boolean {
247
- return Boolean(this.details.muteState);
95
+ async setDisplay(enabled: boolean): Promise<boolean> {
96
+ if (typeof enabled !== 'boolean') return false;
97
+ if (!await this.request('setDisplay', { screenSwitch: Number(enabled) })) return false;
98
+ this.details.screenSwitch = Number(enabled);
99
+ this.details.screenState = Number(enabled);
100
+ return true;
248
101
  }
249
102
 
250
- /**
251
- * Get temperature (in tenths of a degree)
252
- */
253
- get temperature(): number {
254
- return this.details.temperature || 0;
103
+ async setMute(enabled: boolean): Promise<boolean> {
104
+ if (typeof enabled !== 'boolean') return false;
105
+ if (!await this.request('setMuteSwitch', { muteSwitch: Number(enabled) })) return false;
106
+ this.details.muteSwitch = Number(enabled);
107
+ this.details.muteState = Number(enabled);
108
+ return true;
255
109
  }
256
110
 
257
- /**
258
- * Get humidity percentage
259
- */
260
- get humidity(): number {
261
- return this.details.humidity || 0;
111
+ /** Public timer duration remains hours, like the other tsvesync devices. */
112
+ async setTimer(hours: number, action: string = this.deviceStatus === 'on' ? 'off' : 'on'): Promise<boolean> {
113
+ const seconds = Math.round(hours * 3600);
114
+ if (!Number.isFinite(hours) || hours <= 0 || hours > 12 || seconds < 1 || !['on', 'off'].includes(action)) return false;
115
+ const result = await this.request('setTimer', { action, total: seconds });
116
+ if (!result || !Number.isInteger(result.id)) return false;
117
+ this.timerId = result.id;
118
+ this.updateTimerState(seconds, action);
119
+ return true;
262
120
  }
263
121
 
264
- /**
265
- * Get thermal comfort level
266
- */
267
- get thermalComfort(): number {
268
- return this.details.thermalComfort || 0;
122
+ async getTimer(): Promise<boolean> {
123
+ const result = await this.request('getTimer');
124
+ if (!result || !('timers' in result) || (result.timers !== null && !Array.isArray(result.timers))) return false;
125
+ if (result.timers === null || result.timers.length === 0) {
126
+ this.updateTimerState(0, 'off');
127
+ return true;
128
+ }
129
+ const timer = result.timers[0];
130
+ if (!Number.isInteger(timer.id) || !Number.isFinite(timer.remain) || timer.remain < 0 || !['on', 'off'].includes(timer.action)) return false;
131
+ this.timerId = timer.id;
132
+ this.updateTimerState(timer.remain, timer.action);
133
+ return true;
269
134
  }
270
135
 
271
- /**
272
- * Get sleep preferences
273
- */
274
- get sleepPreference(): Record<string, any> {
275
- return this.details.sleepPreference || {};
136
+ async clearTimer(): Promise<boolean> {
137
+ // Status responses include the remaining duration but not the timer id.
138
+ if (this.timerRemaining > 0 && this.timerId === undefined && !await this.getTimer()) return false;
139
+ if (this.timerId === undefined) return this.timerRemaining === 0;
140
+ if (!await this.request('clearTimer', { id: this.timerId })) return false;
141
+ this.updateTimerState(0, 'off');
142
+ return true;
276
143
  }
277
144
 
278
- /**
279
- * Get schedule count
280
- */
281
- get scheduleCount(): number {
282
- return this.details.scheduleCount || 0;
145
+ private updateTimerState(seconds: number, action: string): void {
146
+ const remaining = Number.isFinite(seconds) ? Math.max(0, seconds) : 0;
147
+ this.details.timerRemain = remaining;
148
+ this._timer = remaining > 0 ? { duration: remaining, action } : null;
149
+ this.timerDeadline = remaining > 0 ? Date.now() + remaining * 1000 : undefined;
150
+ if (remaining === 0) this.timerId = undefined;
283
151
  }
284
152
 
285
- /**
286
- * Get error code
287
- */
288
- get errorCode(): number {
289
- return this.details.errorCode || 0;
153
+ get timerRemaining(): number {
154
+ return this.timerDeadline === undefined ? 0 : Math.max(0, Math.ceil((this.timerDeadline - Date.now()) / 1000));
290
155
  }
291
156
 
292
- /**
293
- * Turn oscillation on
294
- */
295
- async setOscillationOn(): Promise<boolean> {
296
- return this.setOscillation(true);
157
+ get mode(): string { return this.details.workMode === 'advancedSleep' ? 'sleep' : this.details.workMode ?? 'normal'; }
158
+ set mode(value: string) {
159
+ this.details.workMode = value === 'sleep' ? 'advancedSleep' : value === 'manual' ? 'normal' : value;
160
+ this.details.mode = this.mode;
297
161
  }
298
-
299
- /**
300
- * Turn oscillation off
301
- */
302
- async setOscillationOff(): Promise<boolean> {
303
- return this.setOscillation(false);
162
+ get speed(): number { return this.details.manualSpeedLevel ?? this.details.fanSpeedLevel ?? 0; }
163
+ set speed(value: number) { this.details.manualSpeedLevel = value; }
164
+ get displayState(): boolean { return this.details.screenState === 1; }
165
+ get displayEnabled(): boolean { return this.details.screenSwitch === 1; }
166
+ get screenStatus(): 'on' | 'off' { return this.displayEnabled ? 'on' : 'off'; }
167
+ get oscillationState(): boolean { return this.details.oscillationState === 1; }
168
+ get oscillationEnabled(): boolean { return this.details.oscillationSwitch === 1; }
169
+ get muteState(): boolean { return this.details.muteState === 1; }
170
+ /** Raw API temperature is Fahrenheit times ten, retained for compatibility. */
171
+ get temperature(): number { return this.details.temperature ?? 0; }
172
+ get temperatureCelsius(): number | undefined {
173
+ const raw = this.details.temperature;
174
+ return typeof raw === 'number' && Number.isFinite(raw) ? (raw / 10 - 32) * 5 / 9 : undefined;
304
175
  }
176
+ get humidity(): number { return this.details.humidity ?? 0; }
177
+ get thermalComfort(): number { return this.details.thermalComfort ?? 0; }
178
+ get sleepPreference(): Record<string, any> { return this.details.sleepPreference ?? {}; }
179
+ get scheduleCount(): number { return this.details.scheduleCount ?? 0; }
180
+ get errorCode(): number { return this.details.errorCode ?? 0; }
305
181
 
306
- /**
307
- * Display device info
308
- */
309
182
  override display(): void {
310
183
  super.display();
311
184
  const info = [
@@ -319,7 +192,7 @@ export class VeSyncTowerFan extends VeSyncAirBaseV2 {
319
192
  ['Mute: ', this.muteState],
320
193
  ['Mute Switch: ', this.details.muteSwitch],
321
194
  ['Timer: ', this.timer],
322
- ['Temperature: ', this.temperature / 10, '°C'],
195
+ ['Temperature: ', this.temperatureCelsius ?? 'unknown', '°C'],
323
196
  ['Humidity: ', this.humidity, '%'],
324
197
  ['Thermal Comfort: ', this.thermalComfort],
325
198
  ['Sleep Preference: ', JSON.stringify(this.sleepPreference)],
@@ -243,25 +243,35 @@ export const fanConfig: FanConfig = {
243
243
  'LTF-F422S-KEU': {
244
244
  module: 'VeSyncTowerFan',
245
245
  // No child_lock: the LTF-F422S API neither reports nor accepts it (pyvesync parity)
246
- features: ['display', 'night_light', 'timer', 'fan_speeds', 'tower_modes'],
246
+ features: ['display', 'timer', 'fan_speeds', 'tower_modes', 'oscillation', 'temperature', 'mute'],
247
+ modes: ['normal', 'auto', 'sleep', 'turbo'],
247
248
  levels: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
248
249
  },
249
250
  'LTF-F422S-WUSR': {
250
251
  module: 'VeSyncTowerFan',
251
252
  // No child_lock: the LTF-F422S API neither reports nor accepts it (pyvesync parity)
252
- features: ['display', 'night_light', 'timer', 'fan_speeds', 'tower_modes'],
253
+ features: ['display', 'timer', 'fan_speeds', 'tower_modes', 'oscillation', 'temperature', 'mute'],
254
+ modes: ['normal', 'auto', 'sleep', 'turbo'],
255
+ levels: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
256
+ },
257
+ 'LTF-F422S-WJP': {
258
+ module: 'VeSyncTowerFan',
259
+ features: ['display', 'timer', 'fan_speeds', 'tower_modes', 'oscillation', 'temperature', 'mute'],
260
+ modes: ['normal', 'auto', 'sleep', 'turbo'],
253
261
  levels: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
254
262
  },
255
263
  'LTF-F422_WJP': {
256
264
  module: 'VeSyncTowerFan',
257
265
  // No child_lock: the LTF-F422S API neither reports nor accepts it (pyvesync parity)
258
- features: ['display', 'night_light', 'timer', 'fan_speeds', 'tower_modes'],
266
+ features: ['display', 'timer', 'fan_speeds', 'tower_modes', 'oscillation', 'temperature', 'mute'],
267
+ modes: ['normal', 'auto', 'sleep', 'turbo'],
259
268
  levels: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
260
269
  },
261
270
  'LTF-F422S-WUS': {
262
271
  module: 'VeSyncTowerFan',
263
272
  // No child_lock: the LTF-F422S API neither reports nor accepts it (pyvesync parity)
264
- features: ['display', 'night_light', 'timer', 'fan_speeds', 'tower_modes'],
273
+ features: ['display', 'timer', 'fan_speeds', 'tower_modes', 'oscillation', 'temperature', 'mute'],
274
+ modes: ['normal', 'auto', 'sleep', 'turbo'],
265
275
  levels: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
266
276
  },
267
277