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.
- package/LICENSE +21 -0
- package/README.md +328 -0
- package/config.schema.json +247 -0
- package/dist/heaterCoolerAccessory.d.ts +31 -0
- package/dist/heaterCoolerAccessory.js +223 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +7 -0
- package/dist/platform.d.ts +26 -0
- package/dist/platform.js +161 -0
- package/dist/settings.d.ts +81 -0
- package/dist/settings.js +66 -0
- package/dist/settings.test.d.ts +1 -0
- package/dist/settings.test.js +152 -0
- package/dist/thermostatAccessory.d.ts +27 -0
- package/dist/thermostatAccessory.js +238 -0
- package/dist/tuyaApi.d.ts +19 -0
- package/dist/tuyaApi.js +233 -0
- package/dist/tuyaApi.test.d.ts +1 -0
- package/dist/tuyaApi.test.js +294 -0
- package/package.json +59 -0
|
@@ -0,0 +1,294 @@
|
|
|
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
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
7
|
+
const axios_1 = __importDefault(require("axios"));
|
|
8
|
+
const tuyaApi_1 = require("./tuyaApi");
|
|
9
|
+
// Mock axios
|
|
10
|
+
jest.mock('axios');
|
|
11
|
+
const mockedAxios = axios_1.default;
|
|
12
|
+
// Mock logger
|
|
13
|
+
const mockLogger = {
|
|
14
|
+
info: jest.fn(),
|
|
15
|
+
warn: jest.fn(),
|
|
16
|
+
error: jest.fn(),
|
|
17
|
+
debug: jest.fn(),
|
|
18
|
+
};
|
|
19
|
+
const testOptions = {
|
|
20
|
+
accessId: 'test_access_id',
|
|
21
|
+
accessKey: 'test_access_key',
|
|
22
|
+
endpoint: 'https://openapi.tuyaus.com',
|
|
23
|
+
username: 'test@example.com',
|
|
24
|
+
password: 'test_password',
|
|
25
|
+
countryCode: 1,
|
|
26
|
+
};
|
|
27
|
+
describe('TuyaApi', () => {
|
|
28
|
+
let api;
|
|
29
|
+
let mockAxiosInstance;
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
jest.clearAllMocks();
|
|
32
|
+
// Create mock axios instance
|
|
33
|
+
mockAxiosInstance = {
|
|
34
|
+
get: jest.fn(),
|
|
35
|
+
post: jest.fn(),
|
|
36
|
+
request: jest.fn(),
|
|
37
|
+
};
|
|
38
|
+
mockedAxios.create.mockReturnValue(mockAxiosInstance);
|
|
39
|
+
api = new tuyaApi_1.TuyaApi(testOptions, mockLogger);
|
|
40
|
+
});
|
|
41
|
+
describe('constructor', () => {
|
|
42
|
+
it('should create axios instance with correct config', () => {
|
|
43
|
+
expect(mockedAxios.create).toHaveBeenCalledWith({
|
|
44
|
+
baseURL: testOptions.endpoint,
|
|
45
|
+
timeout: 10000,
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
describe('authenticate', () => {
|
|
50
|
+
it('should request token and authenticate with user credentials', async () => {
|
|
51
|
+
// Mock token response
|
|
52
|
+
mockAxiosInstance.get.mockResolvedValueOnce({
|
|
53
|
+
data: {
|
|
54
|
+
success: true,
|
|
55
|
+
result: {
|
|
56
|
+
access_token: 'temp_token',
|
|
57
|
+
refresh_token: 'temp_refresh',
|
|
58
|
+
expire_time: 7200,
|
|
59
|
+
uid: 'temp_uid',
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
// Mock auth response
|
|
64
|
+
mockAxiosInstance.post.mockResolvedValueOnce({
|
|
65
|
+
data: {
|
|
66
|
+
success: true,
|
|
67
|
+
result: {
|
|
68
|
+
access_token: 'final_token',
|
|
69
|
+
refresh_token: 'final_refresh',
|
|
70
|
+
expire_time: 7200,
|
|
71
|
+
uid: 'user_uid',
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
await api.authenticate();
|
|
76
|
+
// Verify token request
|
|
77
|
+
expect(mockAxiosInstance.get).toHaveBeenCalledTimes(1);
|
|
78
|
+
const tokenCall = mockAxiosInstance.get.mock.calls[0];
|
|
79
|
+
expect(tokenCall[0]).toBe('/v1.0/token?grant_type=1');
|
|
80
|
+
// Verify auth request
|
|
81
|
+
expect(mockAxiosInstance.post).toHaveBeenCalledTimes(1);
|
|
82
|
+
const authCall = mockAxiosInstance.post.mock.calls[0];
|
|
83
|
+
expect(authCall[0]).toBe('/v1.0/iot-01/associated-users/actions/authorized-login');
|
|
84
|
+
// Verify password is MD5 hashed
|
|
85
|
+
const authBody = authCall[1];
|
|
86
|
+
const expectedPasswordHash = crypto_1.default
|
|
87
|
+
.createHash('md5')
|
|
88
|
+
.update(testOptions.password)
|
|
89
|
+
.digest('hex');
|
|
90
|
+
expect(authBody.password).toBe(expectedPasswordHash);
|
|
91
|
+
expect(authBody.username).toBe(testOptions.username);
|
|
92
|
+
expect(authBody.country_code).toBe(testOptions.countryCode.toString());
|
|
93
|
+
expect(mockLogger.info).toHaveBeenCalledWith('Successfully authenticated with Tuya API');
|
|
94
|
+
});
|
|
95
|
+
it('should throw error on token request failure', async () => {
|
|
96
|
+
mockAxiosInstance.get.mockResolvedValueOnce({
|
|
97
|
+
data: {
|
|
98
|
+
success: false,
|
|
99
|
+
msg: 'Invalid credentials',
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
await expect(api.authenticate()).rejects.toThrow('Token request failed: Invalid credentials');
|
|
103
|
+
});
|
|
104
|
+
it('should throw error on auth request failure', async () => {
|
|
105
|
+
mockAxiosInstance.get.mockResolvedValueOnce({
|
|
106
|
+
data: {
|
|
107
|
+
success: true,
|
|
108
|
+
result: {
|
|
109
|
+
access_token: 'temp_token',
|
|
110
|
+
refresh_token: 'temp_refresh',
|
|
111
|
+
expire_time: 7200,
|
|
112
|
+
uid: 'temp_uid',
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
mockAxiosInstance.post.mockResolvedValueOnce({
|
|
117
|
+
data: {
|
|
118
|
+
success: false,
|
|
119
|
+
msg: 'User not found',
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
await expect(api.authenticate()).rejects.toThrow('Authentication failed: User not found');
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
describe('getDeviceStatus', () => {
|
|
126
|
+
beforeEach(async () => {
|
|
127
|
+
// Setup authenticated state
|
|
128
|
+
mockAxiosInstance.get.mockResolvedValueOnce({
|
|
129
|
+
data: {
|
|
130
|
+
success: true,
|
|
131
|
+
result: {
|
|
132
|
+
access_token: 'test_token',
|
|
133
|
+
refresh_token: 'test_refresh',
|
|
134
|
+
expire_time: 7200,
|
|
135
|
+
uid: 'test_uid',
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
mockAxiosInstance.post.mockResolvedValueOnce({
|
|
140
|
+
data: {
|
|
141
|
+
success: true,
|
|
142
|
+
result: {
|
|
143
|
+
access_token: 'final_token',
|
|
144
|
+
refresh_token: 'final_refresh',
|
|
145
|
+
expire_time: 7200,
|
|
146
|
+
uid: 'user_uid',
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
await api.authenticate();
|
|
151
|
+
});
|
|
152
|
+
it('should fetch device status', async () => {
|
|
153
|
+
const deviceId = 'test_device_id';
|
|
154
|
+
const mockStatus = [
|
|
155
|
+
{ code: 'switch', value: true },
|
|
156
|
+
{ code: 'temp_current', value: 280 },
|
|
157
|
+
{ code: 'mode', value: 'Heating_Smart' },
|
|
158
|
+
];
|
|
159
|
+
mockAxiosInstance.request.mockResolvedValueOnce({
|
|
160
|
+
data: {
|
|
161
|
+
success: true,
|
|
162
|
+
result: mockStatus,
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
const status = await api.getDeviceStatus(deviceId);
|
|
166
|
+
expect(status).toEqual(mockStatus);
|
|
167
|
+
expect(mockAxiosInstance.request).toHaveBeenCalledWith(expect.objectContaining({
|
|
168
|
+
method: 'GET',
|
|
169
|
+
url: `/v1.0/devices/${deviceId}/status`,
|
|
170
|
+
}));
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
describe('sendCommand', () => {
|
|
174
|
+
beforeEach(async () => {
|
|
175
|
+
// Setup authenticated state
|
|
176
|
+
mockAxiosInstance.get.mockResolvedValueOnce({
|
|
177
|
+
data: {
|
|
178
|
+
success: true,
|
|
179
|
+
result: {
|
|
180
|
+
access_token: 'test_token',
|
|
181
|
+
refresh_token: 'test_refresh',
|
|
182
|
+
expire_time: 7200,
|
|
183
|
+
uid: 'test_uid',
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
mockAxiosInstance.post.mockResolvedValueOnce({
|
|
188
|
+
data: {
|
|
189
|
+
success: true,
|
|
190
|
+
result: {
|
|
191
|
+
access_token: 'final_token',
|
|
192
|
+
refresh_token: 'final_refresh',
|
|
193
|
+
expire_time: 7200,
|
|
194
|
+
uid: 'user_uid',
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
await api.authenticate();
|
|
199
|
+
});
|
|
200
|
+
it('should send command to device', async () => {
|
|
201
|
+
const deviceId = 'test_device_id';
|
|
202
|
+
mockAxiosInstance.request.mockResolvedValueOnce({
|
|
203
|
+
data: {
|
|
204
|
+
success: true,
|
|
205
|
+
result: true,
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
const result = await api.sendCommand(deviceId, 'switch', true);
|
|
209
|
+
expect(result).toBe(true);
|
|
210
|
+
expect(mockAxiosInstance.request).toHaveBeenCalledWith(expect.objectContaining({
|
|
211
|
+
method: 'POST',
|
|
212
|
+
url: `/v1.0/devices/${deviceId}/commands`,
|
|
213
|
+
data: JSON.stringify({ commands: [{ code: 'switch', value: true }] }),
|
|
214
|
+
}));
|
|
215
|
+
});
|
|
216
|
+
it('should return false on command failure', async () => {
|
|
217
|
+
const deviceId = 'test_device_id';
|
|
218
|
+
mockAxiosInstance.request.mockRejectedValueOnce(new Error('Command failed'));
|
|
219
|
+
const result = await api.sendCommand(deviceId, 'switch', true);
|
|
220
|
+
expect(result).toBe(false);
|
|
221
|
+
expect(mockLogger.error).toHaveBeenCalled();
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
describe('sendCommands', () => {
|
|
225
|
+
beforeEach(async () => {
|
|
226
|
+
// Setup authenticated state
|
|
227
|
+
mockAxiosInstance.get.mockResolvedValueOnce({
|
|
228
|
+
data: {
|
|
229
|
+
success: true,
|
|
230
|
+
result: {
|
|
231
|
+
access_token: 'test_token',
|
|
232
|
+
refresh_token: 'test_refresh',
|
|
233
|
+
expire_time: 7200,
|
|
234
|
+
uid: 'test_uid',
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
mockAxiosInstance.post.mockResolvedValueOnce({
|
|
239
|
+
data: {
|
|
240
|
+
success: true,
|
|
241
|
+
result: {
|
|
242
|
+
access_token: 'final_token',
|
|
243
|
+
refresh_token: 'final_refresh',
|
|
244
|
+
expire_time: 7200,
|
|
245
|
+
uid: 'user_uid',
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
await api.authenticate();
|
|
250
|
+
});
|
|
251
|
+
it('should send multiple commands at once', async () => {
|
|
252
|
+
const deviceId = 'test_device_id';
|
|
253
|
+
const commands = [
|
|
254
|
+
{ code: 'switch', value: true },
|
|
255
|
+
{ code: 'mode', value: 'Heating_Smart' },
|
|
256
|
+
];
|
|
257
|
+
mockAxiosInstance.request.mockResolvedValueOnce({
|
|
258
|
+
data: {
|
|
259
|
+
success: true,
|
|
260
|
+
result: true,
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
const result = await api.sendCommands(deviceId, commands);
|
|
264
|
+
expect(result).toBe(true);
|
|
265
|
+
expect(mockAxiosInstance.request).toHaveBeenCalledWith(expect.objectContaining({
|
|
266
|
+
method: 'POST',
|
|
267
|
+
data: JSON.stringify({ commands }),
|
|
268
|
+
}));
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
describe('HMAC-SHA256 Signature', () => {
|
|
273
|
+
it('should generate correct signature format', () => {
|
|
274
|
+
// This tests the signature algorithm matches Tuya's requirements
|
|
275
|
+
const accessId = 'test_id';
|
|
276
|
+
const accessKey = 'test_key';
|
|
277
|
+
const timestamp = '1234567890000';
|
|
278
|
+
const method = 'GET';
|
|
279
|
+
const path = '/v1.0/token';
|
|
280
|
+
const body = '';
|
|
281
|
+
const contentHash = crypto_1.default.createHash('sha256').update(body).digest('hex');
|
|
282
|
+
const stringToSign = [method, contentHash, '', path].join('\n');
|
|
283
|
+
const signStr = accessId + timestamp + stringToSign;
|
|
284
|
+
const expectedSign = crypto_1.default
|
|
285
|
+
.createHmac('sha256', accessKey)
|
|
286
|
+
.update(signStr)
|
|
287
|
+
.digest('hex')
|
|
288
|
+
.toUpperCase();
|
|
289
|
+
// Verify the signature is 64 characters (256 bits in hex)
|
|
290
|
+
expect(expectedSign).toHaveLength(64);
|
|
291
|
+
// Verify it's uppercase hex
|
|
292
|
+
expect(expectedSign).toMatch(/^[A-F0-9]{64}$/);
|
|
293
|
+
});
|
|
294
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"private": false,
|
|
3
|
+
"displayName": "Homebridge Tuya Pool Heater",
|
|
4
|
+
"name": "homebridge-tuya-pool-heater",
|
|
5
|
+
"version": "1.0.1",
|
|
6
|
+
"description": "Homebridge plugin for Tuya-based pool heat pumps with proper temperature scaling and HeaterCooler/Thermostat support",
|
|
7
|
+
"main": "dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "rimraf ./dist && tsc",
|
|
11
|
+
"prepublishOnly": "npm run build",
|
|
12
|
+
"watch": "tsc -w",
|
|
13
|
+
"test": "jest",
|
|
14
|
+
"test:watch": "jest --watch",
|
|
15
|
+
"test:coverage": "jest --coverage"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"homebridge-plugin",
|
|
19
|
+
"tuya",
|
|
20
|
+
"pool",
|
|
21
|
+
"heat-pump",
|
|
22
|
+
"pool-heater",
|
|
23
|
+
"pool-heat-pump",
|
|
24
|
+
"thermostat",
|
|
25
|
+
"heater-cooler",
|
|
26
|
+
"smart-home",
|
|
27
|
+
"smart-life",
|
|
28
|
+
"tuya-smart"
|
|
29
|
+
],
|
|
30
|
+
"author": "bstillitano",
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"homepage": "https://github.com/bstillitano/homebridge-tuya-pool-heater#readme",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "https://github.com/bstillitano/homebridge-tuya-pool-heater.git"
|
|
36
|
+
},
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/bstillitano/homebridge-tuya-pool-heater/issues"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=18.0.0",
|
|
42
|
+
"homebridge": ">=1.6.0"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"axios": "^1.6.0"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/jest": "^30.0.0",
|
|
49
|
+
"@types/node": "^20.10.0",
|
|
50
|
+
"homebridge": "^1.7.0",
|
|
51
|
+
"jest": "^30.2.0",
|
|
52
|
+
"rimraf": "^5.0.5",
|
|
53
|
+
"ts-jest": "^29.4.6",
|
|
54
|
+
"typescript": "^5.3.0"
|
|
55
|
+
},
|
|
56
|
+
"peerDependencies": {
|
|
57
|
+
"homebridge": ">=1.6.0"
|
|
58
|
+
}
|
|
59
|
+
}
|