homebridge-tuya-plus 3.14.0-dev.13 → 3.14.0-dev.15
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/Changelog.md +2 -0
- package/lib/IrrigationSystemAccessory.js +16 -3
- package/lib/TuyaCloudApi.js +9 -0
- package/lib/TuyaCloudDevice.js +20 -1
- package/package.json +1 -1
- package/test/IrrigationSystemAccessory.test.js +32 -0
- package/test/TuyaCloudApi.test.js +8 -0
- package/test/TuyaCloudDevice.test.js +29 -0
package/Changelog.md
CHANGED
|
@@ -13,6 +13,8 @@ All notable changes to this project will be documented in this file. This projec
|
|
|
13
13
|
* [*] **Fix cloud irrigation valves that could be turned on but not off** — the per-zone write coalescer was dropping any command that matched the last-known `device.state`. Cloud devices never optimistically advance `state` (it only moves once the realtime stream confirms the device), so an "off" issued before the "on" was echoed matched the stale "off" and was discarded — HomeKit showed the zone closed while it kept running. Queued commands are now sent as-is (callers already queue only genuine changes).
|
|
14
14
|
* [*] **IrrigationSystem: remove the rain sensor.** It never reported reliably on these devices, and bundling a sensor (a different HomeKit category) in the same accessory forced the Home app to fragment the sprinkler into "sub-accessories" — blocking control from the main tile and hiding the system master on/off. The accessory is now a single, clean sprinkler tile (IrrigationSystem + valves + optional battery); any leftover Contact/Leak sensor service from a previous build is removed automatically on restart. The `noRainSensor`, `rainSensorType`, `rainInverted`, `dpRain` and `rainOnValue` options are gone.
|
|
15
15
|
* [*] **IrrigationSystem: add the HAP Service Label service for multi-valve controllers** — an accessory that exposes a collection of same-type services (more than one `Valve`) must include a `ServiceLabel` service to anchor each valve's `ServiceLabelIndex`. It was missing, so stricter Home app clients (notably iOS) scattered the zones as separate tiles instead of nesting them under the single irrigation tile. The service is added automatically (with the Arabic-numerals namespace) whenever there is more than one valve; user-set zone names still take precedence.
|
|
16
|
+
* [*] **IrrigationSystem: stop the valve toggle flickering after a press** — tapping a zone briefly snapped back to the old state before settling on the new one. The `Active` getters returned the raw `device.state`, which (for cloud devices) only advances once the realtime stream echoes the write back, so a read in that window reported the pre-press value. The getters now report the value HomeKit already shows (optimistic on press, then confirmed/corrected by device-side change events); they still surface "No Response" while disconnected.
|
|
17
|
+
* [*] **Tuya Cloud: report real device online/offline status.** Cloud devices previously always showed as reachable. They now mirror Tuya's `online` flag (read from the device record on connect and re-checked when the realtime stream reconnects), so HomeKit shows **"No Response"** when the device is genuinely offline. If the lookup isn't permitted (the project lacks the device-management API), the device is assumed reachable so control is never blocked.
|
|
16
18
|
|
|
17
19
|
## 2.0.1 (2021-03-25)
|
|
18
20
|
This update includes the following changes:
|
|
@@ -238,7 +238,7 @@ class IrrigationSystemAccessory extends BaseAccessory {
|
|
|
238
238
|
|
|
239
239
|
this._systemActiveChar = irrigation.getCharacteristic(Characteristic.Active)
|
|
240
240
|
.updateValue(this._anyValveOn() ? Characteristic.Active.ACTIVE : Characteristic.Active.INACTIVE)
|
|
241
|
-
.onGet(() => this.
|
|
241
|
+
.onGet(() => this._reportCached(this._systemActiveChar))
|
|
242
242
|
.onSet(value => this._setSystemActive(value));
|
|
243
243
|
|
|
244
244
|
this._systemInUseChar = irrigation.getCharacteristic(Characteristic.InUse)
|
|
@@ -281,10 +281,10 @@ class IrrigationSystemAccessory extends BaseAccessory {
|
|
|
281
281
|
.updateValue(0)
|
|
282
282
|
.onGet(() => this._valveRemaining(cfg));
|
|
283
283
|
|
|
284
|
-
valve.getCharacteristic(Characteristic.Active)
|
|
284
|
+
const activeChar = valve.getCharacteristic(Characteristic.Active)
|
|
285
285
|
.updateValue(on ? Characteristic.Active.ACTIVE : Characteristic.Active.INACTIVE)
|
|
286
|
-
.onGet(() => (this.getStateAsync(cfg.dp) ? 1 : 0))
|
|
287
286
|
.onSet(value => this._setValveActive(cfg, value));
|
|
287
|
+
activeChar.onGet(() => this._reportCached(activeChar));
|
|
288
288
|
|
|
289
289
|
valve.getCharacteristic(Characteristic.InUse)
|
|
290
290
|
.updateValue(on ? Characteristic.InUse.IN_USE : Characteristic.InUse.NOT_IN_USE);
|
|
@@ -364,6 +364,19 @@ class IrrigationSystemAccessory extends BaseAccessory {
|
|
|
364
364
|
if (valveChanged) this._syncAggregate();
|
|
365
365
|
}
|
|
366
366
|
|
|
367
|
+
// onGet handler for the system / valve Active characteristics. Returns the
|
|
368
|
+
// value HomeKit already shows — the optimistic value written on press and
|
|
369
|
+
// then confirmed (or corrected) by device-side change events — rather than
|
|
370
|
+
// the raw `device.state`. Cloud devices don't advance `device.state` until
|
|
371
|
+
// the realtime stream echoes the write back, so reading it here returned the
|
|
372
|
+
// pre-press value for a moment and made the Home app toggle visibly flicker
|
|
373
|
+
// back to the old state before settling. Throwing while disconnected keeps
|
|
374
|
+
// the HomeKit "No Response" behaviour.
|
|
375
|
+
_reportCached(char) {
|
|
376
|
+
if (!this.device.connected) throw new Error('Not connected');
|
|
377
|
+
return char.value;
|
|
378
|
+
}
|
|
379
|
+
|
|
367
380
|
/* ------------------------------------------------------------------ *
|
|
368
381
|
* Valve activation + timer logic
|
|
369
382
|
* ------------------------------------------------------------------ */
|
package/lib/TuyaCloudApi.js
CHANGED
|
@@ -214,6 +214,15 @@ class TuyaCloudApi {
|
|
|
214
214
|
return (res && Array.isArray(res.result)) ? res.result : [];
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
+
// Read the device record (name, product, and crucially `online`). Used to
|
|
218
|
+
// learn whether the device is currently reachable. Returns the raw `result`
|
|
219
|
+
// object or null. Requires the project to have the device-management API
|
|
220
|
+
// authorized; callers treat a failure as "online unknown".
|
|
221
|
+
async getDeviceInfo(deviceId) {
|
|
222
|
+
const res = await this.request('GET', `/v1.0/devices/${encodeURIComponent(deviceId)}`);
|
|
223
|
+
return (res && res.result && typeof res.result === 'object') ? res.result : null;
|
|
224
|
+
}
|
|
225
|
+
|
|
217
226
|
// Issue one or more commands: [{code, value}, …].
|
|
218
227
|
async sendCommands(deviceId, commands) {
|
|
219
228
|
if (!Array.isArray(commands) || !commands.length) return true;
|
package/lib/TuyaCloudDevice.js
CHANGED
|
@@ -66,7 +66,7 @@ class TuyaCloudDevice extends EventEmitter {
|
|
|
66
66
|
try {
|
|
67
67
|
const status = await this.api.getStatus(this.context.id);
|
|
68
68
|
this.state = this._statusToState(status);
|
|
69
|
-
this.connected =
|
|
69
|
+
this.connected = await this._readOnline();
|
|
70
70
|
|
|
71
71
|
this._logDiscoveredCodes(status);
|
|
72
72
|
|
|
@@ -102,6 +102,11 @@ class TuyaCloudDevice extends EventEmitter {
|
|
|
102
102
|
async _refreshState() {
|
|
103
103
|
if (this._stopped) return;
|
|
104
104
|
try {
|
|
105
|
+
const online = await this._readOnline();
|
|
106
|
+
if (this.connected !== online) {
|
|
107
|
+
this.connected = online;
|
|
108
|
+
this.log.info(`${this.context.name}: Tuya now reports the device ${online ? 'online' : 'offline'}.`);
|
|
109
|
+
}
|
|
105
110
|
const status = await this.api.getStatus(this.context.id);
|
|
106
111
|
this._applyStatus(status);
|
|
107
112
|
} catch (ex) {
|
|
@@ -109,6 +114,20 @@ class TuyaCloudDevice extends EventEmitter {
|
|
|
109
114
|
}
|
|
110
115
|
}
|
|
111
116
|
|
|
117
|
+
// Resolve the device's reachability from Tuya's `online` flag, so HomeKit
|
|
118
|
+
// shows "No Response" when the device is genuinely offline. If the lookup
|
|
119
|
+
// isn't available (e.g. the project lacks the device-management API) fall
|
|
120
|
+
// back to reachable so control is never blocked.
|
|
121
|
+
async _readOnline() {
|
|
122
|
+
try {
|
|
123
|
+
const info = await this.api.getDeviceInfo(this.context.id);
|
|
124
|
+
if (info && typeof info.online === 'boolean') return info.online;
|
|
125
|
+
} catch (ex) {
|
|
126
|
+
this.log.debug(`${this.context.name}: online-status check failed: ${ex.message}`);
|
|
127
|
+
}
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
|
|
112
131
|
/* ------------------------------------------------------------------ *
|
|
113
132
|
* State helpers
|
|
114
133
|
* ------------------------------------------------------------------ */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homebridge-tuya-plus",
|
|
3
|
-
"version": "3.14.0-dev.
|
|
3
|
+
"version": "3.14.0-dev.15",
|
|
4
4
|
"description": "A community-maintained Homebridge plugin for controlling Tuya devices locally over LAN. Includes new features, fixes, and updated device support.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -298,6 +298,38 @@ describe('IrrigationSystemAccessory — valve activation & timer', () => {
|
|
|
298
298
|
jest.advanceTimersByTime(600 * 1000);
|
|
299
299
|
expect(device.update.mock.calls.length).toBe(calls);
|
|
300
300
|
});
|
|
301
|
+
|
|
302
|
+
test('valve Active onGet reports the optimistic value, not the lagging device state (no flicker)', () => {
|
|
303
|
+
// Cloud devices don't advance device.state until the realtime stream
|
|
304
|
+
// echoes the write back. Emulate that (update is a no-op on state) and
|
|
305
|
+
// confirm a freshly-pressed valve keeps reading ON instead of briefly
|
|
306
|
+
// reverting to the pre-press value.
|
|
307
|
+
const { accessory, device } = makeHarness({ '1': false }, { defaultDuration: 0 });
|
|
308
|
+
device.update.mockImplementation(() => true);
|
|
309
|
+
const v = valve(accessory, 1);
|
|
310
|
+
|
|
311
|
+
v.getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
312
|
+
jest.advanceTimersByTime(500);
|
|
313
|
+
|
|
314
|
+
expect(device.state['1']).toBe(false); // not echoed yet
|
|
315
|
+
expect(v.getCharacteristic(Characteristic.Active).triggerGet()).toBe(Characteristic.Active.ACTIVE);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test('system Active onGet reports the cached aggregate, not the lagging device state', () => {
|
|
319
|
+
const { accessory, device } = makeHarness({ '1': false, '2': false }, { defaultDuration: 0 });
|
|
320
|
+
device.update.mockImplementation(() => true);
|
|
321
|
+
valve(accessory, 1).getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
322
|
+
jest.advanceTimersByTime(500);
|
|
323
|
+
|
|
324
|
+
expect(irrigation(accessory).getCharacteristic(Characteristic.Active).triggerGet())
|
|
325
|
+
.toBe(Characteristic.Active.ACTIVE);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test('Active onGet still throws while disconnected (HomeKit shows "No Response")', () => {
|
|
329
|
+
const { accessory, device } = makeHarness({ '1': false });
|
|
330
|
+
device.connected = false;
|
|
331
|
+
expect(() => valve(accessory, 1).getCharacteristic(Characteristic.Active).triggerGet()).toThrow();
|
|
332
|
+
});
|
|
301
333
|
});
|
|
302
334
|
|
|
303
335
|
describe('IrrigationSystemAccessory — write batching (one Tuya command)', () => {
|
|
@@ -138,6 +138,14 @@ describe('TuyaCloudApi — endpoints', () => {
|
|
|
138
138
|
await expect(api.getStatus('dev')).resolves.toEqual([{code: 'switch_1', value: true}]);
|
|
139
139
|
});
|
|
140
140
|
|
|
141
|
+
test('getDeviceInfo returns the device record (with online status)', async () => {
|
|
142
|
+
const api = ready();
|
|
143
|
+
let path;
|
|
144
|
+
api._httpsRequest = async (method, p) => { path = p; return {success: true, result: {id: 'dev', online: false, name: 'X'}}; };
|
|
145
|
+
await expect(api.getDeviceInfo('dev')).resolves.toEqual({id: 'dev', online: false, name: 'X'});
|
|
146
|
+
expect(path).toBe('/v1.0/devices/dev');
|
|
147
|
+
});
|
|
148
|
+
|
|
141
149
|
test('sendCommands posts the commands and reports success', async () => {
|
|
142
150
|
const api = ready();
|
|
143
151
|
let captured;
|
|
@@ -9,6 +9,7 @@ function makeApi(status = [{code: 'switch_1', value: false}, {code: 'battery_per
|
|
|
9
9
|
return {
|
|
10
10
|
isConfigured: () => true,
|
|
11
11
|
getStatus: jest.fn().mockResolvedValue(status),
|
|
12
|
+
getDeviceInfo: jest.fn().mockResolvedValue({online: true}),
|
|
12
13
|
sendCommands: jest.fn().mockResolvedValue(true)
|
|
13
14
|
};
|
|
14
15
|
}
|
|
@@ -77,6 +78,34 @@ describe('TuyaCloudDevice', () => {
|
|
|
77
78
|
expect(changes).toHaveLength(0);
|
|
78
79
|
});
|
|
79
80
|
|
|
81
|
+
test('connect reflects the device online status (offline → not connected)', async () => {
|
|
82
|
+
const api = makeApi();
|
|
83
|
+
api.getDeviceInfo.mockResolvedValue({online: false});
|
|
84
|
+
const dev = makeDevice(api);
|
|
85
|
+
await dev._connect();
|
|
86
|
+
expect(api.getDeviceInfo).toHaveBeenCalledWith('dev1');
|
|
87
|
+
expect(dev.connected).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('online lookup failure → assume reachable (never block control)', async () => {
|
|
91
|
+
const api = makeApi();
|
|
92
|
+
api.getDeviceInfo.mockRejectedValue(new Error('no device-management permission'));
|
|
93
|
+
const dev = makeDevice(api);
|
|
94
|
+
await dev._connect();
|
|
95
|
+
expect(dev.connected).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('a state refresh re-checks online and flips connected', async () => {
|
|
99
|
+
const api = makeApi();
|
|
100
|
+
const dev = makeDevice(api);
|
|
101
|
+
await dev._connect();
|
|
102
|
+
expect(dev.connected).toBe(true);
|
|
103
|
+
|
|
104
|
+
api.getDeviceInfo.mockResolvedValue({online: false});
|
|
105
|
+
await dev._refreshState();
|
|
106
|
+
expect(dev.connected).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
80
109
|
test('subscribes to realtime and re-reads state when the stream (re)connects', async () => {
|
|
81
110
|
const api = makeApi();
|
|
82
111
|
const messaging = Object.assign(new EventEmitter(), {
|