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.
@@ -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 later, leaving the gate partially open.
504
- Tapping it OFF triggers a standard full close. Useful for letting
505
- 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. */
506
507
  "partialOpenMs": 2000,
507
508
 
508
509
  /* Optional. Exposes extra Force Open and Force Close momentary
@@ -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: