homebridge-tuya-plus 3.14.0-dev.4 → 3.14.0-dev.41

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 +63 -78
  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 +93 -114
  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 +78 -24
  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 +115 -7
  29. package/lib/TuyaCloudDevice.js +351 -28
  30. package/lib/TuyaCloudMessaging.js +28 -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 +122 -68
  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 +63 -9
  44. package/test/TuyaAccessory.protocol.test.js +76 -3
  45. package/test/TuyaCloudApi.test.js +110 -1
  46. package/test/TuyaCloudDevice.test.js +438 -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 +48 -30
  53. package/wiki/Tuya-Cloud-Setup.md +31 -108
@@ -80,7 +80,7 @@ class AirPurifierAccessory extends BaseAccessory {
80
80
  _addAirQualityService() {
81
81
  const {Service} = this.hap;
82
82
  const nameAirQuality = this.device.context.nameAirQuality || 'Air Quality';
83
- this.log.info('Adding air quality sensor: %s', nameAirQuality);
83
+ this.log.debug('Adding air quality sensor: %s', nameAirQuality);
84
84
  this.accessory.addService(Service.AirQualitySensor, nameAirQuality);
85
85
  }
86
86
 
@@ -24,7 +24,7 @@ class BaseAccessory {
24
24
  });
25
25
 
26
26
  this.device.once('change', () => {
27
- this.log(`Ready to handle ${this.device.context.name} (${this.device.context.type}:${this.device.context.version}) with signature ${JSON.stringify(this.device.state)}`);
27
+ this.log.debug(`Ready to handle ${this.device.context.name} (${this.device.context.type}:${this.device.context.version}) with signature ${JSON.stringify(this.device.state)}`);
28
28
 
29
29
  this._registerCharacteristics(this.device.state);
30
30
  });
@@ -65,8 +65,18 @@ class BaseAccessory {
65
65
  return typeof b === 'boolean' ? b : (typeof b === 'string' ? b.toLowerCase().trim() === 'true' : (typeof b === 'number' ? b !== 0 : df));
66
66
  }
67
67
 
68
+ // A HapStatusError to throw from a get/set handler when the value can't be
69
+ // served (device unreachable, or an unusable reading). HomeKit shows the
70
+ // accessory as "No Response". Preferred over a plain `throw new Error(...)`,
71
+ // which newer Homebridge logs a characteristic warning for before falling
72
+ // back to this same status code (SERVICE_COMMUNICATION_FAILURE, -70402).
73
+ _commError() {
74
+ const {HapStatusError, HAPStatus} = this.hap;
75
+ return new HapStatusError(HAPStatus.SERVICE_COMMUNICATION_FAILURE);
76
+ }
77
+
68
78
  getState(dp, callback) {
69
- if (!this.device.connected) return callback(true);
79
+ if (!this.device.connected) return callback(this._commError());
70
80
  const _callback = () => {
71
81
  if (Array.isArray(dp)) {
72
82
  const ret = {};
@@ -91,36 +101,37 @@ class BaseAccessory {
91
101
  //For devices like the DETA Smart Fan Controller Switch that by default set the speed as 3 the new code in the setMultiState function causes issues.
92
102
  if (!this.device.connected) {
93
103
  this.log.debug(`${this.device.context.name}: skipping write, device not connected`);
94
- return callback && callback();
104
+ return callback && callback(this._commError());
95
105
  }
96
106
  const ret = this.device.update(dps);
97
- callback && callback(!ret);
107
+ callback && callback(ret === false ? this._commError() : null);
98
108
  }
99
109
 
100
110
  setMultiState(dps, callback) {
101
111
  if (!this.device.connected) {
102
112
  this.log.debug(`${this.device.context.name}: skipping write, device not connected`);
103
- return callback && callback();
113
+ return callback && callback(this._commError());
104
114
  }
115
+ let failed = false;
105
116
  for (const dp in dps) {
106
117
  if (dps.hasOwnProperty(dp) && dps[dp] !== this.device.state[dp]){
107
- this.__ret = this.device.update({[dp.toString()] : dps[dp]});
118
+ if (this.device.update({[dp.toString()] : dps[dp]}) === false) failed = true;
108
119
  }
109
120
  }
110
- callback && callback(!this.__ret);
121
+ callback && callback(failed ? this._commError() : null);
111
122
  }
112
123
 
113
124
  getDividedState(dp, divisor, callback) {
114
125
  this.getState(dp, (err, data) => {
115
126
  if (err) return callback(err);
116
- if (!isFinite(data)) return callback(true);
127
+ if (!isFinite(data)) return callback(this._commError());
117
128
 
118
129
  callback(null, this._getDividedState(data, divisor));
119
130
  });
120
131
  }
121
132
 
122
133
  getStateAsync(dp) {
123
- if (!this.device.connected) throw new Error('Not connected');
134
+ if (!this.device.connected) throw this._commError();
124
135
  if (Array.isArray(dp)) {
125
136
  const ret = {};
126
137
  dp.forEach(p => { ret[p] = this.device.state[p]; });
@@ -133,29 +144,55 @@ class BaseAccessory {
133
144
  return this.setMultiStateAsync({[dp.toString()]: value});
134
145
  }
135
146
 
136
- setMultiStateAsync(dps) {
147
+ // Throws a "No Response" HapStatusError when the device is unreachable or the
148
+ // write is rejected, so a get/set handler that returns this promise surfaces
149
+ // the failure to HomeKit instead of silently pretending the write succeeded.
150
+ // `device.update` returns a boolean for LAN devices and an awaitable boolean
151
+ // for cloud devices (false == the command did not go through).
152
+ async setMultiStateAsync(dps) {
137
153
  if (!this.device.connected) {
138
154
  this.log.debug(`${this.device.context.name}: skipping write, device not connected`);
139
- return;
155
+ throw this._commError();
140
156
  }
157
+ const writes = [];
141
158
  for (const dp in dps) {
142
159
  if (dps.hasOwnProperty(dp) && dps[dp] !== this.device.state[dp]) {
143
- this.device.update({[dp.toString()]: dps[dp]});
160
+ writes.push(this.device.update({[dp.toString()]: dps[dp]}));
144
161
  }
145
162
  }
163
+ const results = await Promise.all(writes);
164
+ if (results.some(ok => ok === false)) throw this._commError();
146
165
  }
147
166
 
148
- setMultiStateLegacyAsync(dps) {
167
+ async setMultiStateLegacyAsync(dps) {
149
168
  if (!this.device.connected) {
150
169
  this.log.debug(`${this.device.context.name}: skipping write, device not connected`);
151
- return;
170
+ throw this._commError();
152
171
  }
153
- this.device.update(dps);
172
+ if (await this.device.update(dps) === false) throw this._commError();
173
+ }
174
+
175
+ // Non-throwing write helpers for callers that are NOT inside a HomeKit
176
+ // get/set handler — setTimeout callbacks, multi-step command sequences, etc.
177
+ // The async helpers above reject on a disconnected/failed write so HomeKit
178
+ // can show "No Response"; in a detached/background context there is no
179
+ // HomeKit request to fail and an unhandled rejection would crash Homebridge,
180
+ // so these swallow the error (the helper has already logged it).
181
+ setStateInBackground(dp, value) {
182
+ this.setStateAsync(dp, value).catch(() => {});
183
+ }
184
+
185
+ setMultiStateInBackground(dps) {
186
+ this.setMultiStateAsync(dps).catch(() => {});
187
+ }
188
+
189
+ setMultiStateLegacyInBackground(dps) {
190
+ this.setMultiStateLegacyAsync(dps).catch(() => {});
154
191
  }
155
192
 
156
193
  getDividedStateAsync(dp, divisor) {
157
194
  const data = this.getStateAsync(dp);
158
- if (!isFinite(data)) throw new Error('Invalid state');
195
+ if (!isFinite(data)) throw this._commError();
159
196
  return this._getDividedState(data, divisor);
160
197
  }
161
198
 
@@ -167,7 +204,7 @@ class BaseAccessory {
167
204
  this.colorFunction = this.device.context.colorFunction && {HSB: 'HSB', HEXHSB: 'HEXHSB'}[this.device.context.colorFunction.toUpperCase()];
168
205
  if (!this.colorFunction && value) {
169
206
  this.colorFunction = {12: 'HSB', 14: 'HEXHSB'}[value.length] || 'Unknown';
170
- if (this.colorFunction) this.log.info(`Color format for ${this.device.context.name} (${this.device.context.version}) identified as ${this.colorFunction} (length: ${value.length}).`);
207
+ if (this.colorFunction) this.log.debug(`Color format for ${this.device.context.name} (${this.device.context.version}) identified as ${this.colorFunction} (length: ${value.length}).`);
171
208
  }
172
209
  if (!this.colorFunction) {
173
210
  this.colorFunction = 'Unknown';
@@ -55,7 +55,7 @@ class ConvectorAccessory extends BaseAccessory {
55
55
  service.getCharacteristic(Characteristic.TargetHeaterCoolerState)
56
56
  .setProps({ minValue: 1, maxValue: 1, validValues: [Characteristic.TargetHeaterCoolerState.HEAT] })
57
57
  .updateValue(this._getTargetHeaterCoolerState())
58
- .onGet(() => this._getTargetHeaterCoolerState())
58
+ .onGet(() => { if (!this.device.connected) throw this._commError(); return this._getTargetHeaterCoolerState(); })
59
59
  .onSet(() => this.setStateAsync(this.dpActive, true));
60
60
 
61
61
  const characteristicCurrentTemperature = service.getCharacteristic(Characteristic.CurrentTemperature)
@@ -74,6 +74,7 @@ class CustomMultiOutletAccessory extends BaseAccessory {
74
74
  }
75
75
 
76
76
  setPower(dp, value) {
77
+ if (!this.device.connected) throw this._commError();
77
78
  if (!this._pendingPower) {
78
79
  this._pendingPower = {props: {}, resolvers: []};
79
80
  }
@@ -87,7 +88,7 @@ class CustomMultiOutletAccessory extends BaseAccessory {
87
88
  this._pendingPower.timer = setTimeout(() => {
88
89
  const {props, resolvers} = this._pendingPower;
89
90
  this._pendingPower = null;
90
- this.setMultiStateAsync(props);
91
+ this.setMultiStateInBackground(props);
91
92
  resolvers.forEach(r => r());
92
93
  }, 500);
93
94
  });
@@ -11,7 +11,8 @@ const LOCK_TIMEOUT = 5000;
11
11
 
12
12
  class DoorbellDelegate /*extends CameraStreamingDelegate*/ {
13
13
 
14
- constructor() {
14
+ constructor(log) {
15
+ this.log = log || console;
15
16
  this.cameraImage = undefined;
16
17
  }
17
18
 
@@ -27,7 +28,7 @@ class DoorbellDelegate /*extends CameraStreamingDelegate*/ {
27
28
  var body=Buffer.alloc(0);
28
29
 
29
30
  if (res.statusCode!==200) {
30
- return console.error('HTTP '+res.statusCode);
31
+ return me.log.error('Doorbell image fetch failed: HTTP %s', res.statusCode);
31
32
  }
32
33
 
33
34
  res.on('data', function(chunk) {
@@ -36,13 +37,12 @@ class DoorbellDelegate /*extends CameraStreamingDelegate*/ {
36
37
 
37
38
  res.on('end', function() {
38
39
  me.setCameraImage(body);
39
- console.log("Image stored");
40
+ me.log.debug('Doorbell image stored.');
40
41
  callback();
41
- console.log("Callback after image store complete");
42
42
  });
43
43
 
44
44
  res.on('error', function(err) {
45
- console.error(err);
45
+ me.log.error('Doorbell image fetch error:', err);
46
46
  });
47
47
  }
48
48
  );
@@ -96,12 +96,9 @@ class DoorbellAccessory extends BaseAccessory {
96
96
 
97
97
 
98
98
  this.device.on('change', (changes, state) => {
99
- //console.log(`Changes: ${JSON.stringify(changes)}, State: ${JSON.stringify(state)}`);
100
-
101
99
  if (changes.hasOwnProperty(DP_DOORBELL)) {
102
100
  const urlBase64 = changes[DP_DOORBELL];
103
101
  const strUrl = Buffer.from(urlBase64, 'base64');
104
- //console.log(`Image URL: ${strUrl}`);
105
102
 
106
103
  this.streamingDelegate.storeImage(strUrl.toString(), () => {
107
104
  service.updateCharacteristic(Characteristic.ProgrammableSwitchEvent, Characteristic.ProgrammableSwitchEvent.SINGLE_PRESS);
@@ -109,18 +106,18 @@ class DoorbellAccessory extends BaseAccessory {
109
106
  }
110
107
 
111
108
  if (changes.hasOwnProperty(DP_DOOR)) {
112
- console.log("Door button pressed");
109
+ this.log.debug('Door button pressed');
113
110
  }
114
111
 
115
112
  if (changes.hasOwnProperty(DP_GATE)) {
116
- console.log("Gate button pressed");
113
+ this.log.debug('Gate button pressed');
117
114
  }
118
115
  });
119
116
  }
120
117
 
121
118
  configureCamera() {
122
- this.streamingDelegate = new DoorbellDelegate();
123
- console.log("created streaming delegate");
119
+ this.streamingDelegate = new DoorbellDelegate(this.log);
120
+ this.log.debug('Created camera streaming delegate.');
124
121
  const options = {
125
122
  cameraStreamCount: 1,
126
123
  delegate: this.streamingDelegate,
@@ -157,12 +154,8 @@ class DoorbellAccessory extends BaseAccessory {
157
154
  },
158
155
  };
159
156
 
160
- //console.log("created options");
161
157
  const cameraController = new this.hap.CameraController(options);
162
- //console.log("created controller");
163
-
164
158
  this.accessory.configureController(cameraController);
165
- //console.log("configured controller with accessory");
166
159
  }
167
160
 
168
161
  pushDoor(callback) {
@@ -105,7 +105,7 @@ class GarageDoorAccessory extends BaseAccessory {
105
105
  .onGet(() => this.getCurrentDoorState());
106
106
 
107
107
  this.device.on('change', changes => {
108
- this._alwaysLog('changed:' + JSON.stringify(changes));
108
+ this._debugLog('changed:' + JSON.stringify(changes));
109
109
 
110
110
  if (changes.hasOwnProperty(this.dpStatus)) {
111
111
  const newCurrentDoorState = this._getCurrentDoorState(changes[this.dpStatus]);
@@ -4,8 +4,8 @@ const BaseAccessory = require('./BaseAccessory');
4
4
  * IrrigationSystemAccessory
5
5
  *
6
6
  * A fully-fledged HomeKit irrigation controller for Tuya multi-valve devices
7
- * (e.g. the 4-zone faucet/valve timers that expose `switch_1..switch_n`, a
8
- * `battery_percentage` and a `rain_sensor_state`).
7
+ * (e.g. the 4-zone faucet/valve timers that expose `switch_1..switch_n` and a
8
+ * `battery_percentage`).
9
9
  *
10
10
  * HomeKit modelling (one bridged accessory, category SPRINKLER):
11
11
  *
@@ -13,8 +13,9 @@ const BaseAccessory = require('./BaseAccessory');
13
13
  * ├─ Valve "Zone A" (linked, ValveType=IRRIGATION, ServiceLabelIndex 1)
14
14
  * ├─ Valve "Zone B" (linked, ServiceLabelIndex 2)
15
15
  * ├─ ...
16
+ * ServiceLabel (labels the multi-valve collection so the Home app nests
17
+ * the zones under the system tile; added when >1 valve)
16
18
  * Battery (BatteryLevel + StatusLowBattery)
17
- * ContactSensor (rain — or LeakSensor, configurable)
18
19
  *
19
20
  * Each Valve carries its own SetDuration/RemainingDuration so the Home app shows
20
21
  * the familiar per-zone "Duration" picker and a live countdown. A zone whose
@@ -45,14 +46,13 @@ class IrrigationSystemAccessory extends BaseAccessory {
45
46
  // Self-contained (no reliance on instance state) so it is safe to call
46
47
  // both during early service reconciliation and later when wiring
47
48
  // characteristics.
48
- const cloud = this._isCloud();
49
49
  const defaultDuration = isFinite(this.device.context.defaultDuration) ? parseInt(this.device.context.defaultDuration) : 600;
50
50
 
51
51
  if (Array.isArray(this.device.context.valves) && this.device.context.valves.length) {
52
52
  return this.device.context.valves.map((valve, i) => {
53
- // A data-point may be a numeric LAN id (1, 2, …) or a Tuya Cloud
54
- // code (e.g. "switch_1"). Accept either; only an empty value is
55
- // invalid.
53
+ // A data-point may be a numeric id (1, 2, …) or a Tuya code
54
+ // (e.g. "switch_1"); both resolve over either transport. Only an
55
+ // empty value is invalid.
56
56
  const dp = (valve && valve.dp !== undefined && valve.dp !== null) ? ('' + valve.dp).trim() : '';
57
57
  if (!dp) {
58
58
  throw new Error(`The valve definition #${i + 1} is missing a 'dp': ${JSON.stringify(valve)}`);
@@ -71,9 +71,7 @@ class IrrigationSystemAccessory extends BaseAccessory {
71
71
  const configs = [];
72
72
  for (let i = 0; i < count; i++) {
73
73
  configs.push({
74
- // Cloud devices address valves by code (switch_1, switch_2, );
75
- // LAN devices by numeric data-point id (1, 2, …).
76
- dp: cloud ? ('switch_' + (i + 1)) : String(i + 1),
74
+ dp: String(i + 1),
77
75
  name: 'Valve ' + (letters[i] || (i + 1)),
78
76
  index: i + 1,
79
77
  duration: defaultDuration
@@ -82,33 +80,17 @@ class IrrigationSystemAccessory extends BaseAccessory {
82
80
  return configs;
83
81
  }
84
82
 
85
- // True when this device is reached over the Tuya Cloud (data-points keyed by
86
- // string code) rather than the LAN (numeric data-point ids).
87
- _isCloud() {
88
- return this._coerceBoolean(this.device.context.cloud, false);
89
- }
90
-
91
- // Resolve a configurable data-point that may be given as a numeric LAN id or
92
- // a Tuya Cloud code, falling back to a sensible default for the active
93
- // transport.
94
- _resolveDP(value, cloudDefault, lanDefault) {
83
+ // Resolve a configurable data-point (a numeric id or a Tuya code), trimming and
84
+ // falling back to a default when it isn't set.
85
+ _resolveDP(value, fallback) {
95
86
  const v = (value === undefined || value === null) ? '' : ('' + value).trim();
96
- if (v !== '') return v;
97
- return this._isCloud() ? cloudDefault : lanDefault;
87
+ return v !== '' ? v : fallback;
98
88
  }
99
89
 
100
90
  _hasBattery() {
101
91
  return !this._coerceBoolean(this.device.context.noBattery, false);
102
92
  }
103
93
 
104
- _hasRainSensor() {
105
- return !this._coerceBoolean(this.device.context.noRainSensor, false);
106
- }
107
-
108
- _usesLeakSensor() {
109
- return ('' + (this.device.context.rainSensorType || 'contact')).trim().toLowerCase() === 'leak';
110
- }
111
-
112
94
  /* ------------------------------------------------------------------ *
113
95
  * Service registration / cache reconciliation
114
96
  * ------------------------------------------------------------------ */
@@ -149,6 +131,25 @@ class IrrigationSystemAccessory extends BaseAccessory {
149
131
  irrigation.addLinkedService(valve);
150
132
  });
151
133
 
134
+ // --- Service Label (required when the accessory exposes a *collection*
135
+ // of same-type services, i.e. more than one Valve) ---
136
+ // HAP's Service Label profile says an accessory holding several services
137
+ // of the same type must carry a Service Label service, with each member
138
+ // service tagged by a ServiceLabelIndex. We already set the indices; the
139
+ // anchoring Service Label service was missing. Without it the stricter
140
+ // Home app clients (notably iOS) scatter the zones as separate tiles
141
+ // rather than nesting them under the irrigation system — macOS happens to
142
+ // render leniently either way. The namespace is set in
143
+ // _registerCharacteristics. A single-valve system is not a collection, so
144
+ // it needs no label.
145
+ const multiValve = valveConfigs.length > 1;
146
+ const existingLabel = this.accessory.getService(Service.ServiceLabel);
147
+ if (multiValve && !existingLabel) {
148
+ this.accessory.addService(Service.ServiceLabel, this.device.context.name);
149
+ } else if (!multiValve && existingLabel) {
150
+ this.accessory.removeService(existingLabel);
151
+ }
152
+
152
153
  // --- Battery (optional) ---
153
154
  if (this._hasBattery()) {
154
155
  if (!this.accessory.getService(Service.Battery)) {
@@ -156,23 +157,11 @@ class IrrigationSystemAccessory extends BaseAccessory {
156
157
  }
157
158
  }
158
159
 
159
- // --- Rain sensor (optional): Contact (default) or Leak ---
160
- if (this._hasRainSensor()) {
161
- const WantedSensor = this._usesLeakSensor() ? Service.LeakSensor : Service.ContactSensor;
162
- const UnwantedSensor = this._usesLeakSensor() ? Service.ContactSensor : Service.LeakSensor;
163
- // Drop the other sensor type if the user switched `rainSensorType`.
164
- const stale = this.accessory.getService(UnwantedSensor);
165
- if (stale) this.accessory.removeService(stale);
166
- if (!this.accessory.getService(WantedSensor)) {
167
- this.accessory.addService(WantedSensor, this.device.context.name + ' Rain');
168
- }
169
- }
170
-
171
160
  // --- Remove services that no longer belong (config changed) ---
172
161
  this.accessory.services
173
162
  .filter(service => service.UUID === Service.Valve.UUID && !validValveSubtypes.includes(service.subtype))
174
163
  .forEach(service => {
175
- this.log.info('Removing stale valve service %s', service.displayName);
164
+ this.log.debug('Removing stale valve service %s', service.displayName);
176
165
  this.accessory.removeService(service);
177
166
  });
178
167
 
@@ -180,12 +169,19 @@ class IrrigationSystemAccessory extends BaseAccessory {
180
169
  const battery = this.accessory.getService(Service.Battery);
181
170
  if (battery) this.accessory.removeService(battery);
182
171
  }
183
- if (!this._hasRainSensor()) {
184
- [Service.ContactSensor, Service.LeakSensor].forEach(S => {
185
- const svc = this.accessory.getService(S);
186
- if (svc) this.accessory.removeService(svc);
187
- });
188
- }
172
+
173
+ // Rain/leak sensors are no longer supported: they never reported
174
+ // reliably on these devices, and bundling a sensor in the same accessory
175
+ // forced the Home app to fragment the sprinkler into "sub-accessories"
176
+ // (blocking control from the main tile). Drop any left over from an older
177
+ // version so the accessory stays a clean, single-category sprinkler tile.
178
+ [Service.ContactSensor, Service.LeakSensor].forEach(S => {
179
+ const svc = this.accessory.getService(S);
180
+ if (svc) {
181
+ this.log.debug('Removing rain sensor service %s (no longer supported)', svc.displayName);
182
+ this.accessory.removeService(svc);
183
+ }
184
+ });
189
185
  }
190
186
 
191
187
  /* ------------------------------------------------------------------ *
@@ -208,13 +204,10 @@ class IrrigationSystemAccessory extends BaseAccessory {
208
204
  this._cascadeOn = this._coerceBoolean(this.device.context.masterTurnsOnAllZones, true);
209
205
  this._cascadeOff = this._coerceBoolean(this.device.context.masterTurnsOffAllZones, true);
210
206
 
211
- // Data-points accept a numeric LAN id or a Tuya Cloud code; defaults
212
- // differ per transport (cloud uses the standard Tuya codes).
213
- this.dpBattery = this._resolveDP(this.device.context.dpBattery, 'battery_percentage', '46');
214
- this.dpCharging = this._resolveDP(this.device.context.dpCharging, 'charge_state', '101');
215
- this.dpRain = this._resolveDP(this.device.context.dpRain, 'rain_sensor_state', '49');
216
- this._rainOnValue = ('' + (this.device.context.rainOnValue || 'rain')).trim();
217
- this._rainInverted = this._coerceBoolean(this.device.context.rainInverted, false);
207
+ // Data-points default to this device's numeric ids; a Tuya code may be
208
+ // configured instead and works over either transport.
209
+ this.dpBattery = this._resolveDP(this.device.context.dpBattery, '46');
210
+ this.dpCharging = this._resolveDP(this.device.context.dpCharging, '101');
218
211
 
219
212
  // Per-zone runtime state
220
213
  this._valves = this._getValveConfigs();
@@ -234,7 +227,7 @@ class IrrigationSystemAccessory extends BaseAccessory {
234
227
 
235
228
  this._systemActiveChar = irrigation.getCharacteristic(Characteristic.Active)
236
229
  .updateValue(this._anyValveOn() ? Characteristic.Active.ACTIVE : Characteristic.Active.INACTIVE)
237
- .onGet(() => this._anyValveOn() ? Characteristic.Active.ACTIVE : Characteristic.Active.INACTIVE)
230
+ .onGet(() => this._reportCached(this._systemActiveChar))
238
231
  .onSet(value => this._setSystemActive(value));
239
232
 
240
233
  this._systemInUseChar = irrigation.getCharacteristic(Characteristic.InUse)
@@ -277,10 +270,10 @@ class IrrigationSystemAccessory extends BaseAccessory {
277
270
  .updateValue(0)
278
271
  .onGet(() => this._valveRemaining(cfg));
279
272
 
280
- valve.getCharacteristic(Characteristic.Active)
273
+ const activeChar = valve.getCharacteristic(Characteristic.Active)
281
274
  .updateValue(on ? Characteristic.Active.ACTIVE : Characteristic.Active.INACTIVE)
282
- .onGet(() => (this.getStateAsync(cfg.dp) ? 1 : 0))
283
275
  .onSet(value => this._setValveActive(cfg, value));
276
+ activeChar.onGet(() => this._reportCached(activeChar));
284
277
 
285
278
  valve.getCharacteristic(Characteristic.InUse)
286
279
  .updateValue(on ? Characteristic.InUse.IN_USE : Characteristic.InUse.NOT_IN_USE);
@@ -290,6 +283,19 @@ class IrrigationSystemAccessory extends BaseAccessory {
290
283
  if (on) this._reflectValve(cfg, true);
291
284
  });
292
285
 
286
+ // --- Service Label namespace ---
287
+ // Anchors the per-valve ServiceLabelIndex so the Home app groups the
288
+ // zones under the irrigation tile (see _verifyCachedPlatformAccessory).
289
+ // Arabic numerals: zones are labelled 1, 2, 3… — the user-set zone names
290
+ // (ConfiguredName) still take precedence in the Home app.
291
+ if (this._valves.length > 1) {
292
+ const serviceLabel = this.accessory.getService(Service.ServiceLabel);
293
+ if (serviceLabel) {
294
+ serviceLabel.getCharacteristic(Characteristic.ServiceLabelNamespace)
295
+ .updateValue(Characteristic.ServiceLabelNamespace.ARABIC_NUMERALS);
296
+ }
297
+ }
298
+
293
299
  // --- Battery ---
294
300
  if (this._hasBattery()) {
295
301
  const battery = this.accessory.getService(Service.Battery);
@@ -308,25 +314,10 @@ class IrrigationSystemAccessory extends BaseAccessory {
308
314
  .onGet(() => this._chargingState(this.getStateAsync(this.dpCharging)));
309
315
  }
310
316
 
311
- // --- Rain sensor ---
312
- if (this._hasRainSensor()) {
313
- if (this._usesLeakSensor()) {
314
- const leak = this.accessory.getService(Service.LeakSensor);
315
- leak.getCharacteristic(Characteristic.LeakDetected)
316
- .updateValue(this._rainDetected(dps[this.dpRain]) ? Characteristic.LeakDetected.LEAK_DETECTED : Characteristic.LeakDetected.LEAK_NOT_DETECTED)
317
- .onGet(() => this._rainDetected(this.getStateAsync(this.dpRain)) ? 1 : 0);
318
- } else {
319
- const contact = this.accessory.getService(Service.ContactSensor);
320
- contact.getCharacteristic(Characteristic.ContactSensorState)
321
- .updateValue(this._contactState(dps[this.dpRain]))
322
- .onGet(() => this._contactState(this.getStateAsync(this.dpRain)));
323
- }
324
- }
325
-
326
317
  this._syncAggregate();
327
318
 
328
319
  // --- React to device-side changes (physical buttons, our own writes
329
- // being confirmed, battery/rain telemetry) ---
320
+ // being confirmed, battery telemetry) ---
330
321
  this.device.on('change', (changes) => this._onDeviceChange(changes));
331
322
  }
332
323
 
@@ -359,21 +350,22 @@ class IrrigationSystemAccessory extends BaseAccessory {
359
350
  .updateValue(this._chargingState(changes[this.dpCharging]));
360
351
  }
361
352
 
362
- if (changes.hasOwnProperty(this.dpRain) && this._hasRainSensor()) {
363
- if (this._usesLeakSensor()) {
364
- const leak = this.accessory.getService(Service.LeakSensor);
365
- if (leak) leak.getCharacteristic(Characteristic.LeakDetected)
366
- .updateValue(this._rainDetected(changes[this.dpRain]) ? 1 : 0);
367
- } else {
368
- const contact = this.accessory.getService(Service.ContactSensor);
369
- if (contact) contact.getCharacteristic(Characteristic.ContactSensorState)
370
- .updateValue(this._contactState(changes[this.dpRain]));
371
- }
372
- }
373
-
374
353
  if (valveChanged) this._syncAggregate();
375
354
  }
376
355
 
356
+ // onGet handler for the system / valve Active characteristics. Returns the
357
+ // value HomeKit already shows — the optimistic value written on press and
358
+ // then confirmed (or corrected) by device-side change events — rather than
359
+ // the raw `device.state`. Cloud devices don't advance `device.state` until
360
+ // the realtime stream echoes the write back, so reading it here returned the
361
+ // pre-press value for a moment and made the Home app toggle visibly flicker
362
+ // back to the old state before settling. Throwing while disconnected keeps
363
+ // the HomeKit "No Response" behaviour.
364
+ _reportCached(char) {
365
+ if (!this.device.connected) throw this._commError();
366
+ return char.value;
367
+ }
368
+
377
369
  /* ------------------------------------------------------------------ *
378
370
  * Valve activation + timer logic
379
371
  * ------------------------------------------------------------------ */
@@ -550,7 +542,7 @@ class IrrigationSystemAccessory extends BaseAccessory {
550
542
  }
551
543
 
552
544
  /* ------------------------------------------------------------------ *
553
- * Battery + rain mapping
545
+ * Battery mapping
554
546
  * ------------------------------------------------------------------ */
555
547
 
556
548
  _batteryLevel(value) {
@@ -576,22 +568,6 @@ class IrrigationSystemAccessory extends BaseAccessory {
576
568
  : Characteristic.ChargingState.NOT_CHARGING;
577
569
  }
578
570
 
579
- // True when the device reports rain.
580
- _rainDetected(value) {
581
- let raining;
582
- if (typeof value === 'boolean') raining = value;
583
- else raining = ('' + value).trim().toLowerCase() === this._rainOnValue.toLowerCase();
584
- return this._rainInverted ? !raining : raining;
585
- }
586
-
587
- _contactState(value) {
588
- const {Characteristic} = this.hap;
589
- // "Rain" is treated as the triggered/abnormal state → CONTACT_NOT_DETECTED.
590
- return this._rainDetected(value)
591
- ? Characteristic.ContactSensorState.CONTACT_NOT_DETECTED
592
- : Characteristic.ContactSensorState.CONTACT_DETECTED;
593
- }
594
-
595
571
  /* ------------------------------------------------------------------ *
596
572
  * Batched writes — coalesce DP updates into a single Tuya command
597
573
  * ------------------------------------------------------------------ */
@@ -614,16 +590,19 @@ class IrrigationSystemAccessory extends BaseAccessory {
614
590
  return;
615
591
  }
616
592
 
617
- const dps = {};
618
- let count = 0;
619
- Object.keys(pending.dps).forEach(dp => {
620
- if (pending.dps[dp] !== this.device.state[dp]) {
621
- dps[dp] = pending.dps[dp];
622
- count++;
623
- }
624
- });
625
-
626
- if (count) this.device.update(dps);
593
+ // Send exactly what was queued. We deliberately do NOT drop data-points
594
+ // that appear to already match `device.state`: cloud devices never
595
+ // optimistically advance `state` (it only moves when the realtime/refresh
596
+ // stream confirms the device), so comparing against it would silently
597
+ // swallow a genuine command. The most visible symptom was a valve that
598
+ // could be turned on but never off — the "off" matched the stale "off"
599
+ // still in `state` (the "on" hadn't been echoed back yet) and was
600
+ // discarded, so HomeKit showed the zone closed while it kept running.
601
+ // Callers (_setValveActive, _setSystemActive, the auto-shutoff timers)
602
+ // already queue only real changes, judged against the HomeKit state the
603
+ // user sees, so there is nothing left to de-dupe here.
604
+ const dps = pending.dps;
605
+ if (Object.keys(dps).length) this.device.update(dps);
627
606
  }
628
607
 
629
608
  _valveService(cfg) {
@@ -34,7 +34,7 @@ class MultiOutletAccessory extends BaseAccessory {
34
34
  this.accessory.services
35
35
  .filter(service => service.UUID === Service.Outlet.UUID && !_validServices.includes(service))
36
36
  .forEach(service => {
37
- this.log.info('Removing', service.displayName);
37
+ this.log.debug('Removing', service.displayName);
38
38
  this.accessory.removeService(service);
39
39
  });
40
40
  }
@@ -69,6 +69,7 @@ class MultiOutletAccessory extends BaseAccessory {
69
69
  }
70
70
 
71
71
  setPower(dp, value) {
72
+ if (!this.device.connected) throw this._commError();
72
73
  if (!this._pendingPower) {
73
74
  this._pendingPower = {props: {}, resolvers: []};
74
75
  }
@@ -82,7 +83,7 @@ class MultiOutletAccessory extends BaseAccessory {
82
83
  this._pendingPower.timer = setTimeout(() => {
83
84
  const {props, resolvers} = this._pendingPower;
84
85
  this._pendingPower = null;
85
- this.setMultiStateAsync(props);
86
+ this.setMultiStateInBackground(props);
86
87
  resolvers.forEach(r => r());
87
88
  }, 500);
88
89
  });