homebridge-tuya-plus 3.14.0-dev.5 → 3.14.0-dev.51
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/publish-dev.yml +298 -31
- package/AGENTS.md +162 -0
- package/CLAUDE.md +1 -0
- package/Changelog.md +16 -3
- package/Readme.MD +8 -32
- package/config.schema.json +79 -79
- package/index.js +599 -358
- package/lib/AirPurifierAccessory.js +1 -1
- package/lib/BaseAccessory.js +54 -17
- package/lib/ConvectorAccessory.js +1 -1
- package/lib/CustomMultiOutletAccessory.js +2 -1
- package/lib/DoorbellAccessory.js +9 -16
- package/lib/GarageDoorAccessory.js +1 -1
- package/lib/IrrigationSystemAccessory.js +260 -120
- package/lib/MultiOutletAccessory.js +3 -2
- package/lib/OilDiffuserAccessory.js +10 -8
- package/lib/RGBTWLightAccessory.js +13 -11
- package/lib/RGBTWOutletAccessory.js +11 -9
- package/lib/SimpleBlindsAccessory.js +5 -5
- package/lib/SimpleDimmer2Accessory.js +1 -1
- package/lib/SimpleDimmerAccessory.js +10 -6
- package/lib/SimpleGarageDoorAccessory.js +114 -25
- package/lib/SimpleHeaterAccessory.js +4 -4
- package/lib/SimpleLightAccessory.js +1 -1
- package/lib/SwitchAccessory.js +3 -2
- package/lib/TWLightAccessory.js +2 -2
- package/lib/TuyaAccessory.js +75 -42
- package/lib/TuyaCloudApi.js +121 -8
- package/lib/TuyaCloudDevice.js +434 -31
- package/lib/TuyaCloudMessaging.js +34 -41
- package/lib/TuyaDevice.js +269 -0
- package/lib/TuyaDiscovery.js +25 -9
- package/lib/ValveAccessory.js +16 -12
- package/lib/VerticalBlindsWithTilt.js +27 -25
- package/lib/WledDimmerAccessory.js +46 -81
- package/package.json +5 -6
- package/scripts/cleanup-pr-tags.js +122 -0
- package/test/BaseAccessory.test.js +94 -15
- package/test/IrrigationSystemAccessory.test.js +293 -67
- package/test/MultiOutletAccessory.test.js +18 -3
- package/test/RGBTWLightAccessory.test.js +3 -3
- package/test/SimpleFanLightAccessory.test.js +3 -3
- package/test/SimpleGarageDoorAccessory.test.js +146 -19
- package/test/TuyaAccessory.protocol.test.js +76 -3
- package/test/TuyaCloudApi.test.js +110 -1
- package/test/TuyaCloudDevice.test.js +564 -2
- package/test/TuyaCloudMessaging.test.js +24 -5
- package/test/TuyaDevice.test.js +266 -0
- package/test/getCategory.test.js +1 -0
- package/test/index.test.js +271 -0
- package/test/support/mocks.js +13 -0
- package/wiki/Supported-Device-Types.md +64 -34
- package/wiki/Tuya-Cloud-Setup.md +31 -108
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Prune stale `pr-<N>` npm dist-tags created by the `/publish` PR-test-build
|
|
6
|
+
* workflow (.github/workflows/publish-dev.yml).
|
|
7
|
+
*
|
|
8
|
+
* Token-free by design: it shells out to `npm dist-tag`, which uses your
|
|
9
|
+
* existing local npm login — the same one you use to publish releases. No CI
|
|
10
|
+
* secret is involved. (npm's OIDC trusted publishing only covers `npm publish`,
|
|
11
|
+
* not dist-tag management, which is why this is a local maintainer script
|
|
12
|
+
* rather than a CI job.)
|
|
13
|
+
*
|
|
14
|
+
* Default: removes the `pr-<N>` tag for every PR that is MERGED or CLOSED, and
|
|
15
|
+
* keeps tags for PRs that are still open. PR state is read from the public
|
|
16
|
+
* GitHub API (unauthenticated).
|
|
17
|
+
*
|
|
18
|
+
* Usage:
|
|
19
|
+
* npm run cleanup:pr-tags # remove tags for merged/closed PRs
|
|
20
|
+
* npm run cleanup:pr-tags -- --dry-run # preview only, change nothing
|
|
21
|
+
* npm run cleanup:pr-tags -- --all # remove ALL pr-* tags (incl. open)
|
|
22
|
+
*
|
|
23
|
+
* Note: this removes the dist-tag pointer only. The underlying prerelease
|
|
24
|
+
* versions stay published (npm disallows unpublishing after 72h), but they sort
|
|
25
|
+
* below `latest`/`dev` and are never installed by default.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const { execFileSync } = require('child_process');
|
|
29
|
+
const path = require('path');
|
|
30
|
+
|
|
31
|
+
const pkgJson = require(path.join(__dirname, '..', 'package.json'));
|
|
32
|
+
const PKG = pkgJson.name;
|
|
33
|
+
const NPM = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
34
|
+
|
|
35
|
+
const args = process.argv.slice(2);
|
|
36
|
+
const DRY_RUN = args.includes('--dry-run') || args.includes('-n');
|
|
37
|
+
const ALL = args.includes('--all');
|
|
38
|
+
|
|
39
|
+
// Derive "owner/repo" from package.json's repository URL.
|
|
40
|
+
function repoSlug() {
|
|
41
|
+
const url = (pkgJson.repository && pkgJson.repository.url) || '';
|
|
42
|
+
const m = url.match(/github\.com[/:]([^/]+)\/(.+?)(?:\.git)?$/i);
|
|
43
|
+
if (!m) throw new Error(`Cannot parse a GitHub repo from repository.url: "${url}"`);
|
|
44
|
+
return `${m[1]}/${m[2]}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Parse `npm dist-tag ls <pkg>` output ("pr-57: 3.14.0-pr.57.3") into pr-* tags.
|
|
48
|
+
function listPrTags() {
|
|
49
|
+
let out;
|
|
50
|
+
try {
|
|
51
|
+
out = execFileSync(NPM, ['dist-tag', 'ls', PKG], { encoding: 'utf8' });
|
|
52
|
+
} catch (err) {
|
|
53
|
+
throw new Error(`Failed to list dist-tags for ${PKG}: ${err.message}`);
|
|
54
|
+
}
|
|
55
|
+
const tags = [];
|
|
56
|
+
for (const line of out.split('\n')) {
|
|
57
|
+
const m = line.match(/^(pr-(\d+)):\s*(.+)$/);
|
|
58
|
+
if (m) tags.push({ tag: m[1], pr: Number(m[2]), version: m[3].trim() });
|
|
59
|
+
}
|
|
60
|
+
return tags;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function prState(slug, num) {
|
|
64
|
+
const res = await fetch(`https://api.github.com/repos/${slug}/pulls/${num}`, {
|
|
65
|
+
headers: { Accept: 'application/vnd.github+json', 'User-Agent': `${PKG}-cleanup` },
|
|
66
|
+
});
|
|
67
|
+
if (res.status === 404) return 'unknown';
|
|
68
|
+
if (!res.ok) throw new Error(`GitHub API responded ${res.status}`);
|
|
69
|
+
const data = await res.json();
|
|
70
|
+
return data.merged ? 'merged' : data.state; // 'merged' | 'open' | 'closed'
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function main() {
|
|
74
|
+
const slug = repoSlug();
|
|
75
|
+
const tags = listPrTags();
|
|
76
|
+
if (tags.length === 0) {
|
|
77
|
+
console.log(`No pr-* dist-tags on ${PKG}. Nothing to do.`);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
console.log(`Found ${tags.length} pr-* tag(s) on ${PKG}${DRY_RUN ? ' (dry run)' : ''}:`);
|
|
81
|
+
|
|
82
|
+
let removed = 0;
|
|
83
|
+
for (const { tag, pr, version } of tags) {
|
|
84
|
+
let remove = ALL;
|
|
85
|
+
let reason = 'forced by --all';
|
|
86
|
+
if (!ALL) {
|
|
87
|
+
let state;
|
|
88
|
+
try {
|
|
89
|
+
state = await prState(slug, pr);
|
|
90
|
+
} catch (err) {
|
|
91
|
+
console.log(` • ${tag} -> ${version}: skip (could not check PR #${pr}: ${err.message})`);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
remove = state === 'merged' || state === 'closed';
|
|
95
|
+
reason = `PR #${pr} is ${state}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!remove) {
|
|
99
|
+
console.log(` • ${tag} -> ${version}: keep (${reason})`);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (DRY_RUN) {
|
|
103
|
+
console.log(` • ${tag} -> ${version}: would remove (${reason})`);
|
|
104
|
+
removed++;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
execFileSync(NPM, ['dist-tag', 'rm', PKG, tag], { encoding: 'utf8' });
|
|
109
|
+
console.log(` • ${tag} -> ${version}: removed (${reason})`);
|
|
110
|
+
removed++;
|
|
111
|
+
} catch (err) {
|
|
112
|
+
console.log(` • ${tag}: FAILED to remove (${err.message}). Are you \`npm login\`'d?`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
console.log(DRY_RUN ? `Would remove ${removed} tag(s).` : `Removed ${removed} tag(s).`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
main().catch((err) => {
|
|
120
|
+
console.error(err.message);
|
|
121
|
+
process.exit(1);
|
|
122
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const BaseAccessory = require('../lib/BaseAccessory');
|
|
4
|
-
const { makeInstance } = require('./support/mocks');
|
|
4
|
+
const { makeInstance, HAP } = require('./support/mocks');
|
|
5
5
|
|
|
6
6
|
// Minimal concrete subclass — BaseAccessory itself doesn't define _registerCharacteristics
|
|
7
7
|
class TestAccessory extends BaseAccessory {
|
|
@@ -158,10 +158,13 @@ describe('getStateAsync', () => {
|
|
|
158
158
|
expect(instance.getStateAsync(['1', '2'])).toEqual({ '1': true, '2': 50 });
|
|
159
159
|
});
|
|
160
160
|
|
|
161
|
-
test('throws when device is not connected', () => {
|
|
161
|
+
test('throws a comm-failure HapStatusError when device is not connected', () => {
|
|
162
162
|
const { instance, device } = make();
|
|
163
163
|
device.connected = false;
|
|
164
|
-
|
|
164
|
+
let err;
|
|
165
|
+
try { instance.getStateAsync('1'); } catch (e) { err = e; }
|
|
166
|
+
expect(err).toBeInstanceOf(HAP.HapStatusError);
|
|
167
|
+
expect(err.hapStatus).toBe(HAP.HAPStatus.SERVICE_COMMUNICATION_FAILURE);
|
|
165
168
|
});
|
|
166
169
|
});
|
|
167
170
|
|
|
@@ -178,10 +181,10 @@ describe('setStateAsync', () => {
|
|
|
178
181
|
expect(device.update).not.toHaveBeenCalled();
|
|
179
182
|
});
|
|
180
183
|
|
|
181
|
-
test('
|
|
184
|
+
test('rejects with a comm-failure HapStatusError when device is not connected', async () => {
|
|
182
185
|
const { instance, device } = make({ '1': false });
|
|
183
186
|
device.connected = false;
|
|
184
|
-
expect(
|
|
187
|
+
await expect(instance.setStateAsync('1', true)).rejects.toBeInstanceOf(HAP.HapStatusError);
|
|
185
188
|
expect(device.update).not.toHaveBeenCalled();
|
|
186
189
|
});
|
|
187
190
|
});
|
|
@@ -201,12 +204,29 @@ describe('setMultiStateAsync', () => {
|
|
|
201
204
|
expect(device.update).not.toHaveBeenCalled();
|
|
202
205
|
});
|
|
203
206
|
|
|
204
|
-
test('
|
|
207
|
+
test('resolves when the device accepts the writes', async () => {
|
|
208
|
+
const { instance } = make({ '1': false });
|
|
209
|
+
await expect(instance.setMultiStateAsync({ '1': true })).resolves.toBeUndefined();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('rejects with a comm-failure HapStatusError when device is not connected', async () => {
|
|
205
213
|
const { instance, device } = make({ '1': false });
|
|
206
214
|
device.connected = false;
|
|
207
|
-
expect(
|
|
215
|
+
await expect(instance.setMultiStateAsync({ '1': true, '2': 50 })).rejects.toBeInstanceOf(HAP.HapStatusError);
|
|
208
216
|
expect(device.update).not.toHaveBeenCalled();
|
|
209
217
|
});
|
|
218
|
+
|
|
219
|
+
test('rejects when the device write is not accepted (returns false)', async () => {
|
|
220
|
+
const { instance, device } = make({ '1': false });
|
|
221
|
+
device.update.mockReturnValue(false);
|
|
222
|
+
await expect(instance.setMultiStateAsync({ '1': true })).rejects.toBeInstanceOf(HAP.HapStatusError);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('awaits an async device write result (cloud) and rejects on failure', async () => {
|
|
226
|
+
const { instance, device } = make({ '1': false });
|
|
227
|
+
device.update.mockResolvedValue(false);
|
|
228
|
+
await expect(instance.setMultiStateAsync({ '1': true })).rejects.toBeInstanceOf(HAP.HapStatusError);
|
|
229
|
+
});
|
|
210
230
|
});
|
|
211
231
|
|
|
212
232
|
describe('setMultiStateLegacyAsync', () => {
|
|
@@ -217,12 +237,34 @@ describe('setMultiStateLegacyAsync', () => {
|
|
|
217
237
|
expect(device.update).toHaveBeenCalledWith({ '1': true, '3': '2' });
|
|
218
238
|
});
|
|
219
239
|
|
|
220
|
-
test('
|
|
240
|
+
test('rejects with a comm-failure HapStatusError when device is not connected', async () => {
|
|
221
241
|
const { instance, device } = make();
|
|
222
242
|
device.connected = false;
|
|
223
|
-
expect(
|
|
243
|
+
await expect(instance.setMultiStateLegacyAsync({ '1': true })).rejects.toBeInstanceOf(HAP.HapStatusError);
|
|
224
244
|
expect(device.update).not.toHaveBeenCalled();
|
|
225
245
|
});
|
|
246
|
+
|
|
247
|
+
test('rejects when the device write is not accepted (returns false)', async () => {
|
|
248
|
+
const { instance, device } = make();
|
|
249
|
+
device.update.mockReturnValue(false);
|
|
250
|
+
await expect(instance.setMultiStateLegacyAsync({ '1': true })).rejects.toBeInstanceOf(HAP.HapStatusError);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
describe('background write helpers (never throw/reject)', () => {
|
|
255
|
+
test('setStateInBackground swallows a disconnected-device failure', async () => {
|
|
256
|
+
const { instance, device } = make({ '1': false });
|
|
257
|
+
device.connected = false;
|
|
258
|
+
expect(() => instance.setStateInBackground('1', true)).not.toThrow();
|
|
259
|
+
// Give the rejected inner promise a tick to settle; it must be caught.
|
|
260
|
+
await Promise.resolve();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test('setMultiStateLegacyInBackground still dispatches the write when connected', () => {
|
|
264
|
+
const { instance, device } = make();
|
|
265
|
+
instance.setMultiStateLegacyInBackground({ '1': true });
|
|
266
|
+
expect(device.update).toHaveBeenCalledWith({ '1': true });
|
|
267
|
+
});
|
|
226
268
|
});
|
|
227
269
|
|
|
228
270
|
describe('getDividedStateAsync', () => {
|
|
@@ -231,20 +273,57 @@ describe('getDividedStateAsync', () => {
|
|
|
231
273
|
expect(instance.getDividedStateAsync('5', 10)).toBeCloseTo(220);
|
|
232
274
|
});
|
|
233
275
|
|
|
234
|
-
test('throws when state value is not finite', () => {
|
|
276
|
+
test('throws a comm-failure HapStatusError when state value is not finite', () => {
|
|
235
277
|
const { instance } = make({ '5': 'bad' });
|
|
236
|
-
|
|
278
|
+
let err;
|
|
279
|
+
try { instance.getDividedStateAsync('5', 10); } catch (e) { err = e; }
|
|
280
|
+
expect(err).toBeInstanceOf(HAP.HapStatusError);
|
|
281
|
+
expect(err.hapStatus).toBe(HAP.HAPStatus.SERVICE_COMMUNICATION_FAILURE);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
describe('getState (callback)', () => {
|
|
286
|
+
test('returns the DP value via the callback when connected', done => {
|
|
287
|
+
const { instance } = make({ '1': true });
|
|
288
|
+
instance.getState('1', (err, value) => {
|
|
289
|
+
expect(err).toBeNull();
|
|
290
|
+
expect(value).toBe(true);
|
|
291
|
+
done();
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test('invokes the callback with a comm-failure error when not connected', () => {
|
|
296
|
+
const { instance, device } = make({ '1': true });
|
|
297
|
+
device.connected = false;
|
|
298
|
+
const cb = jest.fn();
|
|
299
|
+
instance.getState('1', cb);
|
|
300
|
+
expect(cb).toHaveBeenCalledWith(expect.any(HAP.HapStatusError));
|
|
237
301
|
});
|
|
238
302
|
});
|
|
239
303
|
|
|
240
304
|
describe('setMultiState (legacy callback)', () => {
|
|
241
|
-
test('
|
|
305
|
+
test('invokes the callback without error on a successful write', () => {
|
|
306
|
+
const { instance } = make({ '1': false });
|
|
307
|
+
const cb = jest.fn();
|
|
308
|
+
instance.setMultiState({ '1': true }, cb);
|
|
309
|
+
expect(cb).toHaveBeenCalledWith(null);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test('invokes the callback with a comm-failure error when not connected', () => {
|
|
242
313
|
const { instance, device } = make({ '1': false });
|
|
243
314
|
device.connected = false;
|
|
244
315
|
const cb = jest.fn();
|
|
245
316
|
instance.setMultiState({ '1': true }, cb);
|
|
246
317
|
expect(device.update).not.toHaveBeenCalled();
|
|
247
|
-
expect(cb).toHaveBeenCalledWith();
|
|
318
|
+
expect(cb).toHaveBeenCalledWith(expect.any(HAP.HapStatusError));
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test('invokes the callback with a comm-failure error when the write is not accepted', () => {
|
|
322
|
+
const { instance, device } = make({ '1': false });
|
|
323
|
+
device.update.mockReturnValue(false);
|
|
324
|
+
const cb = jest.fn();
|
|
325
|
+
instance.setMultiState({ '1': true }, cb);
|
|
326
|
+
expect(cb).toHaveBeenCalledWith(expect.any(HAP.HapStatusError));
|
|
248
327
|
});
|
|
249
328
|
|
|
250
329
|
test('tolerates a missing callback when not connected', () => {
|
|
@@ -255,13 +334,13 @@ describe('setMultiState (legacy callback)', () => {
|
|
|
255
334
|
});
|
|
256
335
|
|
|
257
336
|
describe('setMultiStateLegacy (legacy callback)', () => {
|
|
258
|
-
test('
|
|
337
|
+
test('invokes the callback with a comm-failure error when not connected', () => {
|
|
259
338
|
const { instance, device } = make();
|
|
260
339
|
device.connected = false;
|
|
261
340
|
const cb = jest.fn();
|
|
262
341
|
instance.setMultiStateLegacy({ '1': true }, cb);
|
|
263
342
|
expect(device.update).not.toHaveBeenCalled();
|
|
264
|
-
expect(cb).toHaveBeenCalledWith();
|
|
343
|
+
expect(cb).toHaveBeenCalledWith(expect.any(HAP.HapStatusError));
|
|
265
344
|
});
|
|
266
345
|
});
|
|
267
346
|
|