homebridge-tuya-plus 3.14.0-dev.18 → 3.14.0-dev.20
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 +5 -14
- package/index.js +349 -358
- package/lib/IrrigationSystemAccessory.js +12 -23
- package/lib/SimpleDimmerAccessory.js +9 -5
- package/lib/SimpleGarageDoorAccessory.js +9 -9
- package/lib/TuyaCloudApi.js +21 -0
- package/lib/TuyaCloudDevice.js +86 -15
- package/lib/TuyaDevice.js +266 -0
- package/lib/ValveAccessory.js +7 -7
- package/lib/WledDimmerAccessory.js +43 -80
- package/package.json +2 -2
- package/test/IrrigationSystemAccessory.test.js +44 -49
- package/test/TuyaCloudApi.test.js +17 -0
- package/test/TuyaCloudDevice.test.js +49 -0
- package/test/TuyaDevice.test.js +252 -0
- package/test/getCategory.test.js +1 -0
- package/test/index.test.js +141 -0
- package/wiki/Supported-Device-Types.md +8 -6
- package/wiki/Tuya-Cloud-Setup.md +15 -29
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The LAN backend opens real TCP sockets; stub it so attachLan() can be tested
|
|
4
|
+
// without touching the network. Cloud backend stays real (it's inert with
|
|
5
|
+
// connect:false).
|
|
6
|
+
jest.mock('../lib/TuyaAccessory', () => jest.fn().mockImplementation(function(props) {
|
|
7
|
+
this.context = {...props};
|
|
8
|
+
this.connected = false;
|
|
9
|
+
this.state = {};
|
|
10
|
+
this.update = jest.fn().mockReturnValue(true);
|
|
11
|
+
this.on = jest.fn();
|
|
12
|
+
this._connect = jest.fn();
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
const EventEmitter = require('events');
|
|
16
|
+
const TuyaDevice = require('../lib/TuyaDevice');
|
|
17
|
+
const TuyaCloudDevice = require('../lib/TuyaCloudDevice');
|
|
18
|
+
|
|
19
|
+
const log = {info: () => {}, warn: () => {}, error: () => {}, debug: () => {}};
|
|
20
|
+
|
|
21
|
+
// A minimal stand-in for either backend (TuyaAccessory / TuyaCloudDevice): an
|
|
22
|
+
// EventEmitter exposing the surface TuyaDevice routes through.
|
|
23
|
+
function fakeBackend(extra = {}) {
|
|
24
|
+
const b = new EventEmitter();
|
|
25
|
+
b.connected = false;
|
|
26
|
+
b.state = {};
|
|
27
|
+
b.update = jest.fn().mockReturnValue(true);
|
|
28
|
+
b.codeByDpId = {};
|
|
29
|
+
b.dpIdByCode = {};
|
|
30
|
+
b._connect = jest.fn();
|
|
31
|
+
return Object.assign(b, extra);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function makeDevice(props = {}) {
|
|
35
|
+
return new TuyaDevice({id: 'dev1', key: 'k', name: 'Lamp', type: 'switch', log, connect: false, ...props});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Attach fake backends to a device and wire their events (white-box, so the merge
|
|
39
|
+
// / fallback logic can be exercised without any real network).
|
|
40
|
+
function withBackends(dev, {lan, cloud} = {}) {
|
|
41
|
+
if (cloud) { dev.cloud = cloud; dev._wire(cloud, 'cloud'); }
|
|
42
|
+
if (lan) { dev.lan = lan; dev._wire(lan, 'lan'); }
|
|
43
|
+
return dev;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('TuyaDevice — connectivity', () => {
|
|
47
|
+
test('connected reflects either backend', () => {
|
|
48
|
+
const dev = makeDevice();
|
|
49
|
+
const lan = fakeBackend();
|
|
50
|
+
const cloud = fakeBackend();
|
|
51
|
+
withBackends(dev, {lan, cloud});
|
|
52
|
+
|
|
53
|
+
expect(dev.connected).toBe(false);
|
|
54
|
+
lan.connected = true;
|
|
55
|
+
expect(dev.connected).toBe(true);
|
|
56
|
+
lan.connected = false;
|
|
57
|
+
cloud.connected = true;
|
|
58
|
+
expect(dev.connected).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("'connect' is emitted once, on the first backend to connect", () => {
|
|
62
|
+
const dev = makeDevice();
|
|
63
|
+
const lan = fakeBackend();
|
|
64
|
+
const cloud = fakeBackend();
|
|
65
|
+
withBackends(dev, {lan, cloud});
|
|
66
|
+
|
|
67
|
+
let connects = 0;
|
|
68
|
+
dev.on('connect', () => connects++);
|
|
69
|
+
lan.emit('connect');
|
|
70
|
+
cloud.emit('connect');
|
|
71
|
+
expect(connects).toBe(1);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('TuyaDevice — pure LAN (no cloud) preserves legacy behaviour', () => {
|
|
76
|
+
test('update returns the synchronous boolean from the LAN backend', () => {
|
|
77
|
+
const dev = makeDevice();
|
|
78
|
+
const lan = fakeBackend({connected: true});
|
|
79
|
+
withBackends(dev, {lan});
|
|
80
|
+
|
|
81
|
+
lan.update.mockReturnValue(true);
|
|
82
|
+
expect(dev.update({'1': false})).toBe(true);
|
|
83
|
+
expect(lan.update).toHaveBeenCalledWith({'1': false});
|
|
84
|
+
|
|
85
|
+
lan.update.mockReturnValue(false);
|
|
86
|
+
expect(dev.update({'1': true})).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('update is false when there is no transport at all', () => {
|
|
90
|
+
const dev = makeDevice();
|
|
91
|
+
expect(dev.update({'1': true})).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('TuyaDevice — state merge & registration', () => {
|
|
96
|
+
test('first change drives registration and exposes the merged state', () => {
|
|
97
|
+
const dev = makeDevice();
|
|
98
|
+
const lan = fakeBackend({connected: true, state: {'1': true, '2': 50}});
|
|
99
|
+
withBackends(dev, {lan});
|
|
100
|
+
|
|
101
|
+
const seen = [];
|
|
102
|
+
dev.on('change', (changes, state) => seen.push({changes, state}));
|
|
103
|
+
lan.emit('change');
|
|
104
|
+
|
|
105
|
+
expect(seen).toHaveLength(1);
|
|
106
|
+
expect(seen[0].state).toEqual({'1': true, '2': 50});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('LAN wins over the cloud while connected; cloud-only DPs are kept; LAN is mirrored to codes', () => {
|
|
110
|
+
const dev = makeDevice();
|
|
111
|
+
const lan = fakeBackend({connected: true, state: {'1': true}});
|
|
112
|
+
const cloud = fakeBackend({
|
|
113
|
+
connected: true,
|
|
114
|
+
state: {'switch_led': false, '1': false, 'battery_percentage': 80},
|
|
115
|
+
codeByDpId: {'1': 'switch_led'},
|
|
116
|
+
dpIdByCode: {'switch_led': '1'}
|
|
117
|
+
});
|
|
118
|
+
withBackends(dev, {lan, cloud});
|
|
119
|
+
|
|
120
|
+
lan.emit('change');
|
|
121
|
+
expect(dev.state['1']).toBe(true); // LAN value wins
|
|
122
|
+
expect(dev.state['switch_led']).toBe(true); // …mirrored to its code
|
|
123
|
+
expect(dev.state['battery_percentage']).toBe(80); // cloud-only DP retained
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('when the LAN drops, the cloud takes over and the change is emitted', () => {
|
|
127
|
+
const dev = makeDevice();
|
|
128
|
+
const lan = fakeBackend({connected: true, state: {'1': true}});
|
|
129
|
+
const cloud = fakeBackend({connected: true, state: {'1': false}});
|
|
130
|
+
withBackends(dev, {lan, cloud});
|
|
131
|
+
|
|
132
|
+
lan.emit('change');
|
|
133
|
+
expect(dev.state['1']).toBe(true);
|
|
134
|
+
|
|
135
|
+
lan.connected = false; // LAN lost
|
|
136
|
+
const seen = [];
|
|
137
|
+
dev.on('change', changes => seen.push(changes));
|
|
138
|
+
cloud.emit('change');
|
|
139
|
+
|
|
140
|
+
expect(dev.state['1']).toBe(false);
|
|
141
|
+
expect(seen[0]).toEqual({'1': false});
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('TuyaDevice — writes with fallback', () => {
|
|
146
|
+
test('LAN is used first when both backends are up', async () => {
|
|
147
|
+
const dev = makeDevice();
|
|
148
|
+
const lan = fakeBackend({connected: true});
|
|
149
|
+
const cloud = fakeBackend({connected: true, update: jest.fn().mockResolvedValue(true)});
|
|
150
|
+
withBackends(dev, {lan, cloud});
|
|
151
|
+
|
|
152
|
+
await expect(dev.update({'1': true})).resolves.toBe(true);
|
|
153
|
+
expect(lan.update).toHaveBeenCalledWith({'1': true});
|
|
154
|
+
expect(cloud.update).not.toHaveBeenCalled();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('a failed LAN write falls back to the cloud', async () => {
|
|
158
|
+
const dev = makeDevice();
|
|
159
|
+
const lan = fakeBackend({connected: true, update: jest.fn().mockReturnValue(false)});
|
|
160
|
+
const cloud = fakeBackend({connected: true, update: jest.fn().mockResolvedValue(true)});
|
|
161
|
+
withBackends(dev, {lan, cloud});
|
|
162
|
+
|
|
163
|
+
await expect(dev.update({'1': true})).resolves.toBe(true);
|
|
164
|
+
expect(cloud.update).toHaveBeenCalledWith({'1': true});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('with the LAN down, writes go straight to the cloud (raw keys; the cloud translates)', async () => {
|
|
168
|
+
const dev = makeDevice();
|
|
169
|
+
const cloud = fakeBackend({connected: true, update: jest.fn().mockResolvedValue(true)});
|
|
170
|
+
withBackends(dev, {cloud});
|
|
171
|
+
|
|
172
|
+
await expect(dev.update({'switch_led': true})).resolves.toBe(true);
|
|
173
|
+
expect(cloud.update).toHaveBeenCalledWith({'switch_led': true});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('a code-style write is translated to numeric ids for the LAN', async () => {
|
|
177
|
+
const dev = makeDevice();
|
|
178
|
+
const lan = fakeBackend({connected: true, update: jest.fn().mockReturnValue(true)});
|
|
179
|
+
const cloud = fakeBackend({connected: true, dpIdByCode: {'switch_led': '1'}, codeByDpId: {'1': 'switch_led'}});
|
|
180
|
+
withBackends(dev, {lan, cloud});
|
|
181
|
+
|
|
182
|
+
await dev.update({'switch_led': true});
|
|
183
|
+
expect(lan.update).toHaveBeenCalledWith({'1': true});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('update resolves false when nothing is reachable', async () => {
|
|
187
|
+
const dev = makeDevice();
|
|
188
|
+
const cloud = fakeBackend({connected: false, update: jest.fn()});
|
|
189
|
+
withBackends(dev, {cloud});
|
|
190
|
+
await expect(dev.update({'1': true})).resolves.toBe(false);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe('TuyaDevice — registration source guard', () => {
|
|
195
|
+
test('a LAN-primary device defers cloud-driven registration until it has the map or the LAN grace elapses', () => {
|
|
196
|
+
const dev = makeDevice(); // has a key → LAN-primary
|
|
197
|
+
dev.cloud = fakeBackend({codeByDpId: {}}); // cloud, but no numeric map yet
|
|
198
|
+
|
|
199
|
+
expect(dev._mayRegisterFrom('lan')).toBe(true);
|
|
200
|
+
expect(dev._mayRegisterFrom('cloud')).toBe(false);
|
|
201
|
+
|
|
202
|
+
dev.cloud.codeByDpId = {'1': 'switch_led'}; // map learned → safe
|
|
203
|
+
expect(dev._mayRegisterFrom('cloud')).toBe(true);
|
|
204
|
+
|
|
205
|
+
dev.cloud.codeByDpId = {};
|
|
206
|
+
dev._lanGraceElapsed = true; // …or the LAN had its head start
|
|
207
|
+
expect(dev._mayRegisterFrom('cloud')).toBe(true);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('a keyless (cloud-only) device registers off the cloud immediately', () => {
|
|
211
|
+
const keyless = makeDevice({key: undefined});
|
|
212
|
+
keyless.cloud = fakeBackend({codeByDpId: {}});
|
|
213
|
+
expect(keyless._mayRegisterFrom('cloud')).toBe(true);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe('TuyaDevice — composition', () => {
|
|
218
|
+
test('a cloud backend is built when a shared cloud session is supplied', () => {
|
|
219
|
+
const api = {
|
|
220
|
+
isConfigured: () => true,
|
|
221
|
+
getStatus: jest.fn().mockResolvedValue([]),
|
|
222
|
+
getDeviceInfo: jest.fn().mockResolvedValue({online: true}),
|
|
223
|
+
sendCommands: jest.fn()
|
|
224
|
+
};
|
|
225
|
+
const dev = makeDevice({cloudApi: api});
|
|
226
|
+
expect(dev.cloud).toBeInstanceOf(TuyaCloudDevice);
|
|
227
|
+
expect(dev.lan).toBeNull();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test('no cloud backend without a shared session', () => {
|
|
231
|
+
const dev = makeDevice();
|
|
232
|
+
expect(dev.cloud).toBeNull();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('attachLan builds the LAN backend with the discovered version (forceVersion still wins)', () => {
|
|
236
|
+
const dev = makeDevice({ip: '10.0.0.5'});
|
|
237
|
+
dev.attachLan({ip: '10.0.0.9', version: '3.3'});
|
|
238
|
+
expect(dev.lan).not.toBeNull();
|
|
239
|
+
expect(dev.lan.context.ip).toBe('10.0.0.9');
|
|
240
|
+
expect(dev.lan.context.version).toBe('3.3');
|
|
241
|
+
|
|
242
|
+
const forced = makeDevice({forceVersion: '3.5'});
|
|
243
|
+
forced.attachLan({ip: '10.0.0.9', version: '3.3'});
|
|
244
|
+
expect(forced.lan.context.version).toBe('3.5');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('attachLan is a no-op without a local key (cloud-only device)', () => {
|
|
248
|
+
const dev = makeDevice({key: undefined});
|
|
249
|
+
dev.attachLan({ip: '10.0.0.9'});
|
|
250
|
+
expect(dev.lan).toBeNull();
|
|
251
|
+
});
|
|
252
|
+
});
|
package/test/getCategory.test.js
CHANGED
|
@@ -23,6 +23,7 @@ const CLASS_DEF = {
|
|
|
23
23
|
garagedoor: require('../lib/GarageDoorAccessory'),
|
|
24
24
|
simplegaragedoor: require('../lib/SimpleGarageDoorAccessory'),
|
|
25
25
|
simpledimmer: require('../lib/SimpleDimmerAccessory'),
|
|
26
|
+
wleddimmer: require('../lib/WledDimmerAccessory'),
|
|
26
27
|
simpledimmer2: require('../lib/SimpleDimmer2Accessory'),
|
|
27
28
|
simpleblinds: require('../lib/SimpleBlindsAccessory'),
|
|
28
29
|
simpleheater: require('../lib/SimpleHeaterAccessory'),
|
|
@@ -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
|
+
});
|
|
@@ -15,7 +15,8 @@ If you are looking for verified configurations for your specific device, please
|
|
|
15
15
|
|Barely Smart Power Strip|`Outlet`|Smart power strips that don't allow individual control of the outlets|
|
|
16
16
|
|Air Conditioner|`AirConditioner`<sup>[6](#air-conditioners)</sup>|Cooling and heating devices <small>([instructions](#air-conditioners))</small>|
|
|
17
17
|
|Heat Convector|`Convector`<sup>[7](#heat-convectors)</sup>|Heating panels <small>([instructions](#heat-convectors))</small>|
|
|
18
|
-
|
|
|
18
|
+
|Simple Dimmer|`SimpleDimmer`<sup>[8](#simple-dimmers)</sup>|Dimmer switches with power control <small>([instructions](#simple-dimmers))</small>|
|
|
19
|
+
|WLED Dimmer|`WledDimmer`<sup>[8](#simple-dimmers)</sup>|Dimmer switches with power control, plus optional WLED brightness sync and preset-effect switches <small>([instructions](#simple-dimmers))</small>|
|
|
19
20
|
|Simple Heater|`SimpleHeater`<sup>[9](#simple-heaters)</sup>|Heating solutions with only temperature control <small>([instructions](#simple-heaters))</small>|
|
|
20
21
|
|Garage Door|`GarageDoor`<sup>[10](#garage-doors)</sup>|Smart garage doors or garage door openers <small>([instructions](#garage-doors))</small>|
|
|
21
22
|
|Simple Garage Door|`SimpleGarageDoor`<sup>[10](#simple-garage-doors)</sup>|Sliding gate openers and garage door controllers with open/stop/close action DPs and a simple three-value status DP <small>([instructions](#simple-garage-doors))</small>|
|
|
@@ -345,14 +346,15 @@ If your signature doesn't have a variation of _low_ or _high_, `SimpleHeater` wo
|
|
|
345
346
|
```
|
|
346
347
|
|
|
347
348
|
### Simple Dimmers / WLED Dimmers
|
|
348
|
-
These are switches that allow turning on and off, and dimming.
|
|
349
|
+
These are switches that allow turning on and off, and dimming. Two distinct types are available:
|
|
349
350
|
|
|
350
|
-
|
|
351
|
+
- `SimpleDimmer` — a plain dimmer with power and brightness control.
|
|
352
|
+
- `WledDimmer` — a dimmer that can additionally drive a [WLED](https://kno.wled.ge/) controller (e.g. a Tuya-based relay/dimmer feeding power to a WLED strip). With none of the WLED options below configured, it behaves exactly like a `SimpleDimmer`.
|
|
351
353
|
|
|
352
|
-
|
|
353
|
-
- `presetEffects`: array of effect configs to expose as switches in HomeKit (each turns on a WLED fx preset, optionally with staticColor).
|
|
354
|
+
The following options apply to `WledDimmer` only (they are ignored by `SimpleDimmer`):
|
|
354
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%. 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
|
+
- `presetEffects`: array of effect configs to expose as switches in HomeKit (each turns on a WLED fx preset, optionally with staticColor).
|
|
356
358
|
|
|
357
359
|
```json5
|
|
358
360
|
{
|
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:
|