homebridge-tuya-plus 3.14.0-dev.4 → 3.14.0-dev.40
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 +63 -78
- 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 +93 -114
- 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 +78 -24
- 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 +90 -4
- package/lib/TuyaCloudDevice.js +204 -25
- package/lib/TuyaCloudMessaging.js +28 -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 +122 -68
- 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 +63 -9
- package/test/TuyaAccessory.protocol.test.js +76 -3
- package/test/TuyaCloudApi.test.js +80 -0
- package/test/TuyaCloudDevice.test.js +253 -1
- 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 +48 -30
- package/wiki/Tuya-Cloud-Setup.md +24 -113
|
@@ -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.
|
|
83
|
+
this.log.debug('Adding air quality sensor: %s', nameAirQuality);
|
|
84
84
|
this.accessory.addService(Service.AirQualitySensor, nameAirQuality);
|
|
85
85
|
}
|
|
86
86
|
|
package/lib/BaseAccessory.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
-
|
|
118
|
+
if (this.device.update({[dp.toString()] : dps[dp]}) === false) failed = true;
|
|
108
119
|
}
|
|
109
120
|
}
|
|
110
|
-
callback && callback(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
91
|
+
this.setMultiStateInBackground(props);
|
|
91
92
|
resolvers.forEach(r => r());
|
|
92
93
|
}, 500);
|
|
93
94
|
});
|
package/lib/DoorbellAccessory.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
109
|
+
this.log.debug('Door button pressed');
|
|
113
110
|
}
|
|
114
111
|
|
|
115
112
|
if (changes.hasOwnProperty(DP_GATE)) {
|
|
116
|
-
|
|
113
|
+
this.log.debug('Gate button pressed');
|
|
117
114
|
}
|
|
118
115
|
});
|
|
119
116
|
}
|
|
120
117
|
|
|
121
118
|
configureCamera() {
|
|
122
|
-
this.streamingDelegate = new DoorbellDelegate();
|
|
123
|
-
|
|
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.
|
|
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
|
|
8
|
-
* `battery_percentage`
|
|
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
|
|
54
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
86
|
-
//
|
|
87
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
|
212
|
-
//
|
|
213
|
-
this.dpBattery = this._resolveDP(this.device.context.dpBattery, '
|
|
214
|
-
this.dpCharging = this._resolveDP(this.device.context.dpCharging, '
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
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.
|
|
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.
|
|
86
|
+
this.setMultiStateInBackground(props);
|
|
86
87
|
resolvers.forEach(r => r());
|
|
87
88
|
}, 500);
|
|
88
89
|
});
|