homebridge-tuya-plus 3.14.0-dev.18 → 3.14.0-dev.20

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.
@@ -55,7 +55,7 @@ class ValveAccessory extends BaseAccessory {
55
55
  service.getCharacteristic(Characteristic.SetDuration)
56
56
  .onGet(() => { if (!this.device.connected) throw this._commError(); return this.setDuration; })
57
57
  .on('change', (data) => {
58
- this.log.info('Water Valve Time Duration Set to: ' + data.newValue / 60 + ' Minutes');
58
+ this.log.debug('Water Valve Time Duration Set to: ' + data.newValue / 60 + ' Minutes');
59
59
  this.setDuration = data.newValue;
60
60
 
61
61
  if (service.getCharacteristic(Characteristic.InUse).value) {
@@ -65,7 +65,7 @@ class ValveAccessory extends BaseAccessory {
65
65
 
66
66
  clearTimeout(this.timer);
67
67
  this.timer = setTimeout(() => {
68
- this.log.info('Water Valve Timer Expired. Shutting OFF Valve');
68
+ this.log.debug('Water Valve Timer Expired. Shutting OFF Valve');
69
69
  this.setStateInBackground(this.dpPower, 0);
70
70
  service.getCharacteristic(Characteristic.Active).updateValue(0);
71
71
  service.getCharacteristic(Characteristic.InUse).updateValue(0);
@@ -89,16 +89,16 @@ class ValveAccessory extends BaseAccessory {
89
89
  service.getCharacteristic(Characteristic.RemainingDuration).updateValue(0);
90
90
  service.getCharacteristic(Characteristic.Active).updateValue(0);
91
91
  clearTimeout(this.timer);
92
- this.log.info('Water Valve is OFF!');
92
+ this.log.debug('Water Valve is OFF!');
93
93
  break;
94
94
  case 1:
95
95
  this.lastActivationTime = (new Date()).getTime();
96
96
  service.getCharacteristic(Characteristic.RemainingDuration).updateValue(this.setDuration);
97
97
  service.getCharacteristic(Characteristic.Active).updateValue(1);
98
- this.log.info('Water Valve Turning ON with Timer Set to: ' + this.setDuration / 60 + ' Minutes');
98
+ this.log.debug('Water Valve Turning ON with Timer Set to: ' + this.setDuration / 60 + ' Minutes');
99
99
  clearTimeout(this.timer);
100
100
  this.timer = setTimeout(() => {
101
- this.log.info('Water Valve Timer Expired. Shutting OFF Valve');
101
+ this.log.debug('Water Valve Timer Expired. Shutting OFF Valve');
102
102
  this.setStateInBackground(this.dpPower, 0);
103
103
  service.getCharacteristic(Characteristic.Active).updateValue(0);
104
104
  service.getCharacteristic(Characteristic.InUse).updateValue(0);
@@ -113,10 +113,10 @@ class ValveAccessory extends BaseAccessory {
113
113
  service.getCharacteristic(Characteristic.RemainingDuration).updateValue(this.setDuration);
114
114
  service.getCharacteristic(Characteristic.Active).updateValue(1);
115
115
  service.getCharacteristic(Characteristic.InUse).updateValue(1);
116
- this.log.info('Water Valve is ON After Restart. Setting Timer to: ' + this.setDuration / 60 + ' Minutes');
116
+ this.log.debug('Water Valve is ON After Restart. Setting Timer to: ' + this.setDuration / 60 + ' Minutes');
117
117
  clearTimeout(this.timer);
118
118
  this.timer = setTimeout(() => {
119
- this.log.info('Water Valve Timer Expired. Shutting OFF Valve');
119
+ this.log.debug('Water Valve Timer Expired. Shutting OFF Valve');
120
120
  this.setStateInBackground(this.dpPower, 0);
121
121
  service.getCharacteristic(Characteristic.Active).updateValue(0);
122
122
  this.lastActivationTime = null;
@@ -1,4 +1,4 @@
1
- const BaseAccessory = require('./BaseAccessory');
1
+ const SimpleDimmerAccessory = require('./SimpleDimmerAccessory');
2
2
  const http = require('http');
3
3
  const maxWledBrightness = 255;
4
4
 
@@ -6,30 +6,14 @@ const maxWledBrightness = 255;
6
6
  const wledDebounceMs = 50;
7
7
  const wledWarmupMs = 8000;
8
8
 
9
- class WledDimmerAccessory extends BaseAccessory {
10
- static getCategory(Categories) {
11
- return Categories.LIGHTBULB;
12
- }
13
-
14
- constructor(...props) {
15
- super(...props);
16
- }
17
-
18
- _registerPlatformAccessory() {
19
- const {Service} = this.hap;
20
-
21
- this.accessory.addService(Service.Lightbulb, this.device.context.name);
22
-
23
- super._registerPlatformAccessory();
24
- }
25
-
9
+ class WledDimmerAccessory extends SimpleDimmerAccessory {
26
10
  _registerCharacteristics(dps) {
27
- const {Service, Characteristic} = this.hap;
28
- const service = this.accessory.getService(Service.Lightbulb);
29
- this._checkServiceName(service, this.device.context.name);
30
-
31
- this.dpPower = this._getCustomDP(this.device.context.dpPower) || '1';
32
- this.dpBrightness = this._getCustomDP(this.device.context.dpBrightness) || this._getCustomDP(this.device.context.dp) || '2';
11
+ // The Lightbulb service, On/Brightness characteristics, default DPs and
12
+ // the device 'change' listener are all set up by SimpleDimmerAccessory.
13
+ // WLED only layers optional brightness sync and preset-effect switches
14
+ // on top, so without syncBrightnessToWled configured this behaves
15
+ // exactly like a plain SimpleDimmer.
16
+ super._registerCharacteristics(dps);
33
17
 
34
18
  // State for debounced / delayed WLED updates
35
19
  this._wledPendingBri = null;
@@ -47,32 +31,20 @@ class WledDimmerAccessory extends BaseAccessory {
47
31
  null;
48
32
  this.syncBrightnessToWled = (syncCfg && ('' + syncCfg).trim()) || null;
49
33
 
50
- this.log.info(
34
+ this.log.debug(
51
35
  '[WLED Sync] %s: syncBrightnessToWled=%s',
52
36
  this.device.context.name,
53
37
  this.syncBrightnessToWled || 'disabled'
54
38
  );
55
39
 
56
- const characteristicOn = service.getCharacteristic(Characteristic.On)
57
- .updateValue(dps[this.dpPower])
58
- .onGet(() => this.getStateAsync(this.dpPower))
59
- .onSet(value => this.setStateAsync(this.dpPower, value));
60
-
61
- const characteristicBrightness = service.getCharacteristic(Characteristic.Brightness);
62
- // Keep a reference so we can update brightness from background WLED calls
63
- this._characteristicBrightness = characteristicBrightness;
64
-
65
40
  if (this.syncBrightnessToWled) {
66
41
  // Start with 100% locally while we fetch the actual WLED brightness
67
- characteristicBrightness
68
- .updateValue(100)
69
- .onGet(() => this.getBrightness())
70
- .onSet(value => this.setBrightness(value));
42
+ this._characteristicBrightness.updateValue(100);
71
43
 
72
44
  // Force Tuya dimmer brightness to 100% on startup
73
45
  const maxTuyaBrightness = this.convertBrightnessFromHomeKitToTuya(100);
74
46
  if (dps[this.dpBrightness] !== maxTuyaBrightness) {
75
- this.log.info(
47
+ this.log.debug(
76
48
  '[WLED Sync] %s: setting Tuya brightness DP%s to max (%s)',
77
49
  this.device.context.name,
78
50
  this.dpBrightness,
@@ -80,19 +52,6 @@ class WledDimmerAccessory extends BaseAccessory {
80
52
  );
81
53
  this.setState(this.dpBrightness, maxTuyaBrightness, () => {});
82
54
  }
83
- } else {
84
- const initial = this.convertBrightnessFromTuyaToHomeKit(dps[this.dpBrightness]);
85
- this.log.debug(
86
- '%s: initial Tuya brightness DP%s=%s -> HomeKit %s%%',
87
- this.device.context.name,
88
- this.dpBrightness,
89
- dps[this.dpBrightness],
90
- initial
91
- );
92
- characteristicBrightness
93
- .updateValue(initial)
94
- .onGet(() => this.getBrightness())
95
- .onSet(value => this.setBrightness(value));
96
55
  }
97
56
 
98
57
  // Optional: create one HomeKit Switch per configured WLED preset effect.
@@ -148,7 +107,7 @@ class WledDimmerAccessory extends BaseAccessory {
148
107
  return callback();
149
108
  }
150
109
 
151
- this.log.info(
110
+ this.log.debug(
152
111
  '[WLED Sync] %s: setting preset effect "%s" (id=%s, index=%s)',
153
112
  this.device.context.name,
154
113
  effectName,
@@ -212,15 +171,17 @@ class WledDimmerAccessory extends BaseAccessory {
212
171
  .filter(service => service.UUID === HapService.Switch.UUID && service.subtype && service.subtype.startsWith('wledEffect '))
213
172
  .forEach(service => {
214
173
  if (!validEffectServices.includes(service)) {
215
- this.log.info('Removing', service.displayName);
174
+ this.log.debug('Removing', service.displayName);
216
175
  this.accessory.removeService(service);
217
176
  }
218
177
  });
219
178
  }
179
+ }
220
180
 
181
+ _registerChangeListener() {
221
182
  this.device.on('change', changes => {
222
183
  // Log full DPS changes so we can see exactly what Tuya reported
223
- this.log.info(
184
+ this.log.debug(
224
185
  '%s DPS changes: %s %s was:',
225
186
  this.device.context.name,
226
187
  JSON.stringify(changes),
@@ -229,10 +190,10 @@ class WledDimmerAccessory extends BaseAccessory {
229
190
  );
230
191
 
231
192
  if (changes.hasOwnProperty(this.dpPower)) {
232
- const oldPower = !!characteristicOn.value;
193
+ const oldPower = !!this._characteristicOn.value;
233
194
  const newPower = !!changes[this.dpPower];
234
195
 
235
- this.log.info(
196
+ this.log.debug(
236
197
  '%s power change detected on DP%s: %s -> %s',
237
198
  this.device.context.name,
238
199
  this.dpPower,
@@ -241,13 +202,13 @@ class WledDimmerAccessory extends BaseAccessory {
241
202
  );
242
203
 
243
204
  if (oldPower !== newPower) {
244
- characteristicOn.updateValue(newPower);
205
+ this._characteristicOn.updateValue(newPower);
245
206
  }
246
207
 
247
208
  // Track warmup window for WLED after power ON
248
209
  if (this.syncBrightnessToWled && newPower) {
249
210
  this._wledReadyAt = Date.now() + wledWarmupMs;
250
- this.log.info(
211
+ this.log.debug(
251
212
  '[WLED Sync] %s: power ON -> delaying WLED API calls for %sms',
252
213
  this.device.context.name,
253
214
  wledWarmupMs
@@ -271,7 +232,7 @@ class WledDimmerAccessory extends BaseAccessory {
271
232
 
272
233
  // Ignore the "forced back to 100%" update to avoid feedback loops
273
234
  if (tuyaValue === maxTuyaBrightness) {
274
- this.log.info(
235
+ this.log.debug(
275
236
  '[WLED Sync] %s: Tuya DP%s reported max brightness (%s), ignoring',
276
237
  this.device.context.name,
277
238
  this.dpBrightness,
@@ -283,7 +244,7 @@ class WledDimmerAccessory extends BaseAccessory {
283
244
  const percent = this.convertBrightnessFromTuyaToHomeKit(tuyaValue);
284
245
  const bri = Math.max(0, Math.min(maxWledBrightness, Math.round((percent / 100) * maxWledBrightness)));
285
246
 
286
- this.log.info(
247
+ this.log.debug(
287
248
  '[WLED Sync] %s: Tuya DP%s changed to %s -> %s%% -> WLED bri=%s (debounced)',
288
249
  this.device.context.name,
289
250
  this.dpBrightness,
@@ -304,14 +265,14 @@ class WledDimmerAccessory extends BaseAccessory {
304
265
  }
305
266
 
306
267
  // Reflect that brightness back into HomeKit
307
- characteristicBrightness.updateValue(percent);
268
+ this._characteristicBrightness.updateValue(percent);
308
269
 
309
270
  // After a short delay, force Tuya brightness back to 100% so it stops dimming the strip
310
271
  if (this._wledForceMaxTimeout) {
311
272
  clearTimeout(this._wledForceMaxTimeout);
312
273
  }
313
274
  this._wledForceMaxTimeout = setTimeout(() => {
314
- this.log.info(
275
+ this.log.debug(
315
276
  '[WLED Sync] %s: forcing Tuya DP%s back to max (%s) after Tuya app change',
316
277
  this.device.context.name,
317
278
  this.dpBrightness,
@@ -320,8 +281,8 @@ class WledDimmerAccessory extends BaseAccessory {
320
281
  this.setState(this.dpBrightness, maxTuyaBrightness, () => {});
321
282
  }, 5000);
322
283
  });
323
- } else if (!this.syncBrightnessToWled && changes.hasOwnProperty(this.dpBrightness) && this.convertBrightnessFromHomeKitToTuya(characteristicBrightness.value) !== changes[this.dpBrightness]) {
324
- characteristicBrightness.updateValue(this.convertBrightnessFromTuyaToHomeKit(changes[this.dpBrightness]));
284
+ } else if (!this.syncBrightnessToWled && changes.hasOwnProperty(this.dpBrightness) && this.convertBrightnessFromHomeKitToTuya(this._characteristicBrightness.value) !== changes[this.dpBrightness]) {
285
+ this._characteristicBrightness.updateValue(this.convertBrightnessFromTuyaToHomeKit(changes[this.dpBrightness]));
325
286
  }
326
287
  });
327
288
  }
@@ -331,7 +292,7 @@ class WledDimmerAccessory extends BaseAccessory {
331
292
  if (this.syncBrightnessToWled) {
332
293
  // If we already have a cached WLED brightness, return it immediately.
333
294
  if (this._lastWledPercent != null) {
334
- this.log.info(
295
+ this.log.debug(
335
296
  '[WLED Sync] %s: getBrightness() -> using cached %s%%',
336
297
  this.device.context.name,
337
298
  this._lastWledPercent
@@ -340,13 +301,14 @@ class WledDimmerAccessory extends BaseAccessory {
340
301
  }
341
302
  return 50;
342
303
  } else {
343
- return this.convertBrightnessFromTuyaToHomeKit(this.device.state[this.dpBrightness]);
304
+ // No WLED sync: behave as a plain SimpleDimmer.
305
+ return super.getBrightness();
344
306
  }
345
307
  }
346
308
 
347
309
  setBrightness(value) {
348
310
  if (this.syncBrightnessToWled) {
349
- this.log.info(
311
+ this.log.debug(
350
312
  '[WLED Sync] %s: setBrightness(%s%%)',
351
313
  this.device.context.name,
352
314
  value
@@ -358,7 +320,7 @@ class WledDimmerAccessory extends BaseAccessory {
358
320
  // Ensure Tuya stays at 100% so it doesn't interfere with WLED.
359
321
  const maxTuyaBrightness = this.convertBrightnessFromHomeKitToTuya(100);
360
322
  if (this.device.state[this.dpBrightness] !== maxTuyaBrightness) {
361
- this.log.info(
323
+ this.log.debug(
362
324
  '[WLED Sync] %s: ensuring Tuya DP%s=%s (max)',
363
325
  this.device.context.name,
364
326
  this.dpBrightness,
@@ -370,7 +332,7 @@ class WledDimmerAccessory extends BaseAccessory {
370
332
 
371
333
  // Compute desired WLED brightness once.
372
334
  const bri = Math.max(0, Math.min(maxWledBrightness, Math.round((value / 100) * maxWledBrightness)));
373
- this.log.info(
335
+ this.log.debug(
374
336
  '[WLED Sync] %s: mapped HomeKit %s%% -> WLED bri=%s (debounced background)',
375
337
  this.device.context.name,
376
338
  value,
@@ -383,7 +345,8 @@ class WledDimmerAccessory extends BaseAccessory {
383
345
  // Return to HomeKit immediately; don't wait for network I/O.
384
346
  return null;
385
347
  } else {
386
- return this.setStateAsync(this.dpBrightness, this.convertBrightnessFromHomeKitToTuya(value));
348
+ // No WLED sync: behave as a plain SimpleDimmer.
349
+ return super.setBrightness(value);
387
350
  }
388
351
  }
389
352
 
@@ -392,7 +355,7 @@ class WledDimmerAccessory extends BaseAccessory {
392
355
  const parts = String(this.syncBrightnessToWled).split(':');
393
356
  const host = parts[0];
394
357
  const port = parts[1] ? parseInt(parts[1], 10) || 80 : 80;
395
- this.log.info(
358
+ this.log.debug(
396
359
  '[WLED Sync] %s: target host=%s port=%s',
397
360
  this.device.context.name,
398
361
  host,
@@ -426,7 +389,7 @@ class WledDimmerAccessory extends BaseAccessory {
426
389
  timeout: 1000
427
390
  };
428
391
 
429
- this.log.info(
392
+ this.log.debug(
430
393
  '[WLED Sync] %s: GET http://%s:%s/json/state',
431
394
  this.device.context.name,
432
395
  target.host,
@@ -449,7 +412,7 @@ class WledDimmerAccessory extends BaseAccessory {
449
412
  );
450
413
  return done(true);
451
414
  }
452
- this.log.info(
415
+ this.log.debug(
453
416
  '[WLED Sync] %s: /json/state -> bri=%s',
454
417
  this.device.context.name,
455
418
  bri
@@ -508,7 +471,7 @@ class WledDimmerAccessory extends BaseAccessory {
508
471
  timeout: 1000
509
472
  };
510
473
 
511
- this.log.info(
474
+ this.log.debug(
512
475
  '[WLED Sync] %s: POST http://%s:%s/json/state body=%s',
513
476
  this.device.context.name,
514
477
  target.host,
@@ -520,7 +483,7 @@ class WledDimmerAccessory extends BaseAccessory {
520
483
  // Consume response and ignore body
521
484
  res.on('data', () => {});
522
485
  res.on('end', () => {
523
- this.log.info(
486
+ this.log.debug(
524
487
  '[WLED Sync] %s: WLED brightness set, HTTP %s',
525
488
  this.device.context.name,
526
489
  res.statusCode
@@ -583,7 +546,7 @@ class WledDimmerAccessory extends BaseAccessory {
583
546
  timeout: 1000
584
547
  };
585
548
 
586
- this.log.info(
549
+ this.log.debug(
587
550
  '[WLED Sync] %s: POST http://%s:%s/json/state body=%s (preset effect)',
588
551
  this.device.context.name,
589
552
  target.host,
@@ -595,7 +558,7 @@ class WledDimmerAccessory extends BaseAccessory {
595
558
  // Consume response and ignore body
596
559
  res.on('data', () => {});
597
560
  res.on('end', () => {
598
- this.log.info(
561
+ this.log.debug(
599
562
  '[WLED Sync] %s: WLED effect set to %s, HTTP %s',
600
563
  this.device.context.name,
601
564
  effectId,
@@ -659,7 +622,7 @@ class WledDimmerAccessory extends BaseAccessory {
659
622
  const warmupDelay = this._wledReadyAt && now < this._wledReadyAt ? (this._wledReadyAt - now) : 0;
660
623
  const delay = warmupDelay + wledDebounceMs;
661
624
 
662
- this.log.info(
625
+ this.log.debug(
663
626
  '[WLED Sync] %s: scheduling WLED bri=%s in %sms (%s)',
664
627
  this.device.context.name,
665
628
  brightness,
@@ -672,7 +635,7 @@ class WledDimmerAccessory extends BaseAccessory {
672
635
 
673
636
  // If the light is now off, skip the call.
674
637
  if (!this.device.state[this.dpPower]) {
675
- this.log.info(
638
+ this.log.debug(
676
639
  '[WLED Sync] %s: skipping scheduled WLED bri=%s because power is OFF',
677
640
  this.device.context.name,
678
641
  this._wledPendingBri
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "homebridge-tuya-plus",
3
- "version": "3.14.0-dev.18",
4
- "description": "A community-maintained Homebridge plugin for controlling Tuya devices locally over LAN. Includes new features, fixes, and updated device support.",
3
+ "version": "3.14.0-dev.20",
4
+ "description": "A community-maintained Homebridge plugin for controlling Tuya devices in HomeKit. LAN-first, with an optional Tuya Cloud fallback for any device the LAN can't reach.",
5
5
  "main": "index.js",
6
6
  "scripts": {
7
7
  "test": "jest",
@@ -473,46 +473,19 @@ describe('IrrigationSystemAccessory — charging state', () => {
473
473
  });
474
474
  });
475
475
 
476
- describe('IrrigationSystemAccessory — cloud mode (data-points keyed by code)', () => {
477
- // Mirrors a real Tuya "sfkzq" watering controller reached over the cloud:
478
- // valves are switch_1..4 and battery is battery_percentage.
479
- const cloudState = () => ({ switch_1: false, switch_2: false, switch_3: false, switch_4: false, battery_percentage: 99 });
480
- const cloudCtx = { cloud: true };
481
-
476
+ describe('IrrigationSystemAccessory — data-points addressed by code', () => {
477
+ // The accessory is transport-agnostic: a data-point may be a numeric id or a
478
+ // Tuya "code". The LAN+cloud TuyaDevice keeps state dual-keyed and translates
479
+ // writes, so irrigation has no cloud-specific logic it just uses the dp
480
+ // strings it's given. A device reached over the cloud commonly addresses its
481
+ // zones as switch_1.. and battery as battery_percentage.
482
482
  beforeEach(() => jest.useFakeTimers());
483
483
  afterEach(() => { jest.clearAllTimers(); jest.useRealTimers(); });
484
484
 
485
- test('default valves use Tuya codes switch_1..switch_4 (not numeric ids)', () => {
486
- const { accessory } = makeHarness(cloudState(), cloudCtx);
487
- ['switch_1', 'switch_2', 'switch_3', 'switch_4'].forEach(code => {
488
- expect(valve(accessory, code)).toBeTruthy();
489
- });
490
- expect(valve(accessory, '1')).toBeFalsy();
491
- });
492
-
493
- test('turning a zone on writes the code/value to the device', () => {
494
- const { accessory, device } = makeHarness(cloudState(), cloudCtx);
495
- valve(accessory, 'switch_1').getCharacteristic(Characteristic.Active).triggerSet(1);
496
- jest.advanceTimersByTime(500);
497
- expect(device.update).toHaveBeenCalledWith({ switch_1: true });
498
- });
499
-
500
- test('battery is read from the battery_percentage code', () => {
501
- const { accessory } = makeHarness(cloudState(), cloudCtx);
502
- const battery = accessory.getService(Service.Battery);
503
- expect(battery.getCharacteristic(Characteristic.BatteryLevel).value).toBe(99);
504
- });
505
-
506
- test('a realtime change keyed by code is reflected in HomeKit', () => {
507
- const { accessory, device } = makeHarness(cloudState(), cloudCtx);
508
- device.emitChange({ switch_2: true });
509
- expect(valve(accessory, 'switch_2').getCharacteristic(Characteristic.Active).value).toBe(Characteristic.Active.ACTIVE);
510
- });
511
-
512
- test('an explicit valve list may use custom codes', () => {
485
+ test('an explicit valve list may use string codes', () => {
513
486
  const { accessory, device } = makeHarness(
514
487
  { zone_a: false, zone_b: false, battery_percentage: 50 },
515
- { cloud: true, valves: [{ name: 'A', dp: 'zone_a' }, { name: 'B', dp: 'zone_b' }] }
488
+ { valves: [{ name: 'A', dp: 'zone_a' }, { name: 'B', dp: 'zone_b' }], dpBattery: 'battery_percentage' }
516
489
  );
517
490
  expect(valve(accessory, 'zone_a')).toBeTruthy();
518
491
  valve(accessory, 'zone_b').getCharacteristic(Characteristic.Active).triggerSet(1);
@@ -520,35 +493,57 @@ describe('IrrigationSystemAccessory — cloud mode (data-points keyed by code)',
520
493
  expect(device.update).toHaveBeenCalledWith({ zone_b: true });
521
494
  });
522
495
 
523
- test('turning a zone off still writes false when the cloud never echoed the "on"', () => {
524
- // The real cloud device never optimistically advances `state`; it only
525
- // moves when the realtime stream confirms the device. Emulate that by
526
- // making update() a no-op on state. A follow-up "off" must STILL be sent
527
- // — otherwise the valve stays open while HomeKit shows it closed (the
496
+ test('battery can be read from a code data-point', () => {
497
+ const { accessory } = makeHarness(
498
+ { zone_a: false, battery_percentage: 99 },
499
+ { valves: [{ name: 'A', dp: 'zone_a' }], dpBattery: 'battery_percentage' }
500
+ );
501
+ expect(accessory.getService(Service.Battery).getCharacteristic(Characteristic.BatteryLevel).value).toBe(99);
502
+ });
503
+
504
+ test('a device-side change keyed by code is reflected in HomeKit', () => {
505
+ const { accessory, device } = makeHarness(
506
+ { zone_a: false, zone_b: false },
507
+ { valves: [{ name: 'A', dp: 'zone_a' }, { name: 'B', dp: 'zone_b' }], noBattery: true }
508
+ );
509
+ device.emitChange({ zone_b: true });
510
+ expect(valve(accessory, 'zone_b').getCharacteristic(Characteristic.Active).value).toBe(Characteristic.Active.ACTIVE);
511
+ });
512
+
513
+ test('turning a zone off still writes false when the device never echoed the "on"', () => {
514
+ // A device (notably over the cloud) may not optimistically advance `state`;
515
+ // it only moves when the device confirms. A follow-up "off" must STILL be
516
+ // sent — otherwise the valve stays open while HomeKit shows it closed (the
528
517
  // exact "can turn on but not off" report).
529
- const { accessory, device } = makeHarness(cloudState(), { ...cloudCtx, defaultDuration: 0 });
518
+ const { accessory, device } = makeHarness(
519
+ { zone_a: false },
520
+ { valves: [{ name: 'A', dp: 'zone_a' }], noBattery: true, defaultDuration: 0 }
521
+ );
530
522
  device.update.mockImplementation(() => true); // writes never touch state
531
- const v = valve(accessory, 'switch_1');
523
+ const v = valve(accessory, 'zone_a');
532
524
 
533
525
  v.getCharacteristic(Characteristic.Active).triggerSet(1);
534
526
  jest.advanceTimersByTime(500);
535
- expect(device.update).toHaveBeenLastCalledWith({ switch_1: true });
527
+ expect(device.update).toHaveBeenLastCalledWith({ zone_a: true });
536
528
 
537
529
  v.getCharacteristic(Characteristic.Active).triggerSet(0);
538
530
  jest.advanceTimersByTime(500);
539
- expect(device.update).toHaveBeenLastCalledWith({ switch_1: false });
531
+ expect(device.update).toHaveBeenLastCalledWith({ zone_a: false });
540
532
  });
541
533
 
542
- test('master OFF closes zones the cloud has not echoed as open', () => {
543
- const { accessory, device } = makeHarness(cloudState(), { ...cloudCtx, defaultDuration: 0 });
534
+ test('master OFF closes zones the device has not echoed as open', () => {
535
+ const { accessory, device } = makeHarness(
536
+ { zone_a: false, zone_b: false },
537
+ { valves: [{ name: 'A', dp: 'zone_a' }, { name: 'B', dp: 'zone_b' }], noBattery: true, defaultDuration: 0 }
538
+ );
544
539
  device.update.mockImplementation(() => true); // writes never touch state
545
540
 
546
- valve(accessory, 'switch_1').getCharacteristic(Characteristic.Active).triggerSet(1);
547
- valve(accessory, 'switch_2').getCharacteristic(Characteristic.Active).triggerSet(1);
541
+ valve(accessory, 'zone_a').getCharacteristic(Characteristic.Active).triggerSet(1);
542
+ valve(accessory, 'zone_b').getCharacteristic(Characteristic.Active).triggerSet(1);
548
543
  jest.advanceTimersByTime(500);
549
544
 
550
545
  irrigation(accessory).getCharacteristic(Characteristic.Active).triggerSet(0);
551
546
  jest.advanceTimersByTime(500);
552
- expect(device.update).toHaveBeenLastCalledWith({ switch_1: false, switch_2: false });
547
+ expect(device.update).toHaveBeenLastCalledWith({ zone_a: false, zone_b: false });
553
548
  });
554
549
  });
@@ -138,6 +138,23 @@ describe('TuyaCloudApi — endpoints', () => {
138
138
  await expect(api.getStatus('dev')).resolves.toEqual([{code: 'switch_1', value: true}]);
139
139
  });
140
140
 
141
+ test('getShadowProperties returns the properties array (code + dp_id)', async () => {
142
+ const api = ready();
143
+ let path;
144
+ api._httpsRequest = async (method, p) => {
145
+ path = p;
146
+ return {success: true, result: {properties: [{code: 'switch_led', dp_id: 20, value: true}]}};
147
+ };
148
+ await expect(api.getShadowProperties('dev')).resolves.toEqual([{code: 'switch_led', dp_id: 20, value: true}]);
149
+ expect(path).toBe('/v2.0/cloud/thing/dev/shadow/properties');
150
+ });
151
+
152
+ test('getShadowProperties returns null (never throws) when the shadow API is unavailable', async () => {
153
+ const api = ready();
154
+ api._httpsRequest = async () => ({success: false, code: 1106, msg: 'permission deny'});
155
+ await expect(api.getShadowProperties('dev')).resolves.toBeNull();
156
+ });
157
+
141
158
  test('getDeviceInfo returns the device record (with online status)', async () => {
142
159
  const api = ready();
143
160
  let path;
@@ -127,6 +127,55 @@ describe('TuyaCloudDevice', () => {
127
127
  expect(dev.connected).toBe(false);
128
128
  });
129
129
 
130
+ test('builds the code<->dp_id map and dual-keys state from the shadow', async () => {
131
+ const props = [{code: 'switch_1', dp_id: 1, value: false}, {code: 'battery_percentage', dp_id: 46, value: 99}];
132
+ const api = makeApi();
133
+ api.getShadowProperties = jest.fn().mockResolvedValue(props);
134
+ const dev = makeDevice(api);
135
+ await dev._connect();
136
+
137
+ expect(api.getShadowProperties).toHaveBeenCalledWith('dev1');
138
+ expect(dev.codeByDpId).toEqual({'1': 'switch_1', '46': 'battery_percentage'});
139
+ expect(dev.dpIdByCode).toEqual({'switch_1': '1', 'battery_percentage': '46'});
140
+ // state is addressable by BOTH the cloud code and the numeric LAN dp id
141
+ expect(dev.state).toEqual({switch_1: false, '1': false, battery_percentage: 99, '46': 99});
142
+ });
143
+
144
+ test('update translates a numeric dp id to its cloud code', async () => {
145
+ const api = makeApi();
146
+ api.getShadowProperties = jest.fn().mockResolvedValue([{code: 'switch_1', dp_id: 1, value: false}]);
147
+ const dev = makeDevice(api);
148
+ await dev._connect();
149
+
150
+ dev.update({'1': true}); // a LAN-style numeric write
151
+ expect(api.sendCommands).toHaveBeenCalledWith('dev1', [{code: 'switch_1', value: true}]);
152
+ });
153
+
154
+ test('realtime code-only deltas are mirrored to numeric ids via the learned map', async () => {
155
+ const api = makeApi();
156
+ api.getShadowProperties = jest.fn().mockResolvedValue([{code: 'switch_1', dp_id: 1, value: false}]);
157
+ const dev = makeDevice(api);
158
+ await dev._connect();
159
+
160
+ const changes = [];
161
+ dev.on('change', c => changes.push(c));
162
+ dev._applyStatus([{code: 'switch_1', value: true}]); // code-only (as MQTT delivers)
163
+ expect(dev.state.switch_1).toBe(true);
164
+ expect(dev.state['1']).toBe(true);
165
+ expect(changes[0]).toEqual({switch_1: true, '1': true});
166
+ });
167
+
168
+ test('falls back to /status (code-only) when the shadow is unavailable', async () => {
169
+ const api = makeApi();
170
+ api.getShadowProperties = jest.fn().mockResolvedValue(null);
171
+ const dev = makeDevice(api);
172
+ await dev._connect();
173
+
174
+ expect(dev.codeByDpId).toEqual({});
175
+ expect(dev.state).toEqual({switch_1: false, battery_percentage: 99}); // code-only, no numeric mirror
176
+ expect(api.getStatus).toHaveBeenCalledWith('dev1');
177
+ });
178
+
130
179
  test('subscribes to realtime and re-reads state when the stream (re)connects', async () => {
131
180
  const api = makeApi();
132
181
  const messaging = Object.assign(new EventEmitter(), {