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.
@@ -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
+ });
@@ -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
- |WLED Dimmer|`WledDimmer` (or legacy `SimpleDimmer`)<sup>[8](#simple-dimmers)</sup>|Dimmer switches with power control, with optional WLED sync <small>([instructions](#simple-dimmers))</small>|
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. Use type `WledDimmer` (legacy `SimpleDimmer` alias is also supported for backward compatibility).
349
+ These are switches that allow turning on and off, and dimming. Two distinct types are available:
349
350
 
350
- When using with a WLED controller (e.g. a Tuya-based relay/dimmer feeding power to a WLED strip), you can use advanced sync options:
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
- - `syncBrightnessToWled`: set to 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%.
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
- See the accessory source for full details on WLED integration.
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
  {
@@ -1,10 +1,12 @@
1
1
  # Tuya Cloud Setup
2
2
 
3
- This plugin is **LAN-first** — almost every Tuya device is controlled locally. But a few devices **cannot** be reached over the LAN at all:
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
- > 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.)
5
+ It helps in two situations:
6
6
 
7
- For these devices the plugin can talk to the **Tuya Cloud** instead. It is **opt-in, per device** — your other devices stay 100% local.
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, and set **`"cloud": true`** on the device. There are two project styles:
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
- ### Per-device credentials (optional)
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 are addressed by "code" on the cloud
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
- Over the LAN, data-points are numbered (1, 2, …). Over the **cloud** they're named **codes** (e.g. `switch_1`, `battery_percentage`). When a cloud device connects, the plugin **logs the exact codes** it reports, e.g.:
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-point codes → switch_1=false, switch_2=false, switch_3=false, switch_4=false, countdown_1=0, …, battery_percentage=99
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: