homebridge-tuya-pool-heater 1.0.1

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.
@@ -0,0 +1,152 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const settings_1 = require("./settings");
4
+ describe('Mode detection helpers', () => {
5
+ describe('isHeatingMode', () => {
6
+ it('should return true for Heating_Smart', () => {
7
+ expect((0, settings_1.isHeatingMode)(settings_1.TUYA_MODES.HEATING_SMART)).toBe(true);
8
+ });
9
+ it('should return true for Heating_Powerful', () => {
10
+ expect((0, settings_1.isHeatingMode)(settings_1.TUYA_MODES.HEATING_POWERFUL)).toBe(true);
11
+ });
12
+ it('should return true for Heating_Silent', () => {
13
+ expect((0, settings_1.isHeatingMode)(settings_1.TUYA_MODES.HEATING_SILENT)).toBe(true);
14
+ });
15
+ it('should return false for cooling modes', () => {
16
+ expect((0, settings_1.isHeatingMode)(settings_1.TUYA_MODES.COOLING_SMART)).toBe(false);
17
+ expect((0, settings_1.isHeatingMode)(settings_1.TUYA_MODES.COOLING_POWERFUL)).toBe(false);
18
+ expect((0, settings_1.isHeatingMode)(settings_1.TUYA_MODES.COOLING_SILENT)).toBe(false);
19
+ });
20
+ it('should return false for auto mode', () => {
21
+ expect((0, settings_1.isHeatingMode)(settings_1.TUYA_MODES.AUTO)).toBe(false);
22
+ });
23
+ it('should be case insensitive', () => {
24
+ expect((0, settings_1.isHeatingMode)('HEATING_SMART')).toBe(true);
25
+ expect((0, settings_1.isHeatingMode)('heating_smart')).toBe(true);
26
+ });
27
+ });
28
+ describe('isCoolingMode', () => {
29
+ it('should return true for Cooling_Smart', () => {
30
+ expect((0, settings_1.isCoolingMode)(settings_1.TUYA_MODES.COOLING_SMART)).toBe(true);
31
+ });
32
+ it('should return true for Cooling_Powerful', () => {
33
+ expect((0, settings_1.isCoolingMode)(settings_1.TUYA_MODES.COOLING_POWERFUL)).toBe(true);
34
+ });
35
+ it('should return true for Cooling_Silent', () => {
36
+ expect((0, settings_1.isCoolingMode)(settings_1.TUYA_MODES.COOLING_SILENT)).toBe(true);
37
+ });
38
+ it('should return false for heating modes', () => {
39
+ expect((0, settings_1.isCoolingMode)(settings_1.TUYA_MODES.HEATING_SMART)).toBe(false);
40
+ expect((0, settings_1.isCoolingMode)(settings_1.TUYA_MODES.HEATING_POWERFUL)).toBe(false);
41
+ expect((0, settings_1.isCoolingMode)(settings_1.TUYA_MODES.HEATING_SILENT)).toBe(false);
42
+ });
43
+ it('should return false for auto mode', () => {
44
+ expect((0, settings_1.isCoolingMode)(settings_1.TUYA_MODES.AUTO)).toBe(false);
45
+ });
46
+ it('should be case insensitive', () => {
47
+ expect((0, settings_1.isCoolingMode)('COOLING_SMART')).toBe(true);
48
+ expect((0, settings_1.isCoolingMode)('cooling_smart')).toBe(true);
49
+ });
50
+ });
51
+ describe('isAutoMode', () => {
52
+ it('should return true for Auto', () => {
53
+ expect((0, settings_1.isAutoMode)(settings_1.TUYA_MODES.AUTO)).toBe(true);
54
+ });
55
+ it('should return false for heating modes', () => {
56
+ expect((0, settings_1.isAutoMode)(settings_1.TUYA_MODES.HEATING_SMART)).toBe(false);
57
+ });
58
+ it('should return false for cooling modes', () => {
59
+ expect((0, settings_1.isAutoMode)(settings_1.TUYA_MODES.COOLING_SMART)).toBe(false);
60
+ });
61
+ it('should be case insensitive', () => {
62
+ expect((0, settings_1.isAutoMode)('AUTO')).toBe(true);
63
+ expect((0, settings_1.isAutoMode)('auto')).toBe(true);
64
+ expect((0, settings_1.isAutoMode)('Auto')).toBe(true);
65
+ });
66
+ });
67
+ });
68
+ describe('Temperature conversion helpers', () => {
69
+ describe('tuyaTempToCelsius', () => {
70
+ it('should divide by TEMP_SCALE (10)', () => {
71
+ expect((0, settings_1.tuyaTempToCelsius)(250)).toBe(25);
72
+ expect((0, settings_1.tuyaTempToCelsius)(200)).toBe(20);
73
+ expect((0, settings_1.tuyaTempToCelsius)(355)).toBe(35.5);
74
+ });
75
+ it('should handle zero', () => {
76
+ expect((0, settings_1.tuyaTempToCelsius)(0)).toBe(0);
77
+ });
78
+ it('should handle negative values', () => {
79
+ expect((0, settings_1.tuyaTempToCelsius)(-50)).toBe(-5);
80
+ });
81
+ });
82
+ describe('celsiusToTuyaTemp', () => {
83
+ it('should multiply by TEMP_SCALE (10)', () => {
84
+ expect((0, settings_1.celsiusToTuyaTemp)(25)).toBe(250);
85
+ expect((0, settings_1.celsiusToTuyaTemp)(20)).toBe(200);
86
+ });
87
+ it('should round to nearest integer', () => {
88
+ expect((0, settings_1.celsiusToTuyaTemp)(25.4)).toBe(254);
89
+ expect((0, settings_1.celsiusToTuyaTemp)(25.5)).toBe(255);
90
+ expect((0, settings_1.celsiusToTuyaTemp)(25.6)).toBe(256);
91
+ });
92
+ it('should handle zero', () => {
93
+ expect((0, settings_1.celsiusToTuyaTemp)(0)).toBe(0);
94
+ });
95
+ it('should handle negative values', () => {
96
+ expect((0, settings_1.celsiusToTuyaTemp)(-5)).toBe(-50);
97
+ });
98
+ });
99
+ describe('round-trip conversion', () => {
100
+ it('should preserve integer celsius values', () => {
101
+ const original = 28;
102
+ const tuya = (0, settings_1.celsiusToTuyaTemp)(original);
103
+ const back = (0, settings_1.tuyaTempToCelsius)(tuya);
104
+ expect(back).toBe(original);
105
+ });
106
+ it('should preserve values at 0.1 precision', () => {
107
+ const original = 28.5;
108
+ const tuya = (0, settings_1.celsiusToTuyaTemp)(original);
109
+ const back = (0, settings_1.tuyaTempToCelsius)(tuya);
110
+ expect(back).toBe(original);
111
+ });
112
+ });
113
+ });
114
+ describe('Constants', () => {
115
+ describe('TEMP_SCALE', () => {
116
+ it('should be 10', () => {
117
+ expect(settings_1.TEMP_SCALE).toBe(10);
118
+ });
119
+ });
120
+ describe('TEMP_RANGES', () => {
121
+ it('should have valid heating range', () => {
122
+ expect(settings_1.TEMP_RANGES.heating.min).toBeLessThan(settings_1.TEMP_RANGES.heating.max);
123
+ });
124
+ it('should have valid cooling range', () => {
125
+ expect(settings_1.TEMP_RANGES.cooling.min).toBeLessThan(settings_1.TEMP_RANGES.cooling.max);
126
+ });
127
+ it('should have valid auto range', () => {
128
+ expect(settings_1.TEMP_RANGES.auto.min).toBeLessThan(settings_1.TEMP_RANGES.auto.max);
129
+ });
130
+ });
131
+ describe('DP_CODES', () => {
132
+ it('should have all required codes', () => {
133
+ expect(settings_1.DP_CODES.SWITCH).toBe('switch');
134
+ expect(settings_1.DP_CODES.MODE).toBe('mode');
135
+ expect(settings_1.DP_CODES.TEMP_CURRENT).toBe('temp_current');
136
+ expect(settings_1.DP_CODES.SET_HEATING_TEMP).toBe('set_heating_temp');
137
+ expect(settings_1.DP_CODES.SET_COOLING_TEMP).toBe('set_cold_temp');
138
+ expect(settings_1.DP_CODES.SET_AUTO_TEMP).toBe('set_auto_temp');
139
+ });
140
+ });
141
+ describe('TUYA_MODES', () => {
142
+ it('should have all mode values', () => {
143
+ expect(settings_1.TUYA_MODES.AUTO).toBe('Auto');
144
+ expect(settings_1.TUYA_MODES.HEATING_SMART).toBe('Heating_Smart');
145
+ expect(settings_1.TUYA_MODES.HEATING_POWERFUL).toBe('Heating_Powerful');
146
+ expect(settings_1.TUYA_MODES.HEATING_SILENT).toBe('Heating_Silent');
147
+ expect(settings_1.TUYA_MODES.COOLING_SMART).toBe('Cooling_Smart');
148
+ expect(settings_1.TUYA_MODES.COOLING_POWERFUL).toBe('Cooling_Powerful');
149
+ expect(settings_1.TUYA_MODES.COOLING_SILENT).toBe('Cooling_Silent');
150
+ });
151
+ });
152
+ });
@@ -0,0 +1,27 @@
1
+ import { PlatformAccessory, CharacteristicValue } from 'homebridge';
2
+ import { TuyaPoolHeatPumpPlatform } from './platform';
3
+ import { DeviceConfig } from './settings';
4
+ export declare class ThermostatAccessory {
5
+ private service;
6
+ private readonly platform;
7
+ private readonly accessory;
8
+ private readonly deviceConfig;
9
+ private currentTemperature;
10
+ private targetTemperature;
11
+ private currentHeatingCoolingState;
12
+ private targetHeatingCoolingState;
13
+ private lastCommandTime;
14
+ private readonly commandDebounceMs;
15
+ constructor(platform: TuyaPoolHeatPumpPlatform, accessory: PlatformAccessory, deviceConfig: DeviceConfig);
16
+ private fetchInitialStatus;
17
+ private updateState;
18
+ private currentModeString;
19
+ private getTempRange;
20
+ private updateModeFromTuya;
21
+ getCurrentTemperature(): Promise<CharacteristicValue>;
22
+ getTargetTemperature(): Promise<CharacteristicValue>;
23
+ setTargetTemperature(value: CharacteristicValue): Promise<void>;
24
+ getCurrentHeatingCoolingState(): Promise<CharacteristicValue>;
25
+ getTargetHeatingCoolingState(): Promise<CharacteristicValue>;
26
+ setTargetHeatingCoolingState(value: CharacteristicValue): Promise<void>;
27
+ }
@@ -0,0 +1,238 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ThermostatAccessory = void 0;
4
+ const settings_1 = require("./settings");
5
+ class ThermostatAccessory {
6
+ service;
7
+ platform;
8
+ accessory;
9
+ deviceConfig;
10
+ // Cached state
11
+ currentTemperature = 20;
12
+ targetTemperature = 25;
13
+ currentHeatingCoolingState = 0; // OFF
14
+ targetHeatingCoolingState = 0; // OFF
15
+ // Debounce polling after commands
16
+ lastCommandTime = 0;
17
+ commandDebounceMs = 10000; // Ignore polls for 10 seconds after command
18
+ constructor(platform, accessory, deviceConfig) {
19
+ this.platform = platform;
20
+ this.accessory = accessory;
21
+ this.deviceConfig = deviceConfig;
22
+ // Set accessory information
23
+ this.accessory.getService(this.platform.Service.AccessoryInformation)
24
+ .setCharacteristic(this.platform.Characteristic.Manufacturer, 'Tuya')
25
+ .setCharacteristic(this.platform.Characteristic.Model, 'Pool Heat Pump')
26
+ .setCharacteristic(this.platform.Characteristic.SerialNumber, deviceConfig.id);
27
+ // Get or create Thermostat service
28
+ this.service = this.accessory.getService(this.platform.Service.Thermostat)
29
+ || this.accessory.addService(this.platform.Service.Thermostat);
30
+ this.service.setCharacteristic(this.platform.Characteristic.Name, deviceConfig.name);
31
+ // Set temperature display units to Celsius
32
+ this.service.setCharacteristic(this.platform.Characteristic.TemperatureDisplayUnits, this.platform.Characteristic.TemperatureDisplayUnits.CELSIUS);
33
+ // Configure CurrentTemperature
34
+ this.service.getCharacteristic(this.platform.Characteristic.CurrentTemperature)
35
+ .setProps({
36
+ minValue: -10,
37
+ maxValue: 60,
38
+ minStep: 0.1,
39
+ })
40
+ .onGet(this.getCurrentTemperature.bind(this));
41
+ // Configure TargetTemperature
42
+ this.service.getCharacteristic(this.platform.Characteristic.TargetTemperature)
43
+ .setProps({
44
+ minValue: 5,
45
+ maxValue: 55,
46
+ minStep: 1,
47
+ })
48
+ .onGet(this.getTargetTemperature.bind(this))
49
+ .onSet(this.setTargetTemperature.bind(this));
50
+ // Configure CurrentHeatingCoolingState (read-only)
51
+ this.service.getCharacteristic(this.platform.Characteristic.CurrentHeatingCoolingState)
52
+ .onGet(this.getCurrentHeatingCoolingState.bind(this));
53
+ // Configure TargetHeatingCoolingState
54
+ this.service.getCharacteristic(this.platform.Characteristic.TargetHeatingCoolingState)
55
+ .onGet(this.getTargetHeatingCoolingState.bind(this))
56
+ .onSet(this.setTargetHeatingCoolingState.bind(this));
57
+ // Register for status updates
58
+ this.platform.registerStatusCallback(this.deviceConfig.id, (status) => {
59
+ this.updateState(status);
60
+ });
61
+ // Log configured temperature ranges
62
+ this.platform.log.info(`Device ${deviceConfig.name} temperature ranges:`);
63
+ this.platform.log.info(` Heating: ${JSON.stringify(deviceConfig.heatingRange ?? 'using default')}`);
64
+ this.platform.log.info(` Cooling: ${JSON.stringify(deviceConfig.coolingRange ?? 'using default')}`);
65
+ this.platform.log.info(` Auto: ${JSON.stringify(deviceConfig.autoRange ?? 'using default')}`);
66
+ // Initial status fetch
67
+ this.fetchInitialStatus();
68
+ }
69
+ async fetchInitialStatus() {
70
+ try {
71
+ const status = await this.platform.tuyaApi.getDeviceStatus(this.deviceConfig.id);
72
+ this.updateState(status);
73
+ }
74
+ catch (error) {
75
+ this.platform.log.error('Failed to fetch initial status:', error);
76
+ }
77
+ }
78
+ updateState(status) {
79
+ // Ignore polled updates briefly after sending a command to avoid race conditions
80
+ if (Date.now() - this.lastCommandTime < this.commandDebounceMs) {
81
+ this.platform.log.debug('Ignoring polled state update (recent command sent)');
82
+ return;
83
+ }
84
+ // Process MODE first so temperature updates use correct mode
85
+ const modeDP = status.find(dp => dp.code === settings_1.DP_CODES.MODE);
86
+ if (modeDP) {
87
+ this.updateModeFromTuya(modeDP.value);
88
+ }
89
+ for (const dp of status) {
90
+ switch (dp.code) {
91
+ case settings_1.DP_CODES.SWITCH:
92
+ if (!dp.value) {
93
+ this.currentHeatingCoolingState = this.platform.Characteristic.CurrentHeatingCoolingState.OFF;
94
+ this.targetHeatingCoolingState = this.platform.Characteristic.TargetHeatingCoolingState.OFF;
95
+ this.service.updateCharacteristic(this.platform.Characteristic.CurrentHeatingCoolingState, this.currentHeatingCoolingState);
96
+ this.service.updateCharacteristic(this.platform.Characteristic.TargetHeatingCoolingState, this.targetHeatingCoolingState);
97
+ }
98
+ break;
99
+ case settings_1.DP_CODES.TEMP_CURRENT:
100
+ this.currentTemperature = (0, settings_1.tuyaTempToCelsius)(dp.value);
101
+ this.service.updateCharacteristic(this.platform.Characteristic.CurrentTemperature, this.currentTemperature);
102
+ break;
103
+ case settings_1.DP_CODES.SET_HEATING_TEMP:
104
+ if ((0, settings_1.isHeatingMode)(this.currentModeString)) {
105
+ this.targetTemperature = (0, settings_1.tuyaTempToCelsius)(dp.value);
106
+ this.service.updateCharacteristic(this.platform.Characteristic.TargetTemperature, this.targetTemperature);
107
+ }
108
+ break;
109
+ case settings_1.DP_CODES.SET_COOLING_TEMP:
110
+ if ((0, settings_1.isCoolingMode)(this.currentModeString)) {
111
+ this.targetTemperature = (0, settings_1.tuyaTempToCelsius)(dp.value);
112
+ this.service.updateCharacteristic(this.platform.Characteristic.TargetTemperature, this.targetTemperature);
113
+ }
114
+ break;
115
+ case settings_1.DP_CODES.SET_AUTO_TEMP:
116
+ if (this.currentModeString === settings_1.TUYA_MODES.AUTO) {
117
+ this.targetTemperature = (0, settings_1.tuyaTempToCelsius)(dp.value);
118
+ this.service.updateCharacteristic(this.platform.Characteristic.TargetTemperature, this.targetTemperature);
119
+ }
120
+ break;
121
+ case settings_1.DP_CODES.MODE:
122
+ // Already processed above
123
+ break;
124
+ }
125
+ }
126
+ }
127
+ currentModeString = 'Auto';
128
+ getTempRange() {
129
+ if ((0, settings_1.isHeatingMode)(this.currentModeString)) {
130
+ return this.deviceConfig.heatingRange ?? settings_1.TEMP_RANGES.heating;
131
+ }
132
+ else if ((0, settings_1.isCoolingMode)(this.currentModeString)) {
133
+ return this.deviceConfig.coolingRange ?? settings_1.TEMP_RANGES.cooling;
134
+ }
135
+ return this.deviceConfig.autoRange ?? settings_1.TEMP_RANGES.auto;
136
+ }
137
+ updateModeFromTuya(mode) {
138
+ this.currentModeString = mode;
139
+ if ((0, settings_1.isHeatingMode)(mode)) {
140
+ this.currentHeatingCoolingState = this.platform.Characteristic.CurrentHeatingCoolingState.HEAT;
141
+ this.targetHeatingCoolingState = this.platform.Characteristic.TargetHeatingCoolingState.HEAT;
142
+ }
143
+ else if ((0, settings_1.isCoolingMode)(mode)) {
144
+ this.currentHeatingCoolingState = this.platform.Characteristic.CurrentHeatingCoolingState.COOL;
145
+ this.targetHeatingCoolingState = this.platform.Characteristic.TargetHeatingCoolingState.COOL;
146
+ }
147
+ else {
148
+ this.currentHeatingCoolingState = this.platform.Characteristic.CurrentHeatingCoolingState.HEAT;
149
+ this.targetHeatingCoolingState = this.platform.Characteristic.TargetHeatingCoolingState.AUTO;
150
+ }
151
+ // Update temperature range based on mode
152
+ const range = this.getTempRange();
153
+ this.service.getCharacteristic(this.platform.Characteristic.TargetTemperature)
154
+ .setProps({
155
+ minValue: range.min,
156
+ maxValue: range.max,
157
+ minStep: 1,
158
+ });
159
+ // Clamp current target temp to new range
160
+ if (this.targetTemperature > range.max) {
161
+ this.targetTemperature = range.max;
162
+ this.service.updateCharacteristic(this.platform.Characteristic.TargetTemperature, this.targetTemperature);
163
+ }
164
+ else if (this.targetTemperature < range.min) {
165
+ this.targetTemperature = range.min;
166
+ this.service.updateCharacteristic(this.platform.Characteristic.TargetTemperature, this.targetTemperature);
167
+ }
168
+ this.service.updateCharacteristic(this.platform.Characteristic.CurrentHeatingCoolingState, this.currentHeatingCoolingState);
169
+ this.service.updateCharacteristic(this.platform.Characteristic.TargetHeatingCoolingState, this.targetHeatingCoolingState);
170
+ }
171
+ // Handlers
172
+ async getCurrentTemperature() {
173
+ return this.currentTemperature;
174
+ }
175
+ async getTargetTemperature() {
176
+ return this.targetTemperature;
177
+ }
178
+ async setTargetTemperature(value) {
179
+ const originalTemp = value;
180
+ // Clamp to valid range for current mode
181
+ const range = this.getTempRange();
182
+ const temp = Math.max(range.min, Math.min(range.max, originalTemp));
183
+ this.platform.log.info(`Setting target temperature: requested ${originalTemp}°C, clamped to ${temp}°C (range: ${range.min}-${range.max}, mode: ${this.currentModeString})`);
184
+ const tuyaTemp = (0, settings_1.celsiusToTuyaTemp)(temp);
185
+ // Determine which DP to use based on current mode
186
+ let dpCode = settings_1.DP_CODES.SET_HEATING_TEMP;
187
+ if ((0, settings_1.isCoolingMode)(this.currentModeString)) {
188
+ dpCode = settings_1.DP_CODES.SET_COOLING_TEMP;
189
+ }
190
+ else if (!(0, settings_1.isHeatingMode)(this.currentModeString)) {
191
+ // Auto mode (not heating and not cooling)
192
+ dpCode = settings_1.DP_CODES.SET_AUTO_TEMP;
193
+ }
194
+ this.lastCommandTime = Date.now();
195
+ const success = await this.platform.tuyaApi.sendCommand(this.deviceConfig.id, dpCode, tuyaTemp);
196
+ if (success) {
197
+ this.targetTemperature = temp;
198
+ }
199
+ }
200
+ async getCurrentHeatingCoolingState() {
201
+ return this.currentHeatingCoolingState;
202
+ }
203
+ async getTargetHeatingCoolingState() {
204
+ return this.targetHeatingCoolingState;
205
+ }
206
+ async setTargetHeatingCoolingState(value) {
207
+ const state = value;
208
+ this.platform.log.info(`Setting target heating/cooling state to ${state}`);
209
+ this.lastCommandTime = Date.now();
210
+ if (state === this.platform.Characteristic.TargetHeatingCoolingState.OFF) {
211
+ // Turn off
212
+ await this.platform.tuyaApi.sendCommand(this.deviceConfig.id, settings_1.DP_CODES.SWITCH, false);
213
+ this.targetHeatingCoolingState = state;
214
+ this.currentHeatingCoolingState = this.platform.Characteristic.CurrentHeatingCoolingState.OFF;
215
+ return;
216
+ }
217
+ // Turn on if needed
218
+ await this.platform.tuyaApi.sendCommand(this.deviceConfig.id, settings_1.DP_CODES.SWITCH, true);
219
+ // Set mode
220
+ let mode;
221
+ switch (state) {
222
+ case this.platform.Characteristic.TargetHeatingCoolingState.HEAT:
223
+ mode = settings_1.TUYA_MODES.HEATING_SMART;
224
+ break;
225
+ case this.platform.Characteristic.TargetHeatingCoolingState.COOL:
226
+ mode = settings_1.TUYA_MODES.COOLING_SMART;
227
+ break;
228
+ case this.platform.Characteristic.TargetHeatingCoolingState.AUTO:
229
+ default:
230
+ mode = settings_1.TUYA_MODES.AUTO;
231
+ break;
232
+ }
233
+ await this.platform.tuyaApi.sendCommand(this.deviceConfig.id, settings_1.DP_CODES.MODE, mode);
234
+ this.targetHeatingCoolingState = state;
235
+ this.currentModeString = mode; // Update immediately so temperature commands use correct DP
236
+ }
237
+ }
238
+ exports.ThermostatAccessory = ThermostatAccessory;
@@ -0,0 +1,19 @@
1
+ import { Logger } from 'homebridge';
2
+ import { PluginOptions, TuyaDeviceStatus } from './settings';
3
+ export declare class TuyaApi {
4
+ private readonly client;
5
+ private tokenInfo;
6
+ private readonly options;
7
+ private readonly log;
8
+ constructor(options: PluginOptions, log: Logger);
9
+ private sign;
10
+ private request;
11
+ authenticate(): Promise<void>;
12
+ private ensureToken;
13
+ getDeviceStatus(deviceId: string): Promise<TuyaDeviceStatus[]>;
14
+ sendCommand(deviceId: string, code: string, value: boolean | number | string): Promise<boolean>;
15
+ sendCommands(deviceId: string, commands: Array<{
16
+ code: string;
17
+ value: boolean | number | string;
18
+ }>): Promise<boolean>;
19
+ }
@@ -0,0 +1,233 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.TuyaApi = void 0;
7
+ const axios_1 = __importDefault(require("axios"));
8
+ const crypto_1 = __importDefault(require("crypto"));
9
+ class TuyaApi {
10
+ client;
11
+ tokenInfo = null;
12
+ options;
13
+ log;
14
+ constructor(options, log) {
15
+ this.options = options;
16
+ this.log = log;
17
+ this.client = axios_1.default.create({
18
+ baseURL: options.endpoint,
19
+ timeout: 10000,
20
+ });
21
+ }
22
+ // Generate HMAC-SHA256 signature
23
+ sign(method, path, query = {}, body = '', timestamp, accessToken = '') {
24
+ // Parse path and query if query is in path
25
+ let basePath = path;
26
+ const mergedQuery = { ...query };
27
+ if (path.includes('?')) {
28
+ const [p, q] = path.split('?');
29
+ basePath = p;
30
+ if (q) {
31
+ q.split('&').forEach(param => {
32
+ const [key, value] = param.split('=');
33
+ if (key && value !== undefined) {
34
+ mergedQuery[key] = value;
35
+ }
36
+ });
37
+ }
38
+ }
39
+ const contentHash = crypto_1.default.createHash('sha256').update(body).digest('hex');
40
+ const queryString = Object.keys(mergedQuery)
41
+ .sort()
42
+ .map(key => `${key}=${mergedQuery[key]}`)
43
+ .join('&');
44
+ const url = queryString ? `${basePath}?${queryString}` : basePath;
45
+ const stringToSign = [
46
+ method.toUpperCase(),
47
+ contentHash,
48
+ '',
49
+ url,
50
+ ].join('\n');
51
+ const signStr = this.options.accessId + accessToken + timestamp + stringToSign;
52
+ return crypto_1.default
53
+ .createHmac('sha256', this.options.accessKey)
54
+ .update(signStr)
55
+ .digest('hex')
56
+ .toUpperCase();
57
+ }
58
+ // Make authenticated API request
59
+ async request(method, path, query = {}, body = {}) {
60
+ const timestamp = Date.now().toString();
61
+ const bodyStr = Object.keys(body).length > 0 ? JSON.stringify(body) : '';
62
+ const accessToken = this.tokenInfo?.accessToken || '';
63
+ const sign = this.sign(method, path, query, bodyStr, timestamp, accessToken);
64
+ const headers = {
65
+ 't': timestamp,
66
+ 'client_id': this.options.accessId,
67
+ 'sign': sign,
68
+ 'sign_method': 'HMAC-SHA256',
69
+ };
70
+ if (accessToken) {
71
+ headers['access_token'] = accessToken;
72
+ }
73
+ if (bodyStr) {
74
+ headers['Content-Type'] = 'application/json';
75
+ }
76
+ const queryString = Object.keys(query)
77
+ .sort()
78
+ .map(key => `${key}=${query[key]}`)
79
+ .join('&');
80
+ const url = queryString ? `${path}?${queryString}` : path;
81
+ try {
82
+ const response = await this.client.request({
83
+ method,
84
+ url,
85
+ headers,
86
+ data: bodyStr || undefined,
87
+ });
88
+ if (!response.data.success) {
89
+ throw new Error(`Tuya API error: ${response.data.code} - ${response.data.msg}`);
90
+ }
91
+ return response.data.result;
92
+ }
93
+ catch (error) {
94
+ if (axios_1.default.isAxiosError(error)) {
95
+ const status = error.response?.status || 'unknown';
96
+ const data = error.response?.data;
97
+ const errorMsg = data?.msg || error.message || 'Unknown error';
98
+ this.log.error(`Tuya API request failed: [${status}] ${errorMsg}`);
99
+ if (data) {
100
+ this.log.debug('Response data:', JSON.stringify(data));
101
+ }
102
+ throw new Error(`Tuya API request failed: [${status}] ${errorMsg}`);
103
+ }
104
+ const errMsg = error instanceof Error ? error.message : String(error);
105
+ this.log.error('Tuya API request failed:', errMsg);
106
+ throw new Error(`Tuya API request failed: ${errMsg}`);
107
+ }
108
+ }
109
+ // Get access token using user credentials
110
+ async authenticate() {
111
+ this.log.info('Authenticating with Tuya API...');
112
+ try {
113
+ // First, get a token using client credentials
114
+ const timestamp = Date.now().toString();
115
+ const path = '/v1.0/token?grant_type=1';
116
+ const sign = this.sign('GET', path, {}, '', timestamp);
117
+ const tokenResponse = await this.client.get(path, {
118
+ headers: {
119
+ 't': timestamp,
120
+ 'client_id': this.options.accessId,
121
+ 'sign': sign,
122
+ 'sign_method': 'HMAC-SHA256',
123
+ },
124
+ });
125
+ if (!tokenResponse.data.success) {
126
+ throw new Error(`Token request failed: ${tokenResponse.data.msg}`);
127
+ }
128
+ const tempToken = tokenResponse.data.result.access_token;
129
+ // Hash password
130
+ const passwordHash = crypto_1.default
131
+ .createHash('md5')
132
+ .update(this.options.password)
133
+ .digest('hex');
134
+ // Now authenticate with user credentials
135
+ const authBody = {
136
+ username: this.options.username,
137
+ password: passwordHash,
138
+ country_code: this.options.countryCode.toString(),
139
+ schema: 'smartlife',
140
+ };
141
+ const authTimestamp = Date.now().toString();
142
+ const authPath = '/v1.0/iot-01/associated-users/actions/authorized-login';
143
+ const authBodyStr = JSON.stringify(authBody);
144
+ const authSign = this.sign('POST', authPath, {}, authBodyStr, authTimestamp, tempToken);
145
+ const authResponse = await this.client.post(authPath, authBody, {
146
+ headers: {
147
+ 't': authTimestamp,
148
+ 'client_id': this.options.accessId,
149
+ 'sign': authSign,
150
+ 'sign_method': 'HMAC-SHA256',
151
+ 'access_token': tempToken,
152
+ 'Content-Type': 'application/json',
153
+ },
154
+ });
155
+ if (!authResponse.data.success) {
156
+ throw new Error(`Authentication failed: ${authResponse.data.msg}`);
157
+ }
158
+ this.tokenInfo = {
159
+ accessToken: authResponse.data.result.access_token,
160
+ refreshToken: authResponse.data.result.refresh_token,
161
+ expireTime: Date.now() + (authResponse.data.result.expire_time * 1000),
162
+ uid: authResponse.data.result.uid,
163
+ };
164
+ this.log.info('Successfully authenticated with Tuya API');
165
+ }
166
+ catch (error) {
167
+ this.log.error('Authentication failed:', error);
168
+ throw error;
169
+ }
170
+ }
171
+ // Check and refresh token if needed
172
+ async ensureToken() {
173
+ if (!this.tokenInfo) {
174
+ await this.authenticate();
175
+ return;
176
+ }
177
+ // Refresh if token expires in less than 5 minutes
178
+ if (Date.now() > this.tokenInfo.expireTime - 300000) {
179
+ this.log.info('Refreshing Tuya API token...');
180
+ try {
181
+ const result = await this.request('GET', `/v1.0/token/${this.tokenInfo.refreshToken}`);
182
+ this.tokenInfo = {
183
+ accessToken: result.access_token,
184
+ refreshToken: result.refresh_token,
185
+ expireTime: Date.now() + (result.expire_time * 1000),
186
+ uid: result.uid,
187
+ };
188
+ this.log.info('Token refreshed successfully');
189
+ }
190
+ catch {
191
+ // If refresh fails, re-authenticate
192
+ this.log.warn('Token refresh failed, re-authenticating...');
193
+ this.tokenInfo = null;
194
+ await this.authenticate();
195
+ }
196
+ }
197
+ }
198
+ // Get device status
199
+ async getDeviceStatus(deviceId) {
200
+ await this.ensureToken();
201
+ const result = await this.request('GET', `/v1.0/devices/${deviceId}/status`);
202
+ return result;
203
+ }
204
+ // Send command to device
205
+ async sendCommand(deviceId, code, value) {
206
+ await this.ensureToken();
207
+ try {
208
+ await this.request('POST', `/v1.0/devices/${deviceId}/commands`, {}, {
209
+ commands: [{ code, value }],
210
+ });
211
+ this.log.debug(`Command sent: ${code} = ${value}`);
212
+ return true;
213
+ }
214
+ catch (error) {
215
+ this.log.error(`Failed to send command ${code}:`, error);
216
+ return false;
217
+ }
218
+ }
219
+ // Send multiple commands at once
220
+ async sendCommands(deviceId, commands) {
221
+ await this.ensureToken();
222
+ try {
223
+ await this.request('POST', `/v1.0/devices/${deviceId}/commands`, {}, { commands });
224
+ this.log.debug(`Commands sent: ${JSON.stringify(commands)}`);
225
+ return true;
226
+ }
227
+ catch (error) {
228
+ this.log.error('Failed to send commands:', error);
229
+ return false;
230
+ }
231
+ }
232
+ }
233
+ exports.TuyaApi = TuyaApi;
@@ -0,0 +1 @@
1
+ export {};