homebridge-tuya-plus 3.14.0-dev.3 → 3.14.0-dev.30
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 +120 -0
- package/CLAUDE.md +1 -0
- package/Changelog.md +21 -0
- package/Readme.MD +8 -24
- package/config.schema.json +122 -68
- package/index.js +498 -269
- package/lib/BaseAccessory.js +52 -15
- package/lib/ConvectorAccessory.js +1 -1
- package/lib/CustomMultiOutletAccessory.js +2 -1
- package/lib/IrrigationSystemAccessory.js +98 -95
- package/lib/MultiOutletAccessory.js +2 -1
- 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 +3 -3
- package/lib/SwitchAccessory.js +2 -1
- package/lib/TWLightAccessory.js +2 -2
- package/lib/TuyaAccessory.js +0 -1
- package/lib/TuyaCloudApi.js +320 -0
- package/lib/TuyaCloudDevice.js +327 -0
- package/lib/TuyaCloudMessaging.js +219 -0
- package/lib/TuyaDevice.js +266 -0
- package/lib/ValveAccessory.js +16 -12
- package/lib/VerticalBlindsWithTilt.js +5 -3
- package/lib/WledDimmerAccessory.js +46 -81
- package/package.json +5 -3
- package/scripts/cleanup-pr-tags.js +122 -0
- package/test/BaseAccessory.test.js +94 -15
- package/test/IrrigationSystemAccessory.test.js +134 -31
- 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/TuyaCloudApi.test.js +221 -0
- package/test/TuyaCloudDevice.test.js +304 -0
- package/test/TuyaCloudMessaging.test.js +113 -0
- package/test/TuyaDevice.test.js +252 -0
- package/test/getCategory.test.js +1 -0
- package/test/index.test.js +183 -0
- package/test/support/mocks.js +13 -0
- package/wiki/Supported-Device-Types.md +60 -28
- package/wiki/Tuya-Cloud-Setup.md +71 -0
package/lib/BaseAccessory.js
CHANGED
|
@@ -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
|
|
|
@@ -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
|
});
|
|
@@ -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
|
|
@@ -49,11 +50,15 @@ class IrrigationSystemAccessory extends BaseAccessory {
|
|
|
49
50
|
|
|
50
51
|
if (Array.isArray(this.device.context.valves) && this.device.context.valves.length) {
|
|
51
52
|
return this.device.context.valves.map((valve, i) => {
|
|
52
|
-
|
|
53
|
-
|
|
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
|
+
const dp = (valve && valve.dp !== undefined && valve.dp !== null) ? ('' + valve.dp).trim() : '';
|
|
57
|
+
if (!dp) {
|
|
58
|
+
throw new Error(`The valve definition #${i + 1} is missing a 'dp': ${JSON.stringify(valve)}`);
|
|
54
59
|
}
|
|
55
60
|
return {
|
|
56
|
-
dp
|
|
61
|
+
dp,
|
|
57
62
|
name: (('' + (valve.name || '')).trim()) || ('Zone ' + (i + 1)),
|
|
58
63
|
index: i + 1,
|
|
59
64
|
duration: isFinite(valve.defaultDuration) ? parseInt(valve.defaultDuration) : defaultDuration
|
|
@@ -75,16 +80,15 @@ class IrrigationSystemAccessory extends BaseAccessory {
|
|
|
75
80
|
return configs;
|
|
76
81
|
}
|
|
77
82
|
|
|
78
|
-
|
|
79
|
-
|
|
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) {
|
|
86
|
+
const v = (value === undefined || value === null) ? '' : ('' + value).trim();
|
|
87
|
+
return v !== '' ? v : fallback;
|
|
80
88
|
}
|
|
81
89
|
|
|
82
|
-
|
|
83
|
-
return !this._coerceBoolean(this.device.context.
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
_usesLeakSensor() {
|
|
87
|
-
return ('' + (this.device.context.rainSensorType || 'contact')).trim().toLowerCase() === 'leak';
|
|
90
|
+
_hasBattery() {
|
|
91
|
+
return !this._coerceBoolean(this.device.context.noBattery, false);
|
|
88
92
|
}
|
|
89
93
|
|
|
90
94
|
/* ------------------------------------------------------------------ *
|
|
@@ -127,6 +131,25 @@ class IrrigationSystemAccessory extends BaseAccessory {
|
|
|
127
131
|
irrigation.addLinkedService(valve);
|
|
128
132
|
});
|
|
129
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
|
+
|
|
130
153
|
// --- Battery (optional) ---
|
|
131
154
|
if (this._hasBattery()) {
|
|
132
155
|
if (!this.accessory.getService(Service.Battery)) {
|
|
@@ -134,18 +157,6 @@ class IrrigationSystemAccessory extends BaseAccessory {
|
|
|
134
157
|
}
|
|
135
158
|
}
|
|
136
159
|
|
|
137
|
-
// --- Rain sensor (optional): Contact (default) or Leak ---
|
|
138
|
-
if (this._hasRainSensor()) {
|
|
139
|
-
const WantedSensor = this._usesLeakSensor() ? Service.LeakSensor : Service.ContactSensor;
|
|
140
|
-
const UnwantedSensor = this._usesLeakSensor() ? Service.ContactSensor : Service.LeakSensor;
|
|
141
|
-
// Drop the other sensor type if the user switched `rainSensorType`.
|
|
142
|
-
const stale = this.accessory.getService(UnwantedSensor);
|
|
143
|
-
if (stale) this.accessory.removeService(stale);
|
|
144
|
-
if (!this.accessory.getService(WantedSensor)) {
|
|
145
|
-
this.accessory.addService(WantedSensor, this.device.context.name + ' Rain');
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
160
|
// --- Remove services that no longer belong (config changed) ---
|
|
150
161
|
this.accessory.services
|
|
151
162
|
.filter(service => service.UUID === Service.Valve.UUID && !validValveSubtypes.includes(service.subtype))
|
|
@@ -158,12 +169,19 @@ class IrrigationSystemAccessory extends BaseAccessory {
|
|
|
158
169
|
const battery = this.accessory.getService(Service.Battery);
|
|
159
170
|
if (battery) this.accessory.removeService(battery);
|
|
160
171
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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.info('Removing rain sensor service %s (no longer supported)', svc.displayName);
|
|
182
|
+
this.accessory.removeService(svc);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
167
185
|
}
|
|
168
186
|
|
|
169
187
|
/* ------------------------------------------------------------------ *
|
|
@@ -186,11 +204,10 @@ class IrrigationSystemAccessory extends BaseAccessory {
|
|
|
186
204
|
this._cascadeOn = this._coerceBoolean(this.device.context.masterTurnsOnAllZones, true);
|
|
187
205
|
this._cascadeOff = this._coerceBoolean(this.device.context.masterTurnsOffAllZones, true);
|
|
188
206
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
this.
|
|
192
|
-
this.
|
|
193
|
-
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');
|
|
194
211
|
|
|
195
212
|
// Per-zone runtime state
|
|
196
213
|
this._valves = this._getValveConfigs();
|
|
@@ -210,7 +227,7 @@ class IrrigationSystemAccessory extends BaseAccessory {
|
|
|
210
227
|
|
|
211
228
|
this._systemActiveChar = irrigation.getCharacteristic(Characteristic.Active)
|
|
212
229
|
.updateValue(this._anyValveOn() ? Characteristic.Active.ACTIVE : Characteristic.Active.INACTIVE)
|
|
213
|
-
.onGet(() => this.
|
|
230
|
+
.onGet(() => this._reportCached(this._systemActiveChar))
|
|
214
231
|
.onSet(value => this._setSystemActive(value));
|
|
215
232
|
|
|
216
233
|
this._systemInUseChar = irrigation.getCharacteristic(Characteristic.InUse)
|
|
@@ -253,10 +270,10 @@ class IrrigationSystemAccessory extends BaseAccessory {
|
|
|
253
270
|
.updateValue(0)
|
|
254
271
|
.onGet(() => this._valveRemaining(cfg));
|
|
255
272
|
|
|
256
|
-
valve.getCharacteristic(Characteristic.Active)
|
|
273
|
+
const activeChar = valve.getCharacteristic(Characteristic.Active)
|
|
257
274
|
.updateValue(on ? Characteristic.Active.ACTIVE : Characteristic.Active.INACTIVE)
|
|
258
|
-
.onGet(() => (this.getStateAsync(cfg.dp) ? 1 : 0))
|
|
259
275
|
.onSet(value => this._setValveActive(cfg, value));
|
|
276
|
+
activeChar.onGet(() => this._reportCached(activeChar));
|
|
260
277
|
|
|
261
278
|
valve.getCharacteristic(Characteristic.InUse)
|
|
262
279
|
.updateValue(on ? Characteristic.InUse.IN_USE : Characteristic.InUse.NOT_IN_USE);
|
|
@@ -266,6 +283,19 @@ class IrrigationSystemAccessory extends BaseAccessory {
|
|
|
266
283
|
if (on) this._reflectValve(cfg, true);
|
|
267
284
|
});
|
|
268
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
|
+
|
|
269
299
|
// --- Battery ---
|
|
270
300
|
if (this._hasBattery()) {
|
|
271
301
|
const battery = this.accessory.getService(Service.Battery);
|
|
@@ -284,25 +314,10 @@ class IrrigationSystemAccessory extends BaseAccessory {
|
|
|
284
314
|
.onGet(() => this._chargingState(this.getStateAsync(this.dpCharging)));
|
|
285
315
|
}
|
|
286
316
|
|
|
287
|
-
// --- Rain sensor ---
|
|
288
|
-
if (this._hasRainSensor()) {
|
|
289
|
-
if (this._usesLeakSensor()) {
|
|
290
|
-
const leak = this.accessory.getService(Service.LeakSensor);
|
|
291
|
-
leak.getCharacteristic(Characteristic.LeakDetected)
|
|
292
|
-
.updateValue(this._rainDetected(dps[this.dpRain]) ? Characteristic.LeakDetected.LEAK_DETECTED : Characteristic.LeakDetected.LEAK_NOT_DETECTED)
|
|
293
|
-
.onGet(() => this._rainDetected(this.getStateAsync(this.dpRain)) ? 1 : 0);
|
|
294
|
-
} else {
|
|
295
|
-
const contact = this.accessory.getService(Service.ContactSensor);
|
|
296
|
-
contact.getCharacteristic(Characteristic.ContactSensorState)
|
|
297
|
-
.updateValue(this._contactState(dps[this.dpRain]))
|
|
298
|
-
.onGet(() => this._contactState(this.getStateAsync(this.dpRain)));
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
|
|
302
317
|
this._syncAggregate();
|
|
303
318
|
|
|
304
319
|
// --- React to device-side changes (physical buttons, our own writes
|
|
305
|
-
// being confirmed, battery
|
|
320
|
+
// being confirmed, battery telemetry) ---
|
|
306
321
|
this.device.on('change', (changes) => this._onDeviceChange(changes));
|
|
307
322
|
}
|
|
308
323
|
|
|
@@ -335,21 +350,22 @@ class IrrigationSystemAccessory extends BaseAccessory {
|
|
|
335
350
|
.updateValue(this._chargingState(changes[this.dpCharging]));
|
|
336
351
|
}
|
|
337
352
|
|
|
338
|
-
if (changes.hasOwnProperty(this.dpRain) && this._hasRainSensor()) {
|
|
339
|
-
if (this._usesLeakSensor()) {
|
|
340
|
-
const leak = this.accessory.getService(Service.LeakSensor);
|
|
341
|
-
if (leak) leak.getCharacteristic(Characteristic.LeakDetected)
|
|
342
|
-
.updateValue(this._rainDetected(changes[this.dpRain]) ? 1 : 0);
|
|
343
|
-
} else {
|
|
344
|
-
const contact = this.accessory.getService(Service.ContactSensor);
|
|
345
|
-
if (contact) contact.getCharacteristic(Characteristic.ContactSensorState)
|
|
346
|
-
.updateValue(this._contactState(changes[this.dpRain]));
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
|
|
350
353
|
if (valveChanged) this._syncAggregate();
|
|
351
354
|
}
|
|
352
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
|
+
|
|
353
369
|
/* ------------------------------------------------------------------ *
|
|
354
370
|
* Valve activation + timer logic
|
|
355
371
|
* ------------------------------------------------------------------ */
|
|
@@ -526,7 +542,7 @@ class IrrigationSystemAccessory extends BaseAccessory {
|
|
|
526
542
|
}
|
|
527
543
|
|
|
528
544
|
/* ------------------------------------------------------------------ *
|
|
529
|
-
* Battery
|
|
545
|
+
* Battery mapping
|
|
530
546
|
* ------------------------------------------------------------------ */
|
|
531
547
|
|
|
532
548
|
_batteryLevel(value) {
|
|
@@ -552,22 +568,6 @@ class IrrigationSystemAccessory extends BaseAccessory {
|
|
|
552
568
|
: Characteristic.ChargingState.NOT_CHARGING;
|
|
553
569
|
}
|
|
554
570
|
|
|
555
|
-
// True when the device reports rain.
|
|
556
|
-
_rainDetected(value) {
|
|
557
|
-
let raining;
|
|
558
|
-
if (typeof value === 'boolean') raining = value;
|
|
559
|
-
else raining = ('' + value).trim().toLowerCase() === this._rainOnValue.toLowerCase();
|
|
560
|
-
return this._rainInverted ? !raining : raining;
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
_contactState(value) {
|
|
564
|
-
const {Characteristic} = this.hap;
|
|
565
|
-
// "Rain" is treated as the triggered/abnormal state → CONTACT_NOT_DETECTED.
|
|
566
|
-
return this._rainDetected(value)
|
|
567
|
-
? Characteristic.ContactSensorState.CONTACT_NOT_DETECTED
|
|
568
|
-
: Characteristic.ContactSensorState.CONTACT_DETECTED;
|
|
569
|
-
}
|
|
570
|
-
|
|
571
571
|
/* ------------------------------------------------------------------ *
|
|
572
572
|
* Batched writes — coalesce DP updates into a single Tuya command
|
|
573
573
|
* ------------------------------------------------------------------ */
|
|
@@ -590,16 +590,19 @@ class IrrigationSystemAccessory extends BaseAccessory {
|
|
|
590
590
|
return;
|
|
591
591
|
}
|
|
592
592
|
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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);
|
|
603
606
|
}
|
|
604
607
|
|
|
605
608
|
_valveService(cfg) {
|
|
@@ -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
|
});
|
|
@@ -203,8 +203,8 @@ class OilDiffuserAccessory extends BaseAccessory {
|
|
|
203
203
|
}
|
|
204
204
|
|
|
205
205
|
getBrightness() {
|
|
206
|
-
if (this.
|
|
207
|
-
return this.convertColorFromTuyaToHomeKit(this.
|
|
206
|
+
if (this.getStateAsync(this.dpMode) === this.cmdWhite) return this.convertBrightnessFromTuyaToHomeKit(this.getStateAsync(this.dpColor)).b;
|
|
207
|
+
return this.convertColorFromTuyaToHomeKit(this.getStateAsync(this.dpColor)).b;
|
|
208
208
|
}
|
|
209
209
|
|
|
210
210
|
setBrightness(value) {
|
|
@@ -218,8 +218,8 @@ class OilDiffuserAccessory extends BaseAccessory {
|
|
|
218
218
|
}
|
|
219
219
|
|
|
220
220
|
getColorTemperature() {
|
|
221
|
-
if (this.
|
|
222
|
-
return this.convertColorTemperatureFromTuyaToHomeKit(this.
|
|
221
|
+
if (this.getStateAsync(this.dpMode) !== this.cmdWhite) return 0;
|
|
222
|
+
return this.convertColorTemperatureFromTuyaToHomeKit(this.getStateAsync(this.dpColorTemperature));
|
|
223
223
|
}
|
|
224
224
|
|
|
225
225
|
setColorTemperature(value) {
|
|
@@ -234,8 +234,8 @@ class OilDiffuserAccessory extends BaseAccessory {
|
|
|
234
234
|
}
|
|
235
235
|
|
|
236
236
|
getHue() {
|
|
237
|
-
if (this.
|
|
238
|
-
return this.convertColorFromTuyaToHomeKit(this.
|
|
237
|
+
if (this.getStateAsync(this.dpMode) === this.cmdWhite) return 0;
|
|
238
|
+
return this.convertColorFromTuyaToHomeKit(this.getStateAsync(this.dpColor)).h;
|
|
239
239
|
}
|
|
240
240
|
|
|
241
241
|
setHue(value) {
|
|
@@ -243,7 +243,7 @@ class OilDiffuserAccessory extends BaseAccessory {
|
|
|
243
243
|
}
|
|
244
244
|
|
|
245
245
|
getSaturation() {
|
|
246
|
-
return this.convertColorFromTuyaToHomeKit(this.
|
|
246
|
+
return this.convertColorFromTuyaToHomeKit(this.getStateAsync(this.dpColor)).s;
|
|
247
247
|
}
|
|
248
248
|
|
|
249
249
|
setSaturation(value) {
|
|
@@ -251,6 +251,8 @@ class OilDiffuserAccessory extends BaseAccessory {
|
|
|
251
251
|
}
|
|
252
252
|
|
|
253
253
|
_setHueSaturation(prop) {
|
|
254
|
+
if (!this.device.connected) throw this._commError();
|
|
255
|
+
|
|
254
256
|
if (!this._pendingHueSaturation) {
|
|
255
257
|
this._pendingHueSaturation = {props: {}, resolvers: []};
|
|
256
258
|
}
|
|
@@ -266,7 +268,7 @@ class OilDiffuserAccessory extends BaseAccessory {
|
|
|
266
268
|
this._pendingHueSaturation = null;
|
|
267
269
|
|
|
268
270
|
const newValue = this.convertColorFromHomeKitToTuya(props);
|
|
269
|
-
this.
|
|
271
|
+
this.setMultiStateInBackground({[this.dpMode]: this.cmdColor, [this.dpColor]: newValue});
|
|
270
272
|
|
|
271
273
|
resolvers.forEach(r => r());
|
|
272
274
|
}, 500);
|
|
@@ -134,8 +134,8 @@ class RGBTWLightAccessory extends BaseAccessory {
|
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
getBrightness() {
|
|
137
|
-
if (this.
|
|
138
|
-
return this.convertColorFromTuyaToHomeKit(this.
|
|
137
|
+
if (this.getStateAsync(this.dpMode) === this.cmdWhite) return this.convertBrightnessFromTuyaToHomeKit(this.getStateAsync(this.dpBrightness));
|
|
138
|
+
return this.convertColorFromTuyaToHomeKit(this.getStateAsync(this.dpColor)).b;
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
setBrightness(value) {
|
|
@@ -145,8 +145,8 @@ class RGBTWLightAccessory extends BaseAccessory {
|
|
|
145
145
|
|
|
146
146
|
getColorTemperature() {
|
|
147
147
|
this.log.debug(`getColorTemperature`);
|
|
148
|
-
if (this.
|
|
149
|
-
return this.convertColorTemperatureFromTuyaToHomeKit(this.
|
|
148
|
+
if (this.getStateAsync(this.dpMode) !== this.cmdWhite) return this.minWhiteColor;
|
|
149
|
+
return this.convertColorTemperatureFromTuyaToHomeKit(this.getStateAsync(this.dpColorTemperature));
|
|
150
150
|
}
|
|
151
151
|
|
|
152
152
|
setColorTemperature(value) {
|
|
@@ -165,10 +165,10 @@ class RGBTWLightAccessory extends BaseAccessory {
|
|
|
165
165
|
}
|
|
166
166
|
|
|
167
167
|
getHue() {
|
|
168
|
-
if (this.
|
|
169
|
-
return this.convertHomeKitColorTemperatureToHomeKitColor(this.convertColorTemperatureFromTuyaToHomeKit(this.
|
|
168
|
+
if (this.getStateAsync(this.dpMode) === this.cmdWhite) {
|
|
169
|
+
return this.convertHomeKitColorTemperatureToHomeKitColor(this.convertColorTemperatureFromTuyaToHomeKit(this.getStateAsync(this.dpColorTemperature))).h;
|
|
170
170
|
}
|
|
171
|
-
return this.convertColorFromTuyaToHomeKit(this.
|
|
171
|
+
return this.convertColorFromTuyaToHomeKit(this.getStateAsync(this.dpColor)).h;
|
|
172
172
|
}
|
|
173
173
|
|
|
174
174
|
setHue(value) {
|
|
@@ -176,10 +176,10 @@ class RGBTWLightAccessory extends BaseAccessory {
|
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
getSaturation() {
|
|
179
|
-
if (this.
|
|
180
|
-
return this.convertHomeKitColorTemperatureToHomeKitColor(this.convertColorTemperatureFromTuyaToHomeKit(this.
|
|
179
|
+
if (this.getStateAsync(this.dpMode) === this.cmdWhite) {
|
|
180
|
+
return this.convertHomeKitColorTemperatureToHomeKitColor(this.convertColorTemperatureFromTuyaToHomeKit(this.getStateAsync(this.dpColorTemperature))).s;
|
|
181
181
|
}
|
|
182
|
-
return this.convertColorFromTuyaToHomeKit(this.
|
|
182
|
+
return this.convertColorFromTuyaToHomeKit(this.getStateAsync(this.dpColor)).s;
|
|
183
183
|
}
|
|
184
184
|
|
|
185
185
|
setSaturation(value) {
|
|
@@ -187,6 +187,8 @@ class RGBTWLightAccessory extends BaseAccessory {
|
|
|
187
187
|
}
|
|
188
188
|
|
|
189
189
|
_setHueSaturation(prop) {
|
|
190
|
+
if (!this.device.connected) throw this._commError();
|
|
191
|
+
|
|
190
192
|
if (!this._pendingHueSaturation) {
|
|
191
193
|
this._pendingHueSaturation = {props: {}, resolvers: []};
|
|
192
194
|
}
|
|
@@ -208,7 +210,7 @@ class RGBTWLightAccessory extends BaseAccessory {
|
|
|
208
210
|
const newValue = this.convertColorFromHomeKitToTuya(props);
|
|
209
211
|
|
|
210
212
|
if (!(this.device.state[this.dpMode] === this.cmdWhite && isSham)) {
|
|
211
|
-
this.
|
|
213
|
+
this.setMultiStateInBackground({[this.dpMode]: this.cmdColor, [this.dpColor]: newValue});
|
|
212
214
|
}
|
|
213
215
|
this.characteristicColorTemperature.updateValue(this.minWhiteColor);
|
|
214
216
|
|