homebridge-tuya-plus 3.14.0-dev.19 → 3.14.0-dev.21
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 +4 -3
- package/Readme.MD +4 -6
- package/config.schema.json +4 -13
- package/index.js +349 -359
- package/lib/IrrigationSystemAccessory.js +12 -23
- package/lib/SimpleGarageDoorAccessory.js +49 -8
- package/lib/TuyaCloudApi.js +21 -0
- package/lib/TuyaCloudDevice.js +86 -15
- package/lib/TuyaDevice.js +266 -0
- package/package.json +2 -2
- package/test/IrrigationSystemAccessory.test.js +44 -49
- package/test/SimpleGarageDoorAccessory.test.js +59 -7
- package/test/TuyaCloudApi.test.js +17 -0
- package/test/TuyaCloudDevice.test.js +49 -0
- package/test/TuyaDevice.test.js +252 -0
- package/test/index.test.js +141 -0
- package/wiki/Supported-Device-Types.md +5 -4
- package/wiki/Tuya-Cloud-Setup.md +15 -29
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Mock every module that would touch the network or HAP, so we can exercise the
|
|
4
|
+
// platform's orchestration (cloud-session setup, per-device cloud policy, and
|
|
5
|
+
// discovery -> attachLan routing) in isolation.
|
|
6
|
+
jest.mock('../lib/TuyaDevice', () => jest.fn().mockImplementation(function(props) {
|
|
7
|
+
this.context = {...props};
|
|
8
|
+
this.cloud = props.cloudApi ? {} : null; // truthy iff a shared cloud session was handed in
|
|
9
|
+
this.attachLan = jest.fn();
|
|
10
|
+
this._connect = jest.fn();
|
|
11
|
+
}));
|
|
12
|
+
jest.mock('../lib/TuyaAccessory', () => jest.fn().mockImplementation(function(props) {
|
|
13
|
+
this.context = {...props};
|
|
14
|
+
this._connect = jest.fn();
|
|
15
|
+
}));
|
|
16
|
+
jest.mock('../lib/TuyaCloudApi', () => jest.fn().mockImplementation(function(cfg) {
|
|
17
|
+
this.endpoint = 'https://openapi.example.com';
|
|
18
|
+
this.cfg = cfg;
|
|
19
|
+
}));
|
|
20
|
+
jest.mock('../lib/TuyaCloudMessaging', () => jest.fn().mockImplementation(function() {}));
|
|
21
|
+
jest.mock('../lib/TuyaDiscovery', () => ({
|
|
22
|
+
start: jest.fn(function() { return this; }),
|
|
23
|
+
on: jest.fn(function() { return this; })
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
const TuyaDevice = require('../lib/TuyaDevice');
|
|
27
|
+
const TuyaCloudApi = require('../lib/TuyaCloudApi');
|
|
28
|
+
const TuyaCloudMessaging = require('../lib/TuyaCloudMessaging');
|
|
29
|
+
const TuyaDiscovery = require('../lib/TuyaDiscovery');
|
|
30
|
+
|
|
31
|
+
const makeHap = () => ({
|
|
32
|
+
Characteristic: class { setProps() { return this; } getDefaultValue() { return 0; } },
|
|
33
|
+
Formats: {FLOAT: 'float', UINT32: 'uint32', UINT16: 'uint16'},
|
|
34
|
+
Perms: {READ: 'pr', NOTIFY: 'ev', WRITE: 'pw'},
|
|
35
|
+
Categories: {OTHER: 1, SWITCH: 8, SPRINKLER: 28},
|
|
36
|
+
Service: {AccessoryInformation: {UUID: '3E'}},
|
|
37
|
+
uuid: {generate: s => 'uuid:' + s}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Load the platform factory and capture the registered platform class.
|
|
41
|
+
const factory = require('../index');
|
|
42
|
+
function getPlatformClass() {
|
|
43
|
+
let cls;
|
|
44
|
+
factory({
|
|
45
|
+
platformAccessory: function() {},
|
|
46
|
+
hap: makeHap(),
|
|
47
|
+
registerPlatform: (pluginName, platformName, c) => { cls = c; }
|
|
48
|
+
});
|
|
49
|
+
return cls;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function makeLog() {
|
|
53
|
+
const log = jest.fn();
|
|
54
|
+
log.info = jest.fn(); log.warn = jest.fn(); log.error = jest.fn(); log.debug = jest.fn();
|
|
55
|
+
return log;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function makeApi() {
|
|
59
|
+
return {hap: makeHap(), on: jest.fn(), registerPlatformAccessories: jest.fn()};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function run(config) {
|
|
63
|
+
const Platform = getPlatformClass();
|
|
64
|
+
const platform = new Platform(makeLog(), config, makeApi());
|
|
65
|
+
platform.addAccessory = jest.fn(); // bypass HAP accessory creation
|
|
66
|
+
platform.discoverDevices();
|
|
67
|
+
return platform;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const propsFor = id => {
|
|
71
|
+
const call = TuyaDevice.mock.calls.find(c => c[0].id === id);
|
|
72
|
+
return call && call[0];
|
|
73
|
+
};
|
|
74
|
+
const instanceFor = id => TuyaDevice.mock.instances.find(i => i.context && i.context.id === id);
|
|
75
|
+
|
|
76
|
+
const SW = (extra = {}) => ({id: 'bf11111111111111', key: 'k1', type: 'switch', name: 'Switch', ...extra});
|
|
77
|
+
// A keyless device can't speak the LAN protocol, so it is cloud-only.
|
|
78
|
+
const SLEEPY = (extra = {}) => ({id: 'bf22222222222222', type: 'irrigationsystem', name: 'Sprinklers', ...extra});
|
|
79
|
+
const CLOUD = {accessId: 'aid', accessKey: 'akey', region: 'eu'};
|
|
80
|
+
|
|
81
|
+
// discoverDevices schedules a long discovery-timeout timer; fake timers keep it
|
|
82
|
+
// from holding the event loop open after the tests finish.
|
|
83
|
+
beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); });
|
|
84
|
+
afterEach(() => { jest.useRealTimers(); });
|
|
85
|
+
|
|
86
|
+
describe('TuyaLan — cloud session setup', () => {
|
|
87
|
+
test('no cloud config → no session, devices are pure-LAN', () => {
|
|
88
|
+
run({devices: [SW()]});
|
|
89
|
+
expect(TuyaCloudApi).not.toHaveBeenCalled();
|
|
90
|
+
expect(propsFor('bf11111111111111').cloudApi).toBeUndefined();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('a top-level cloud block creates the single shared session', () => {
|
|
94
|
+
run({cloud: CLOUD, devices: [SW()]});
|
|
95
|
+
expect(TuyaCloudApi).toHaveBeenCalledTimes(1);
|
|
96
|
+
expect(TuyaCloudApi.mock.calls[0][0]).toMatchObject({accessId: 'aid', accessKey: 'akey', region: 'eu'});
|
|
97
|
+
expect(TuyaCloudMessaging).toHaveBeenCalledTimes(1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('realtime:false skips the MQTT session', () => {
|
|
101
|
+
run({cloud: {...CLOUD, realtime: false}, devices: [SW()]});
|
|
102
|
+
expect(TuyaCloudApi).toHaveBeenCalledTimes(1);
|
|
103
|
+
expect(TuyaCloudMessaging).not.toHaveBeenCalled();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('TuyaLan — cloud participation', () => {
|
|
108
|
+
test('with a session, every device shares the one global fallback', () => {
|
|
109
|
+
run({cloud: CLOUD, devices: [SW(), SLEEPY()]});
|
|
110
|
+
expect(propsFor('bf11111111111111').cloudApi).toBeDefined(); // LAN device, cloud fallback
|
|
111
|
+
expect(propsFor('bf22222222222222').cloudApi).toBeDefined(); // keyless, cloud-only
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('without a session, no device gets a cloud backend', () => {
|
|
115
|
+
run({devices: [SW()]});
|
|
116
|
+
expect(propsFor('bf11111111111111').cloudApi).toBeUndefined();
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('TuyaLan — discovery routing', () => {
|
|
121
|
+
test('only keyed devices are discovered; keyless ones are cloud-only', () => {
|
|
122
|
+
run({cloud: CLOUD, devices: [SW(), SLEEPY()]});
|
|
123
|
+
expect(TuyaDiscovery.start).toHaveBeenCalledTimes(1);
|
|
124
|
+
expect(TuyaDiscovery.start.mock.calls[0][0].ids).toEqual(['bf11111111111111']);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('a discovered device is handed its LAN target via attachLan', () => {
|
|
128
|
+
run({cloud: CLOUD, devices: [SW()]});
|
|
129
|
+
// capture the 'discover' handler registered on the discovery emitter
|
|
130
|
+
const onDiscover = TuyaDiscovery.on.mock.calls.find(c => c[0] === 'discover');
|
|
131
|
+
expect(onDiscover).toBeDefined();
|
|
132
|
+
onDiscover[1]({id: 'bf11111111111111', ip: '10.0.0.7', version: '3.3'});
|
|
133
|
+
const inst = instanceFor('bf11111111111111');
|
|
134
|
+
expect(inst.attachLan).toHaveBeenCalledWith({ip: '10.0.0.7', version: '3.3'});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('a cloud-only configuration starts no LAN discovery', () => {
|
|
138
|
+
run({cloud: CLOUD, devices: [SLEEPY()]});
|
|
139
|
+
expect(TuyaDiscovery.start).not.toHaveBeenCalled();
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -353,7 +353,7 @@ These are switches that allow turning on and off, and dimming. Two distinct type
|
|
|
353
353
|
|
|
354
354
|
The following options apply to `WledDimmer` only (they are ignored by `SimpleDimmer`):
|
|
355
355
|
|
|
356
|
-
- `syncBrightnessToWled`: set to the WLED device IP (e.g. "192.168.1.50" or "192.168.1.50:80") to sync HomeKit brightness changes directly to WLED over HTTP, keeping the Tuya dimmer at 100%.
|
|
356
|
+
- `syncBrightnessToWled`: set to the WLED device IP (e.g. "192.168.1.50" or "192.168.1.50:80") to sync HomeKit brightness changes directly to WLED over HTTP, keeping the Tuya dimmer at 100%. This talks to the WLED controller directly on your **LAN**, independently of how the Tuya dimmer is reached — so if the Tuya dimmer is ever on the cloud fallback (because the LAN is down), the WLED sync is best-effort and may not go through until the LAN is back.
|
|
357
357
|
- `presetEffects`: array of effect configs to expose as switches in HomeKit (each turns on a WLED fx preset, optionally with staticColor).
|
|
358
358
|
|
|
359
359
|
```json5
|
|
@@ -500,9 +500,10 @@ external stop, where close is accepted immediately), a close is sent as
|
|
|
500
500
|
/* Optional. If set, exposes an extra stateful switch that mirrors
|
|
501
501
|
whether the gate is currently open in HomeKit's view. Tapping it
|
|
502
502
|
ON triggers a partial-open: the gate opens and then stops itself
|
|
503
|
-
this many milliseconds
|
|
504
|
-
Tapping it OFF triggers a standard full close.
|
|
505
|
-
someone pass through briefly. Leave unset to skip
|
|
503
|
+
this many milliseconds after it actually starts moving, leaving the
|
|
504
|
+
gate partially open. Tapping it OFF triggers a standard full close.
|
|
505
|
+
Useful for letting someone pass through briefly. Leave unset to skip
|
|
506
|
+
the switch. */
|
|
506
507
|
"partialOpenMs": 2000,
|
|
507
508
|
|
|
508
509
|
/* Optional. Exposes extra Force Open and Force Close momentary
|
package/wiki/Tuya-Cloud-Setup.md
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
# Tuya Cloud Setup
|
|
2
2
|
|
|
3
|
-
This plugin is **LAN-first** —
|
|
3
|
+
This plugin is **LAN-first** — every Tuya device is controlled locally whenever it can be. Adding your Tuya Cloud credentials turns the cloud into a **transparent fallback for every device**: each accessory tries the LAN first, and only falls back to the cloud when the device can't be reached locally. It's **opt-in** (nothing happens unless you add credentials) and **local stays the preferred path**.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
It helps in two situations:
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
> **Devices that are never on the LAN.** Battery-powered **"sleepy"** devices — most notably multi-zone **irrigation / faucet timers** — sleep almost all the time to save battery and only ever connect *outbound* to Tuya's cloud (over MQTT) for a brief moment when they wake. They never keep the local port open and never answer LAN discovery, so the local protocol can't reach them. (Tuya's own developer docs state LAN control is unavailable in low-power mode.)
|
|
8
|
+
|
|
9
|
+
> **Devices that occasionally drop off the LAN.** If a normally-local device sometimes shows "No Response" in HomeKit, the cloud fallback quietly covers those moments — local control still runs first.
|
|
8
10
|
|
|
9
11
|
* Initial state + control go through the Tuya OpenAPI (signed HTTPS).
|
|
10
12
|
* Live updates (including physical button presses) arrive over Tuya's **MQTT** message service — no polling.
|
|
@@ -42,7 +44,9 @@ Still in the project: **Devices → Link App Account → Add App Account**, then
|
|
|
42
44
|
|
|
43
45
|
## 5. Configure the plugin
|
|
44
46
|
|
|
45
|
-
Add a **top-level `cloud` block** with your credentials
|
|
47
|
+
Add a **top-level `cloud` block** with your credentials. That's all that's needed — every device then tries the LAN first and uses the cloud as a backup.
|
|
48
|
+
|
|
49
|
+
A device that is **never** reachable on the LAN (a battery-powered "sleepy" timer) simply has **no local `key`**: without one it can't speak the LAN protocol, so the plugin reaches it through the cloud session. There are two project styles:
|
|
46
50
|
|
|
47
51
|
### Smart Home project (recommended — what most people have)
|
|
48
52
|
|
|
@@ -65,8 +69,8 @@ Authenticates as your app account (username/password), so it sees exactly the de
|
|
|
65
69
|
"name": "Garden Irrigation",
|
|
66
70
|
"type": "IrrigationSystem",
|
|
67
71
|
"id": "bfae6739xxxxxxxxxxxxxx", // the cloud Device ID
|
|
68
|
-
"cloud": true,
|
|
69
72
|
"valveCount": 4
|
|
73
|
+
// no "key" -> this device is reached over the cloud
|
|
70
74
|
}
|
|
71
75
|
]
|
|
72
76
|
}
|
|
@@ -84,36 +88,18 @@ If you created a **Custom** project (devices linked by QR to the project's asset
|
|
|
84
88
|
}
|
|
85
89
|
```
|
|
86
90
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
Instead of (or in addition to) the platform block, a device can carry its own credentials — handy if different devices live in different Tuya projects:
|
|
90
|
-
|
|
91
|
-
```json5
|
|
92
|
-
{
|
|
93
|
-
"name": "Garden Irrigation",
|
|
94
|
-
"type": "IrrigationSystem",
|
|
95
|
-
"id": "bfae6739xxxxxxxxxxxxxx",
|
|
96
|
-
"cloud": {
|
|
97
|
-
"accessId": "…",
|
|
98
|
-
"accessKey": "…",
|
|
99
|
-
"region": "eu",
|
|
100
|
-
"username": "…",
|
|
101
|
-
"password": "…",
|
|
102
|
-
"countryCode": "48"
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
```
|
|
106
|
-
|
|
107
|
-
> No local `key` is needed for cloud devices — the cloud authenticates with your project credentials.
|
|
91
|
+
> There is one global session. The plugin doesn't support per-device cloud credentials or multiple Tuya accounts — put your credentials in the single top-level `cloud` block. A cloud-only device just omits its local `key`.
|
|
108
92
|
|
|
109
93
|
---
|
|
110
94
|
|
|
111
|
-
## Data-points
|
|
95
|
+
## Data-points: numbers on the LAN, codes on the cloud
|
|
96
|
+
|
|
97
|
+
Over the LAN, data-points are numbered (1, 2, …). Over the **cloud** they're named **codes** (e.g. `switch_1`, `battery_percentage`). You normally don't need to care which is which: when the cloud session connects, the plugin reads the device's *thing shadow*, which lists both the code **and** the numeric id for each data-point, and learns the mapping. From then on a configuration written either way works over either transport — a numeric-DP config falls back to the cloud, and a code-based config works on the LAN.
|
|
112
98
|
|
|
113
|
-
|
|
99
|
+
When a cloud device connects, the plugin **logs the data-points** it reports (code and numeric id), e.g.:
|
|
114
100
|
|
|
115
101
|
```
|
|
116
|
-
Garden Irrigation: Tuya Cloud data-
|
|
102
|
+
Garden Irrigation: Tuya Cloud data-points → switch_1(dp 1)=false, switch_2(dp 2)=false, switch_3(dp 3)=false, switch_4(dp 4)=false, countdown_1(dp 5)=0, …, battery_percentage(dp 46)=99
|
|
117
103
|
```
|
|
118
104
|
|
|
119
105
|
The `IrrigationSystem` defaults already match the common 4-zone layout (`switch_1`…`switch_4`, battery `battery_percentage`). If your device differs, use the logged codes:
|