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.
Files changed (47) hide show
  1. package/.github/workflows/publish-dev.yml +298 -31
  2. package/AGENTS.md +120 -0
  3. package/CLAUDE.md +1 -0
  4. package/Changelog.md +21 -0
  5. package/Readme.MD +8 -24
  6. package/config.schema.json +122 -68
  7. package/index.js +498 -269
  8. package/lib/BaseAccessory.js +52 -15
  9. package/lib/ConvectorAccessory.js +1 -1
  10. package/lib/CustomMultiOutletAccessory.js +2 -1
  11. package/lib/IrrigationSystemAccessory.js +98 -95
  12. package/lib/MultiOutletAccessory.js +2 -1
  13. package/lib/OilDiffuserAccessory.js +10 -8
  14. package/lib/RGBTWLightAccessory.js +13 -11
  15. package/lib/RGBTWOutletAccessory.js +11 -9
  16. package/lib/SimpleBlindsAccessory.js +5 -5
  17. package/lib/SimpleDimmer2Accessory.js +1 -1
  18. package/lib/SimpleDimmerAccessory.js +10 -6
  19. package/lib/SimpleGarageDoorAccessory.js +78 -24
  20. package/lib/SimpleHeaterAccessory.js +3 -3
  21. package/lib/SwitchAccessory.js +2 -1
  22. package/lib/TWLightAccessory.js +2 -2
  23. package/lib/TuyaAccessory.js +0 -1
  24. package/lib/TuyaCloudApi.js +320 -0
  25. package/lib/TuyaCloudDevice.js +327 -0
  26. package/lib/TuyaCloudMessaging.js +219 -0
  27. package/lib/TuyaDevice.js +266 -0
  28. package/lib/ValveAccessory.js +16 -12
  29. package/lib/VerticalBlindsWithTilt.js +5 -3
  30. package/lib/WledDimmerAccessory.js +46 -81
  31. package/package.json +5 -3
  32. package/scripts/cleanup-pr-tags.js +122 -0
  33. package/test/BaseAccessory.test.js +94 -15
  34. package/test/IrrigationSystemAccessory.test.js +134 -31
  35. package/test/MultiOutletAccessory.test.js +18 -3
  36. package/test/RGBTWLightAccessory.test.js +3 -3
  37. package/test/SimpleFanLightAccessory.test.js +3 -3
  38. package/test/SimpleGarageDoorAccessory.test.js +63 -9
  39. package/test/TuyaCloudApi.test.js +221 -0
  40. package/test/TuyaCloudDevice.test.js +304 -0
  41. package/test/TuyaCloudMessaging.test.js +113 -0
  42. package/test/TuyaDevice.test.js +252 -0
  43. package/test/getCategory.test.js +1 -0
  44. package/test/index.test.js +183 -0
  45. package/test/support/mocks.js +13 -0
  46. package/wiki/Supported-Device-Types.md +60 -28
  47. package/wiki/Tuya-Cloud-Setup.md +71 -0
@@ -0,0 +1,183 @@
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 TuyaAccessory = require('../lib/TuyaAccessory');
28
+ const TuyaCloudApi = require('../lib/TuyaCloudApi');
29
+ const TuyaCloudMessaging = require('../lib/TuyaCloudMessaging');
30
+ const TuyaDiscovery = require('../lib/TuyaDiscovery');
31
+
32
+ const makeHap = () => ({
33
+ Characteristic: class { setProps() { return this; } getDefaultValue() { return 0; } },
34
+ Formats: {FLOAT: 'float', UINT32: 'uint32', UINT16: 'uint16'},
35
+ Perms: {READ: 'pr', NOTIFY: 'ev', WRITE: 'pw'},
36
+ Categories: {OTHER: 1, SWITCH: 8, SPRINKLER: 28},
37
+ Service: {AccessoryInformation: {UUID: '3E'}},
38
+ uuid: {generate: s => 'uuid:' + s}
39
+ });
40
+
41
+ // Load the platform factory and capture the registered platform class.
42
+ const factory = require('../index');
43
+ function getPlatformClass() {
44
+ let cls;
45
+ factory({
46
+ platformAccessory: function() {},
47
+ hap: makeHap(),
48
+ registerPlatform: (pluginName, platformName, c) => { cls = c; }
49
+ });
50
+ return cls;
51
+ }
52
+
53
+ function makeLog() {
54
+ const log = jest.fn();
55
+ log.info = jest.fn(); log.warn = jest.fn(); log.error = jest.fn(); log.debug = jest.fn();
56
+ return log;
57
+ }
58
+
59
+ function makeApi() {
60
+ return {hap: makeHap(), on: jest.fn(), registerPlatformAccessories: jest.fn(), unregisterPlatformAccessories: jest.fn()};
61
+ }
62
+
63
+ function run(config) {
64
+ const Platform = getPlatformClass();
65
+ const platform = new Platform(makeLog(), config, makeApi());
66
+ platform.addAccessory = jest.fn(); // bypass HAP accessory creation
67
+ platform.discoverDevices();
68
+ return platform;
69
+ }
70
+
71
+ const propsFor = id => {
72
+ const call = TuyaDevice.mock.calls.find(c => c[0].id === id);
73
+ return call && call[0];
74
+ };
75
+ const instanceFor = id => TuyaDevice.mock.instances.find(i => i.context && i.context.id === id);
76
+
77
+ const SW = (extra = {}) => ({id: 'bf11111111111111', key: 'k1', type: 'switch', name: 'Switch', ...extra});
78
+ // A keyless device can't speak the LAN protocol, so it is cloud-only.
79
+ const SLEEPY = (extra = {}) => ({id: 'bf22222222222222', type: 'irrigationsystem', name: 'Sprinklers', ...extra});
80
+ const CLOUD = {accessId: 'aid', accessKey: 'akey', region: 'eu'};
81
+
82
+ // discoverDevices schedules a long discovery-timeout timer; fake timers keep it
83
+ // from holding the event loop open after the tests finish.
84
+ beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); });
85
+ afterEach(() => { jest.useRealTimers(); });
86
+
87
+ describe('TuyaLan — cloud session setup', () => {
88
+ test('no cloud config → no session, devices are pure-LAN', () => {
89
+ run({devices: [SW()]});
90
+ expect(TuyaCloudApi).not.toHaveBeenCalled();
91
+ expect(propsFor('bf11111111111111').cloudApi).toBeUndefined();
92
+ });
93
+
94
+ test('a top-level cloud block creates the single shared session', () => {
95
+ run({cloud: CLOUD, devices: [SW()]});
96
+ expect(TuyaCloudApi).toHaveBeenCalledTimes(1);
97
+ expect(TuyaCloudApi.mock.calls[0][0]).toMatchObject({accessId: 'aid', accessKey: 'akey', region: 'eu'});
98
+ expect(TuyaCloudMessaging).toHaveBeenCalledTimes(1);
99
+ });
100
+ });
101
+
102
+ describe('TuyaLan — cloud participation', () => {
103
+ test('with a session, every device shares the one global fallback', () => {
104
+ run({cloud: CLOUD, devices: [SW(), SLEEPY()]});
105
+ expect(propsFor('bf11111111111111').cloudApi).toBeDefined(); // LAN device, cloud fallback
106
+ expect(propsFor('bf22222222222222').cloudApi).toBeDefined(); // keyless, cloud-only
107
+ });
108
+
109
+ test('without a session, no device gets a cloud backend', () => {
110
+ run({devices: [SW()]});
111
+ expect(propsFor('bf11111111111111').cloudApi).toBeUndefined();
112
+ });
113
+ });
114
+
115
+ describe('TuyaLan — discovery routing', () => {
116
+ test('only keyed devices are discovered; keyless ones are cloud-only', () => {
117
+ run({cloud: CLOUD, devices: [SW(), SLEEPY()]});
118
+ expect(TuyaDiscovery.start).toHaveBeenCalledTimes(1);
119
+ expect(TuyaDiscovery.start.mock.calls[0][0].ids).toEqual(['bf11111111111111']);
120
+ });
121
+
122
+ test('a discovered device is handed its LAN target via attachLan', () => {
123
+ run({cloud: CLOUD, devices: [SW()]});
124
+ // capture the 'discover' handler registered on the discovery emitter
125
+ const onDiscover = TuyaDiscovery.on.mock.calls.find(c => c[0] === 'discover');
126
+ expect(onDiscover).toBeDefined();
127
+ onDiscover[1]({id: 'bf11111111111111', ip: '10.0.0.7', version: '3.3'});
128
+ const inst = instanceFor('bf11111111111111');
129
+ expect(inst.attachLan).toHaveBeenCalledWith({ip: '10.0.0.7', version: '3.3'});
130
+ });
131
+
132
+ test('a cloud-only configuration starts no LAN discovery', () => {
133
+ run({cloud: CLOUD, devices: [SLEEPY()]});
134
+ expect(TuyaDiscovery.start).not.toHaveBeenCalled();
135
+ });
136
+ });
137
+
138
+ describe('TuyaLan — duplicate device ids', () => {
139
+ // Two entries sharing an id resolve to one accessory UUID; configuring it twice
140
+ // used to crash the child bridge (addAccessory read back its own wrapper and
141
+ // tried to unregister it). The repeat must be dropped with a warning instead.
142
+ test('a repeated id is configured once and warns', () => {
143
+ const platform = run({devices: [SW(), SW()]});
144
+ expect(TuyaDevice).toHaveBeenCalledTimes(1);
145
+ expect(platform.addAccessory).toHaveBeenCalledTimes(1);
146
+ expect(platform.log.warn).toHaveBeenCalled();
147
+ });
148
+
149
+ test('distinct ids are all configured', () => {
150
+ run({devices: [SW(), SLEEPY()]});
151
+ expect(TuyaDevice).toHaveBeenCalledTimes(2);
152
+ });
153
+
154
+ test('a repeated fake id is configured once', () => {
155
+ run({devices: [SW({fake: true}), SW({fake: true})]});
156
+ expect(TuyaAccessory).toHaveBeenCalledTimes(1);
157
+ });
158
+ });
159
+
160
+ describe('TuyaLan — removeAccessory hardening', () => {
161
+ function platformWith(PlatformAccessory) {
162
+ let cls;
163
+ factory({platformAccessory: PlatformAccessory, hap: makeHap(), registerPlatform: (n, p, c) => { cls = c; }});
164
+ return new cls(makeLog(), {devices: [SW()]}, makeApi());
165
+ }
166
+ const PlatformAccessoryStub = function(name, uuid) { this.displayName = name; this.UUID = uuid; };
167
+
168
+ test('a non-PlatformAccessory (e.g. an accessory wrapper) is never unregistered', () => {
169
+ const platform = platformWith(PlatformAccessoryStub);
170
+ expect(() => platform.removeAccessory({accessory: {}})).not.toThrow();
171
+ expect(platform.api.unregisterPlatformAccessories).not.toHaveBeenCalled();
172
+ expect(platform.log.warn).toHaveBeenCalled();
173
+ });
174
+
175
+ test('a real PlatformAccessory is unregistered and dropped from the cache', () => {
176
+ const platform = platformWith(PlatformAccessoryStub);
177
+ const accessory = new PlatformAccessoryStub('Real', 'uuid:x');
178
+ platform.cachedAccessories.set('uuid:x', accessory);
179
+ platform.removeAccessory(accessory);
180
+ expect(platform.api.unregisterPlatformAccessories).toHaveBeenCalledTimes(1);
181
+ expect(platform.cachedAccessories.has('uuid:x')).toBe(false);
182
+ });
183
+ });
@@ -34,6 +34,7 @@ const HAP = {
34
34
  ProgramMode: { UUID: 'D1', NO_PROGRAM_SCHEDULED: 0, PROGRAM_SCHEDULED: 1, PROGRAM_SCHEDULED_MANUAL_MODE: 2 },
35
35
  IsConfigured: { UUID: 'D6', NOT_CONFIGURED: 0, CONFIGURED: 1 },
36
36
  ServiceLabelIndex: { UUID: 'CB' },
37
+ ServiceLabelNamespace: { UUID: 'CD', DOTS: 0, ARABIC_NUMERALS: 1 },
37
38
  ConfiguredName: { UUID: 'E3' },
38
39
  BatteryLevel: { UUID: '68' },
39
40
  StatusLowBattery: { UUID: '79', BATTERY_LEVEL_NORMAL: 0, BATTERY_LEVEL_LOW: 1 },
@@ -65,6 +66,7 @@ const HAP = {
65
66
  LockMechanism: { UUID: '45' },
66
67
  Valve: { UUID: 'D0' },
67
68
  IrrigationSystem: { UUID: 'CF' },
69
+ ServiceLabel: { UUID: 'CC' },
68
70
  Battery: { UUID: '96' },
69
71
  ContactSensor: { UUID: '80' },
70
72
  LeakSensor: { UUID: '83' },
@@ -73,6 +75,17 @@ const HAP = {
73
75
  },
74
76
  Formats: { FLOAT: 'float', UINT32: 'uint32', UINT16: 'uint16' },
75
77
  Perms: { READ: 'pr', NOTIFY: 'ev', WRITE: 'pw' },
78
+ // Subset of hap-nodejs HAPStatus + the HapStatusError class, so accessories
79
+ // can signal "No Response" the proper way (throw a HapStatusError from a
80
+ // get/set handler) and tests can assert on it.
81
+ HAPStatus: { SUCCESS: 0, SERVICE_COMMUNICATION_FAILURE: -70402 },
82
+ HapStatusError: class HapStatusError extends Error {
83
+ constructor(hapStatus) {
84
+ super('HapStatusError: ' + hapStatus);
85
+ this.name = 'HapStatusError';
86
+ this.hapStatus = hapStatus;
87
+ }
88
+ },
76
89
  // Mirrors hap-nodejs Categories. Intentionally kept faithful to the real
77
90
  // enum (no FANLIGHT, no bare DEHUMIDIFIER) so getCategory() regressions are
78
91
  // caught here instead of in production (see issue #45).
@@ -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>|
@@ -223,7 +224,17 @@ Some smart power strips don't have sequential data-points. Using `CustomMultiOut
223
224
  ```
224
225
 
225
226
  ### Air Conditioners
226
- These devices have cooling and/or heating capabilities; they could also have _dry_, _fan_, or others modes but HomeKit's definition doesn't facilitate modes other than _heat_, _cool_, and _auto_. By default, _heat_ and _cool_ modes are enabled; to let the plugin know that a device doesn't have heating or cooling capabilities, add an additional parameter named `noHeat` or `noCool` and set it to `true`. Tuya devices don't follow a unified pattern for naming the modes, for example cooling mode is called _COOL_ on Kogan's KAPRA14WFGA and _cold_ on Igenix's IG9901WIFI; by default, the plugin uses the phrases _COOL_ and _HEAT_ while communicating with your device but to let the plugin know that a device has different phrases, add additional parameters using `cmdHeat` and `cmdCool`. Additional parameters can be found in the sample below.
227
+ These devices have cooling and/or heating capabilities; they could also have _dry_, _fan_, or others modes but HomeKit's definition doesn't facilitate modes other than _heat_, _cool_, and _auto_. By default, _heat_, _cool_ and _auto_ modes are enabled; to let the plugin know that a device doesn't have heating, cooling or auto capabilities, add an additional parameter named `noHeat`, `noCool` or `noAuto` and set it to `true`.
228
+
229
+ Tuya devices don't follow a unified pattern for naming the modes, for example cooling mode is called _COOL_ on Kogan's KAPRA14WFGA but _cold_ on Igenix's IG9901WIFI and most "standard" Tuya AC (category `kt`) firmwares. The phrases are also **case-sensitive**. By default, the plugin uses the phrases _COOL_, _HEAT_ and _AUTO_ while communicating with your device; if your device uses different phrases (a very common case — many ACs report lowercase `cold` / `hot` / `auto` / `wet` / `wind`), override them with `cmdCool`, `cmdHeat` and `cmdAuto`. If the modes don't switch at all, this is almost always the reason.
230
+
231
+ > **Tip — finding the exact phrases.** The phrases are the raw values of the `mode` data-point (DP `4`). You can read them from the [Tuya IoT Platform](https://iot.tuya.com) → _Cloud → API Explorer → Query Things Data Model_ (or _Get Device Specification_) for your device. For example, a device whose `mode` enum range is `["auto","cold","wet","wind","hot"]` needs `cmdCool: "cold"`, `cmdHeat: "hot"`, `cmdAuto: "auto"`.
232
+
233
+ The fan speed data-point (DP `5`, `windspeed`) is usually an **enum of string values** like `"1"`, `"2"`, `"3"`. Set `fanSpeedSteps` to the number of speeds (e.g. `3`); this both maps HomeKit's 0–100 % slider onto the right number of steps and — importantly — sends the speed as a string, which these firmwares require. Without it the fan may silently ignore speed changes.
234
+
235
+ Many ACs have no child-lock data-point; if yours doesn't, set `noChildLock: true` so the plugin doesn't add a (non-functional) lock control. Likewise, set `noRotationSpeed: true` if the device has no fan-speed control.
236
+
237
+ Additional parameters can be found in the sample below.
227
238
 
228
239
  ```json5
229
240
  {
@@ -242,12 +253,27 @@ These devices have cooling and/or heating capabilities; they could also have _dr
242
253
  /* This device has no heating function */
243
254
  "noHeat": true ,
244
255
 
245
- /* Override cooling phrase */
256
+ /* This device has no auto function */
257
+ "noAuto": true,
258
+
259
+ /* Override cooling phrase (case-sensitive; e.g. "cold" on many devices) */
246
260
  "cmdCool": "COOL",
247
261
 
248
- /* Override heating phrase */
262
+ /* Override heating phrase (case-sensitive; e.g. "hot" on many devices) */
249
263
  "cmdHeat": "HEAT",
250
264
 
265
+ /* Override auto phrase (case-sensitive; e.g. "auto" on many devices) */
266
+ "cmdAuto": "AUTO",
267
+
268
+ /* Number of fan speeds; also sends the speed as a string (required by most ACs) */
269
+ "fanSpeedSteps": 3,
270
+
271
+ /* This device has no fan-speed control */
272
+ "noRotationSpeed": true,
273
+
274
+ /* This device has no child-lock data-point */
275
+ "noChildLock": true,
276
+
251
277
  /* This device has no oscillation (swinging) function */
252
278
  "noSwing": true,
253
279
 
@@ -258,7 +284,10 @@ These devices have cooling and/or heating capabilities; they could also have _dr
258
284
  "maxTemperature": 40,
259
285
 
260
286
  /* Temperature change steps, in Celsius (°C) */
261
- "minTemperatureSteps": 1
287
+ "minTemperatureSteps": 1,
288
+
289
+ /* Only if your firmware reports/accepts temperatures scaled by 10 (e.g. 170 = 17.0 °C) */
290
+ "temperatureDivisor": 10
262
291
  }
263
292
  ```
264
293
 
@@ -317,14 +346,15 @@ If your signature doesn't have a variation of _low_ or _high_, `SimpleHeater` wo
317
346
  ```
318
347
 
319
348
  ### Simple Dimmers / WLED Dimmers
320
- 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:
321
350
 
322
- 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`.
323
353
 
324
- - `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%.
325
- - `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`):
326
355
 
327
- 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).
328
358
 
329
359
  ```json5
330
360
  {
@@ -470,9 +500,10 @@ external stop, where close is accepted immediately), a close is sent as
470
500
  /* Optional. If set, exposes an extra stateful switch that mirrors
471
501
  whether the gate is currently open in HomeKit's view. Tapping it
472
502
  ON triggers a partial-open: the gate opens and then stops itself
473
- this many milliseconds later, leaving the gate partially open.
474
- Tapping it OFF triggers a standard full close. Useful for letting
475
- someone pass through briefly. Leave unset to skip the switch. */
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. */
476
507
  "partialOpenMs": 2000,
477
508
 
478
509
  /* Optional. Exposes extra Force Open and Force Close momentary
@@ -723,18 +754,30 @@ If the light, brightness and turning the fan **off** all work, but turning the f
723
754
 
724
755
  ### Irrigation Systems / Sprinklers
725
756
 
726
- Multi-valve Tuya irrigation/sprinkler controllers (the battery-powered Wi-Fi "faucet timers" that expose several `switch_*` valves, a `battery_percentage` and a `rain_sensor_state`) are exposed as a single, fully-fledged HomeKit **Irrigation System** accessory:
757
+ Multi-valve Tuya irrigation/sprinkler controllers (the battery-powered Wi-Fi "faucet timers" that expose several `switch_*` valves and a `battery_percentage`) are exposed as a single, fully-fledged HomeKit **Irrigation System** accessory:
727
758
 
728
759
  * one **Irrigation System** tile that contains every zone,
729
760
  * one **Valve** per zone (`ValveType = Irrigation`) — each with its own on/off, its own **Duration** picker and a live countdown,
730
- * an optional **Battery** service (level, low-battery warning, and — for solar/USB-C rechargeable units that report it — live charging status),
731
- * an optional **rain sensor** (a Contact sensor by default).
761
+ * an optional **Battery** service (level, low-battery warning, and — for solar/USB-C rechargeable units that report it — live charging status).
732
762
 
733
763
  Because these devices are slow to respond, all zone changes that happen close together — turning the whole system on/off, or running a scene that toggles several zones — are merged into a **single** Tuya command instead of a burst of them.
734
764
 
765
+ > **⚠️ Can't connect locally?** Most of these are battery-powered **"sleepy"** devices that **cannot be controlled over the LAN at all** — they sleep to save battery and only ever reach Tuya's cloud. If discovery never finds yours (or it won't connect), control it over the **[Tuya Cloud](https://github.com/adrianjagielak/homebridge-tuya-plus/blob/main/wiki/Tuya-Cloud-Setup.md)** instead: add cloud credentials once and set `"cloud": true` on the device. Everything below works the same — data-points are just addressed by their Tuya *code* (e.g. `switch_1`), which the plugin logs on startup.
766
+
767
+ ```json5
768
+ // Cloud example (also needs a top-level "cloud" credentials block — see the Tuya Cloud Setup guide)
769
+ {
770
+ "name": "Garden Irrigation",
771
+ "type": "IrrigationSystem",
772
+ "id": "bfae6739xxxxxxxxxxxxxx", // the cloud Device ID
773
+ "cloud": true,
774
+ "valveCount": 4
775
+ }
776
+ ```
777
+
735
778
  #### Minimal Configuration
736
779
 
737
- The defaults match the common 4-zone layout (valves A–D on data-points `1`–`4`, battery on `46`, rain on `49`):
780
+ The defaults match the common 4-zone layout (valves A–D on data-points `1`–`4`, battery on `46`):
738
781
 
739
782
  ```json5
740
783
  {
@@ -758,10 +801,6 @@ Each zone has its own **Duration**. When a zone is switched on it runs for that
758
801
 
759
802
  Switching the whole Irrigation System tile **off** closes every open zone, and switching it **on** opens every zone — each as one combined command (mirroring the physical "all" button many of these controllers have). Either direction can be disabled with `masterTurnsOffAllZones` / `masterTurnsOnAllZones`.
760
803
 
761
- #### Rain sensor: Contact vs Leak
762
-
763
- By default the rain sensor is a **Contact sensor** (`raining → Open`). You can switch it to a **Leak sensor** (`raining → Leak Detected`) with `"rainSensorType": "leak"` — leak sensors read more naturally for rain and make "when it starts raining" automations obvious, **but HomeKit treats them as safety accessories and raises critical alerts that bypass Do-Not-Disturb on every rainfall**, which many people find too noisy. Use `rainInverted` if the wet/dry states appear reversed.
764
-
765
804
  #### Full Configuration
766
805
 
767
806
  ```json5
@@ -799,12 +838,5 @@ By default the rain sensor is a **Contact sensor** (`raining → Open`). You can
799
838
  "lowBatteryThreshold": 20,
800
839
  "dpCharging": 101, /* boolean charging-status DP (solar / USB-C); omit if not reported */
801
840
  /* "noBattery": true, */
802
-
803
- /* --- Rain sensor (omit / set noRainSensor:true if not present) --- */
804
- "dpRain": 49,
805
- "rainSensorType": "contact", /* or "leak" */
806
- "rainOnValue": "rain", /* enum value reported while raining */
807
- "rainInverted": false
808
- /* "noRainSensor": true, */
809
841
  }
810
842
  ```
@@ -0,0 +1,71 @@
1
+ # Tuya Cloud Setup
2
+
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
+
5
+ It helps in two situations:
6
+
7
+ > **Devices that are never on the LAN.** Some devices, most notably some battery-powered **"sleepy"** ones, 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.
10
+
11
+ ---
12
+
13
+ ## 1. Create a Tuya Cloud project
14
+
15
+ 1. Sign up / log in at **[iot.tuya.com](https://iot.tuya.com/)** (the Tuya IoT Development Platform).
16
+ 2. **Cloud → Development → Create Cloud Project.**
17
+ * **Development Method:** choose **Smart Home**.
18
+ * **Data Center:** pick the region your **Tuya Smart / Smart Life app account** is in (App → *Me → Settings → Account and Security → Region*). This matters — cross-region API calls are rejected.
19
+ 3. After it's created, open the project — the **Overview** tab shows your **Access ID / Client ID** and **Access Secret / Client Secret**. You'll need both.
20
+
21
+ ## 2. Authorize the required API services
22
+
23
+ There are two types of projects: `Custom` and `Smart Home`. The difference between them is:
24
+
25
+ * The `Custom` project pulls devices from the project's assets.
26
+ * The `Smart Home` project pulls devices from the user's home in the Tuya app.
27
+
28
+ If you are a personal user and are unsure which one to choose, please use the Smart Home project.
29
+
30
+ In the project, go to **Service API → Go to Authorize** and make sure these are subscribed (all free):
31
+
32
+ * Authorization Token Management
33
+ * Device Status Notification
34
+ * IoT Core
35
+ * IoT Video Live Stream (for cameras)
36
+ * Industry Project Client Service (for the `Custom` project)
37
+ * IR Control Hub Open Service (for IR devices)
38
+ * Smart Home Scene Linkage (for scenes)
39
+ * Smart Lock Open Service (for Lock devices)
40
+
41
+ > **⚠️Remember to extend the API trial period every 6 months here: [Tuya IoT Platform > Cloud > Cloud Services > IoT Core](https://iot.tuya.com/cloud/products/detail?abilityId=1442730014117204014&id=p1668587814138nv4h3n&abilityAuth=0&tab=1) (the first-time subscription only gives you 1 month).**
42
+
43
+ ## 3. Link your app account (so the project can see your devices)
44
+
45
+ Still in the project: **Devices → Link App Account → Add App Account**, then in the **Tuya Smart / Smart Life** app go to **Me → ⊞ (scan icon, top-right)** and scan the QR code. Your irrigation timer should now appear under **Devices → All Devices**.
46
+
47
+ ## 4. Configure the plugin
48
+
49
+ Add a **top-level `cloud` block** with your credentials:
50
+
51
+ ```json5
52
+ {
53
+ "platform": "TuyaLan",
54
+ "cloud": {
55
+ "accessId": "your-access-id",
56
+ "accessKey": "your-access-secret",
57
+ "region": "eu", // eu / us / cn / in / sg / eu-w / us-e
58
+ "username": "you@example.com", // your Tuya/Smart Life app login
59
+ "password": "your-app-password",
60
+ "countryCode": "48", // your phone country code (e.g. 1, 44, 48)
61
+ "schema": "tuyaSmart" // or "smartlife" if you use the Smart Life app
62
+ },
63
+ "devices": [
64
+ // ...
65
+ ]
66
+ }
67
+ ```
68
+
69
+ ### Security note
70
+
71
+ Your **Access Secret** and app password are sensitive. Keep them only in your Homebridge `config.json`. If you ever share a config for support, redact them.