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.
Files changed (53) hide show
  1. package/.github/workflows/publish-dev.yml +298 -31
  2. package/AGENTS.md +162 -0
  3. package/CLAUDE.md +1 -0
  4. package/Changelog.md +16 -3
  5. package/Readme.MD +8 -32
  6. package/config.schema.json +79 -79
  7. package/index.js +599 -358
  8. package/lib/AirPurifierAccessory.js +1 -1
  9. package/lib/BaseAccessory.js +54 -17
  10. package/lib/ConvectorAccessory.js +1 -1
  11. package/lib/CustomMultiOutletAccessory.js +2 -1
  12. package/lib/DoorbellAccessory.js +9 -16
  13. package/lib/GarageDoorAccessory.js +1 -1
  14. package/lib/IrrigationSystemAccessory.js +260 -120
  15. package/lib/MultiOutletAccessory.js +3 -2
  16. package/lib/OilDiffuserAccessory.js +10 -8
  17. package/lib/RGBTWLightAccessory.js +13 -11
  18. package/lib/RGBTWOutletAccessory.js +11 -9
  19. package/lib/SimpleBlindsAccessory.js +5 -5
  20. package/lib/SimpleDimmer2Accessory.js +1 -1
  21. package/lib/SimpleDimmerAccessory.js +10 -6
  22. package/lib/SimpleGarageDoorAccessory.js +114 -25
  23. package/lib/SimpleHeaterAccessory.js +4 -4
  24. package/lib/SimpleLightAccessory.js +1 -1
  25. package/lib/SwitchAccessory.js +3 -2
  26. package/lib/TWLightAccessory.js +2 -2
  27. package/lib/TuyaAccessory.js +75 -42
  28. package/lib/TuyaCloudApi.js +121 -8
  29. package/lib/TuyaCloudDevice.js +434 -31
  30. package/lib/TuyaCloudMessaging.js +34 -41
  31. package/lib/TuyaDevice.js +269 -0
  32. package/lib/TuyaDiscovery.js +25 -9
  33. package/lib/ValveAccessory.js +16 -12
  34. package/lib/VerticalBlindsWithTilt.js +27 -25
  35. package/lib/WledDimmerAccessory.js +46 -81
  36. package/package.json +5 -6
  37. package/scripts/cleanup-pr-tags.js +122 -0
  38. package/test/BaseAccessory.test.js +94 -15
  39. package/test/IrrigationSystemAccessory.test.js +293 -67
  40. package/test/MultiOutletAccessory.test.js +18 -3
  41. package/test/RGBTWLightAccessory.test.js +3 -3
  42. package/test/SimpleFanLightAccessory.test.js +3 -3
  43. package/test/SimpleGarageDoorAccessory.test.js +146 -19
  44. package/test/TuyaAccessory.protocol.test.js +76 -3
  45. package/test/TuyaCloudApi.test.js +110 -1
  46. package/test/TuyaCloudDevice.test.js +564 -2
  47. package/test/TuyaCloudMessaging.test.js +24 -5
  48. package/test/TuyaDevice.test.js +266 -0
  49. package/test/getCategory.test.js +1 -0
  50. package/test/index.test.js +271 -0
  51. package/test/support/mocks.js +13 -0
  52. package/wiki/Supported-Device-Types.md +64 -34
  53. 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
- expect(() => instance.getStateAsync('1')).toThrow('Not connected');
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('skips silently when device is not connected (issue #34)', () => {
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(() => instance.setStateAsync('1', true)).not.toThrow();
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('skips silently when device is not connected (issue #34)', () => {
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(() => instance.setMultiStateAsync({ '1': true, '2': 50 })).not.toThrow();
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('skips silently when device is not connected (issue #34)', () => {
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(() => instance.setMultiStateLegacyAsync({ '1': true })).not.toThrow();
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
- expect(() => instance.getDividedStateAsync('5', 10)).toThrow();
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('skips silently and invokes callback without error when not connected (issue #34)', () => {
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('skips silently and invokes callback without error when not connected (issue #34)', () => {
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