homebridge-tuya-plus 3.14.0-dev.29 → 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.
@@ -22,7 +22,7 @@
22
22
  "cloud": {
23
23
  "type": "object",
24
24
  "title": "Tuya Cloud fallback (optional)",
25
- "description": "This plugin is LAN-first — every device is controlled locally. Add your Tuya Cloud project credentials here and the plugin keeps one Cloud session running in the background as a transparent fallback for every device: each accessory tries the LAN first, and only falls back to the cloud when it can't be reached locally — a flaky LAN moment, or a battery-powered 'sleepy' device that never appears on the LAN. It's opt-in and local is always preferred. Create a free Cloud project at iot.tuya.com. See the wiki for step-by-step setup.",
25
+ "description": "<a href='https://github.com/adrianjagielak/homebridge-tuya-plus/blob/main/wiki/Tuya-Cloud-Setup.md'>Tuya Cloud API Setup</a>.",
26
26
  "properties": {
27
27
  "accessId": {
28
28
  "type": "string",
@@ -69,12 +69,6 @@
69
69
  { "title": "Tuya Smart", "enum": ["tuyaSmart"] },
70
70
  { "title": "Smart Life", "enum": ["smartlife"] }
71
71
  ]
72
- },
73
- "realtime": {
74
- "type": "boolean",
75
- "title": "Realtime updates (MQTT)",
76
- "default": true,
77
- "description": "Receive instant updates over Tuya's MQTT message service (uses the 'mqtt' package, installed automatically). If disabled, devices stay controllable and show their state at startup, but external changes (physical buttons, the device's own timers) won't be reflected until restart."
78
72
  }
79
73
  }
80
74
  },
@@ -96,7 +90,7 @@
96
90
  {
97
91
  "title": "Switch Gang / Basic Switch",
98
92
  "enum": ["Switch"]
99
- },
93
+ },
100
94
  {
101
95
  "title": "Smart Plug / Barely Smart Power Strip",
102
96
  "enum": ["Outlet"]
@@ -311,7 +305,7 @@
311
305
  "condition": {
312
306
  "functionBody": "return model.devices && model.devices[arrayIndices] && ['WledDimmer'].includes(model.devices[arrayIndices].type);"
313
307
  }
314
- },
308
+ },
315
309
  "dpColorTemperature": {
316
310
  "type": "integer",
317
311
  "placeholder": "3",
@@ -754,9 +748,21 @@
754
748
  "items": {
755
749
  "type": "object",
756
750
  "properties": {
757
- "name": { "type": "string", "title": "Zone name", "placeholder": "Lawn" },
758
- "dp": { "type": ["integer", "string"], "title": "Data-point (numeric id, or cloud code)", "placeholder": "1 or switch_1" },
759
- "defaultDuration": { "type": "integer", "title": "Default run time (seconds, 0 = run indefinitely)", "placeholder": 600 }
751
+ "name": {
752
+ "type": "string",
753
+ "title": "Zone name",
754
+ "placeholder": "Lawn"
755
+ },
756
+ "dp": {
757
+ "type": ["integer", "string"],
758
+ "title": "Data-point (numeric id, or cloud code)",
759
+ "placeholder": "1 or switch_1"
760
+ },
761
+ "defaultDuration": {
762
+ "type": "integer",
763
+ "title": "Default run time (seconds, 0 = run indefinitely)",
764
+ "placeholder": 600
765
+ }
760
766
  }
761
767
  },
762
768
  "condition": {
@@ -847,4 +853,4 @@
847
853
  }
848
854
  }
849
855
  }
850
- }
856
+ }
package/index.js CHANGED
@@ -40,328 +40,459 @@ const PLATFORM_NAME = 'TuyaLan';
40
40
  const UUID_SEED = 'homebridge-tuya';
41
41
  const DEFAULT_DISCOVER_TIMEOUT = 60000;
42
42
 
43
- // Lenient boolean coercion (matches BaseAccessory._coerceBoolean) so config
44
- // values like true / "true" / 1 all read as true.
45
- const coerceBoolean = (b, df = false) =>
46
- typeof b === 'boolean' ? b :
47
- typeof b === 'string' ? b.toLowerCase().trim() === 'true' :
48
- typeof b === 'number' ? b !== 0 : df;
49
-
50
43
  const CLASS_DEF = {
51
- outlet: OutletAccessory,
52
- simplelight: SimpleLightAccessory,
53
- rgbtwlight: RGBTWLightAccessory,
54
- rgbtwoutlet: RGBTWOutletAccessory,
55
- twlight: TWLightAccessory,
56
- multioutlet: MultiOutletAccessory,
57
- custommultioutlet: CustomMultiOutletAccessory,
58
- airconditioner: AirConditionerAccessory,
59
- airpurifier: AirPurifierAccessory,
60
- dehumidifier: DehumidifierAccessory,
61
- convector: ConvectorAccessory,
62
- garagedoor: GarageDoorAccessory,
63
- simplegaragedoor: SimpleGarageDoorAccessory,
64
- simpledimmer: SimpleDimmerAccessory,
65
- wleddimmer: WledDimmerAccessory,
66
- simpledimmer2: SimpleDimmer2Accessory,
67
- simpleblinds: SimpleBlindsAccessory,
68
- simpleheater: SimpleHeaterAccessory,
69
- switch: SwitchAccessory,
70
- fan: SimpleFanAccessory,
71
- fanlight: SimpleFanLightAccessory,
72
- watervalve: ValveAccessory,
73
- irrigationsystem: IrrigationSystemAccessory,
74
- oildiffuser: OilDiffuserAccessory,
75
- doorbell: DoorbellAccessory,
76
- verticalblindswithtilt: VerticalBlindsWithTilt,
77
- percentblinds: PercentBlindsAccessory
44
+ outlet: OutletAccessory,
45
+ simplelight: SimpleLightAccessory,
46
+ rgbtwlight: RGBTWLightAccessory,
47
+ rgbtwoutlet: RGBTWOutletAccessory,
48
+ twlight: TWLightAccessory,
49
+ multioutlet: MultiOutletAccessory,
50
+ custommultioutlet: CustomMultiOutletAccessory,
51
+ airconditioner: AirConditionerAccessory,
52
+ airpurifier: AirPurifierAccessory,
53
+ dehumidifier: DehumidifierAccessory,
54
+ convector: ConvectorAccessory,
55
+ garagedoor: GarageDoorAccessory,
56
+ simplegaragedoor: SimpleGarageDoorAccessory,
57
+ simpledimmer: SimpleDimmerAccessory,
58
+ wleddimmer: WledDimmerAccessory,
59
+ simpledimmer2: SimpleDimmer2Accessory,
60
+ simpleblinds: SimpleBlindsAccessory,
61
+ simpleheater: SimpleHeaterAccessory,
62
+ switch: SwitchAccessory,
63
+ fan: SimpleFanAccessory,
64
+ fanlight: SimpleFanLightAccessory,
65
+ watervalve: ValveAccessory,
66
+ irrigationsystem: IrrigationSystemAccessory,
67
+ oildiffuser: OilDiffuserAccessory,
68
+ doorbell: DoorbellAccessory,
69
+ verticalblindswithtilt: VerticalBlindsWithTilt,
70
+ percentblinds: PercentBlindsAccessory,
78
71
  };
79
72
 
80
- let Characteristic, Formats, Perms, Categories, PlatformAccessory, Service, AdaptiveLightingController, UUID;
81
-
82
- module.exports = function(homebridge) {
83
- ({
84
- platformAccessory: PlatformAccessory,
85
- hap: {Characteristic, Formats, Perms, Categories, Service, AdaptiveLightingController, uuid: UUID}
86
- } = homebridge);
87
-
88
- homebridge.registerPlatform(PLUGIN_NAME, PLATFORM_NAME, TuyaLan, true);
73
+ let Characteristic,
74
+ Formats,
75
+ Perms,
76
+ Categories,
77
+ PlatformAccessory,
78
+ Service,
79
+ AdaptiveLightingController,
80
+ UUID;
81
+
82
+ module.exports = function (homebridge) {
83
+ ({
84
+ platformAccessory: PlatformAccessory,
85
+ hap: {
86
+ Characteristic,
87
+ Formats,
88
+ Perms,
89
+ Categories,
90
+ Service,
91
+ AdaptiveLightingController,
92
+ uuid: UUID,
93
+ },
94
+ } = homebridge);
95
+
96
+ homebridge.registerPlatform(PLUGIN_NAME, PLATFORM_NAME, TuyaLan, true);
89
97
  };
90
98
 
91
99
  class TuyaLan {
92
- constructor(...props) {
93
- [this.log, this.config, this.api] = [...props];
94
-
95
- this.cachedAccessories = new Map();
96
- // One TuyaDevice per configured device, keyed by Tuya id, so discovery can
97
- // hand each its LAN target once it's found on the network.
98
- this.tuyaDevices = new Map();
99
- // A SINGLE shared Tuya Cloud session (OpenAPI token + realtime MQTT) for the
100
- // whole platform — the global fallback every device can lean on. Stays null
101
- // unless cloud credentials are configured.
102
- this.cloudApi = null;
103
- this.cloudMessaging = null;
104
- this.api.hap.EnergyCharacteristics = require('./lib/EnergyCharacteristics')(this.api.hap);
105
-
106
- if(!this.config || !this.config.devices) {
107
- this.log("No devices found. Check that you have specified them in your config.json file.");
108
- return false;
109
- }
110
-
111
- this._expectedUUIDs = this.config.devices.map(device => UUID.generate(UUID_SEED +(device.fake ? ':fake:' : ':') + device.id));
112
-
113
- this.api.on('didFinishLaunching', () => {
114
- this.discoverDevices();
115
- });
100
+ constructor(...props) {
101
+ [this.log, this.config, this.api] = [...props];
102
+
103
+ this.cachedAccessories = new Map();
104
+ // One TuyaDevice per configured device, keyed by Tuya id, so discovery can
105
+ // hand each its LAN target once it's found on the network.
106
+ this.tuyaDevices = new Map();
107
+ // A SINGLE shared Tuya Cloud session (OpenAPI token + realtime MQTT) for the
108
+ // whole platform — the global fallback every device can lean on. Stays null
109
+ // unless cloud credentials are configured.
110
+ this.cloudApi = null;
111
+ this.cloudMessaging = null;
112
+ this.api.hap.EnergyCharacteristics = require('./lib/EnergyCharacteristics')(
113
+ this.api.hap,
114
+ );
115
+
116
+ if (!this.config || !this.config.devices) {
117
+ this.log(
118
+ 'No devices found. Check that you have specified them in your config.json file.',
119
+ );
120
+ return false;
116
121
  }
117
122
 
118
- discoverDevices() {
119
- // Bring up the single shared cloud session first, so every device that opts
120
- // into (or falls back to) the cloud shares one token and one MQTT stream.
121
- this._setupCloudSession();
122
-
123
- const lanDeviceIds = []; // ids we still want to find on the LAN
124
- const connectedDevices = []; // ids discovered on the LAN
125
- const fakeDevices = [];
126
- const seenUUIDs = new Set(); // accessory UUIDs already configured this run
127
-
128
- this.config.devices.forEach(device => {
129
- try {
130
- device.id = ('' + device.id).trim();
131
- // Cloud-only devices don't need a local key; only trim when present.
132
- if (device.key != null) device.key = ('' + device.key).trim();
133
- device.type = ('' + device.type).trim();
134
-
135
- device.ip = ('' + (device.ip || '')).trim();
136
- } catch(ex) {}
137
-
138
- if (!device.type) return this.log.error('%s (%s) doesn\'t have a type defined.', device.name || 'Unnamed device', device.id);
139
- if (!CLASS_DEF[device.type.toLowerCase()]) return this.log.error('%s (%s) doesn\'t have a valid type defined.', device.name || 'Unnamed device', device.id);
140
-
141
- // Two config entries that resolve to the same accessory UUID (the same
142
- // Tuya id, real or fake) make addAccessory process that UUID twice. The
143
- // second pass reads back the accessory wrapper the first pass cached
144
- // instead of a PlatformAccessory and crashes the child bridge while
145
- // trying to unregister it. Keep the first entry; skip the rest.
146
- const uuid = UUID.generate(UUID_SEED + (device.fake ? ':fake:' : ':') + device.id);
147
- if (seenUUIDs.has(uuid)) return this.log.warn('%s (%s) is configured more than once; ignoring the duplicate entry.', device.name || 'Unnamed device', device.id);
148
- seenUUIDs.add(uuid);
149
-
150
- if (device.fake) {
151
- fakeDevices.push({name: device.id.slice(8), ...device});
152
- return;
153
- }
154
-
155
- const tuyaDevice = this._createDevice({name: device.id.slice(8), ...device});
156
- this.tuyaDevices.set(device.id, tuyaDevice);
157
- this.addAccessory(tuyaDevice);
158
-
159
- // A device with a local key can be reached on the LAN, so it wants
160
- // discovery; the cloud, when configured, is its fallback. A device with
161
- // no key is cloud-only (e.g. a battery-powered "sleepy" unit) and relies
162
- // on the cloud session to be reachable at all.
163
- if (device.key) lanDeviceIds.push(device.id);
164
- else if (!this.cloudApi) this.log.error('%s (%s) has no local key and no Tuya Cloud session is configured, so it can\'t be reached. Add a key for LAN control, or a top-level "cloud" block.', tuyaDevice.context.name, device.id);
165
- });
166
-
167
- fakeDevices.forEach(config => {
168
- this.log.info('Adding fake device: %s', config.name);
169
- this.addAccessory(new TuyaAccessory({
170
- ...config,
171
- log: this.log,
172
- UUID: UUID.generate(UUID_SEED + ':fake:' + config.id),
173
- connect: false
174
- }));
175
- });
176
-
177
- if (lanDeviceIds.length === 0) {
178
- if (this.tuyaDevices.size === 0 && fakeDevices.length === 0) this.log.error('No valid configured devices found.');
179
- return; // cloud-only (or empty) configuration: nothing to discover over the LAN
180
- }
181
-
182
- this.log.info('Starting discovery...');
183
-
184
- TuyaDiscovery.start({ids: lanDeviceIds, log: this.log})
185
- .on('discover', config => {
186
- if (!config || !config.id) return;
187
- const tuyaDevice = this.tuyaDevices.get(config.id);
188
- if (!tuyaDevice) return this.log.warn('Discovered a device that has not been configured yet (%s@%s).', config.id, config.ip);
189
- if (connectedDevices.includes(config.id)) return;
190
-
191
- connectedDevices.push(config.id);
192
-
193
- this.log.info('Discovered %s (%s) identified as %s (%s)', tuyaDevice.context.name, config.id, tuyaDevice.context.type, config.version);
194
-
195
- // The version broadcast by the device wins over a configured `version`,
196
- // but `forceVersion` overrides everything; attachLan applies that order.
197
- tuyaDevice.attachLan({ip: config.ip, version: config.version});
198
- });
199
-
200
- setTimeout(() => {
201
- lanDeviceIds.forEach(deviceId => {
202
- if (connectedDevices.includes(deviceId)) return;
203
-
204
- const tuyaDevice = this.tuyaDevices.get(deviceId);
205
- if (!tuyaDevice) return;
206
-
207
- if (tuyaDevice.context.ip) {
208
- this.log.info('Failed to discover %s (%s) in time but will connect via %s.', tuyaDevice.context.name, deviceId, tuyaDevice.context.ip);
209
- tuyaDevice.attachLan({ip: tuyaDevice.context.ip});
210
- } else if (tuyaDevice.cloud) {
211
- this.log.info('Failed to discover %s (%s) on the LAN; it will run over the Tuya Cloud fallback.', tuyaDevice.context.name, deviceId);
212
- } else {
213
- this.log.warn('Failed to discover %s (%s) in time but will keep looking.', tuyaDevice.context.name, deviceId);
214
- }
215
- });
216
- }, this.config.discoverTimeout ?? DEFAULT_DISCOVER_TIMEOUT);
123
+ this._expectedUUIDs = this.config.devices.map((device) =>
124
+ UUID.generate(UUID_SEED + (device.fake ? ':fake:' : ':') + device.id),
125
+ );
126
+
127
+ this.api.on('didFinishLaunching', () => {
128
+ this.discoverDevices();
129
+ });
130
+ }
131
+
132
+ discoverDevices() {
133
+ // Bring up the single shared cloud session first, so every device that opts
134
+ // into (or falls back to) the cloud shares one token and one MQTT stream.
135
+ this._setupCloudSession();
136
+
137
+ const lanDeviceIds = []; // ids we still want to find on the LAN
138
+ const connectedDevices = []; // ids discovered on the LAN
139
+ const fakeDevices = [];
140
+ const seenUUIDs = new Set(); // accessory UUIDs already configured this run
141
+
142
+ this.config.devices.forEach((device) => {
143
+ try {
144
+ device.id = ('' + device.id).trim();
145
+ // Cloud-only devices don't need a local key; only trim when present.
146
+ if (device.key != null) device.key = ('' + device.key).trim();
147
+ device.type = ('' + device.type).trim();
148
+
149
+ device.ip = ('' + (device.ip || '')).trim();
150
+ } catch (ex) {}
151
+
152
+ if (!device.type)
153
+ return this.log.error(
154
+ "%s (%s) doesn't have a type defined.",
155
+ device.name || 'Unnamed device',
156
+ device.id,
157
+ );
158
+ if (!CLASS_DEF[device.type.toLowerCase()])
159
+ return this.log.error(
160
+ "%s (%s) doesn't have a valid type defined.",
161
+ device.name || 'Unnamed device',
162
+ device.id,
163
+ );
164
+
165
+ // Two config entries that resolve to the same accessory UUID (the same
166
+ // Tuya id, real or fake) make addAccessory process that UUID twice. The
167
+ // second pass reads back the accessory wrapper the first pass cached
168
+ // instead of a PlatformAccessory and crashes the child bridge while
169
+ // trying to unregister it. Keep the first entry; skip the rest.
170
+ const uuid = UUID.generate(
171
+ UUID_SEED + (device.fake ? ':fake:' : ':') + device.id,
172
+ );
173
+ if (seenUUIDs.has(uuid))
174
+ return this.log.warn(
175
+ '%s (%s) is configured more than once; ignoring the duplicate entry.',
176
+ device.name || 'Unnamed device',
177
+ device.id,
178
+ );
179
+ seenUUIDs.add(uuid);
180
+
181
+ if (device.fake) {
182
+ fakeDevices.push({ name: device.id.slice(8), ...device });
183
+ return;
184
+ }
185
+
186
+ const tuyaDevice = this._createDevice({
187
+ name: device.id.slice(8),
188
+ ...device,
189
+ });
190
+ this.tuyaDevices.set(device.id, tuyaDevice);
191
+ this.addAccessory(tuyaDevice);
192
+
193
+ // A device with a local key can be reached on the LAN, so it wants
194
+ // discovery; the cloud, when configured, is its fallback. A device with
195
+ // no key is cloud-only (e.g. a battery-powered "sleepy" unit) and relies
196
+ // on the cloud session to be reachable at all.
197
+ if (device.key) lanDeviceIds.push(device.id);
198
+ else if (!this.cloudApi)
199
+ this.log.error(
200
+ '%s (%s) has no local key and no Tuya Cloud session is configured, so it can\'t be reached. Add a key for LAN control, or a top-level "cloud" block.',
201
+ tuyaDevice.context.name,
202
+ device.id,
203
+ );
204
+ });
205
+
206
+ fakeDevices.forEach((config) => {
207
+ this.log.info('Adding fake device: %s', config.name);
208
+ this.addAccessory(
209
+ new TuyaAccessory({
210
+ ...config,
211
+ log: this.log,
212
+ UUID: UUID.generate(UUID_SEED + ':fake:' + config.id),
213
+ connect: false,
214
+ }),
215
+ );
216
+ });
217
+
218
+ if (lanDeviceIds.length === 0) {
219
+ if (this.tuyaDevices.size === 0 && fakeDevices.length === 0)
220
+ this.log.error('No valid configured devices found.');
221
+ return; // cloud-only (or empty) configuration: nothing to discover over the LAN
217
222
  }
218
223
 
219
- /* ------------------------------------------------------------------ *
220
- * Tuya Cloud — a single, global fallback session.
221
- *
222
- * This plugin stays LAN-first: every device is controlled locally when it
223
- * can be. When a top-level `cloud` block is configured, the plugin keeps one
224
- * shared Tuya Cloud session alive in the background, and every device gains a
225
- * transparent cloud fallback for the moments the LAN can't be reached (a
226
- * flaky connection, or a battery-powered "sleepy" device that never appears
227
- * on the LAN at all). It's all opt-in — without `cloud`, nothing here runs.
228
- * ------------------------------------------------------------------ */
229
-
230
- _setupCloudSession() {
231
- const cloudCfg = (this.config.cloud && typeof this.config.cloud === 'object') ? this.config.cloud : null;
232
- if (!cloudCfg) return; // no cloud block: the plugin runs purely on the LAN
233
-
234
- if (!cloudCfg.accessId || !cloudCfg.accessKey) {
235
- return this.log.error('A "cloud" block is present but is missing accessId/accessKey, so the Tuya Cloud fallback is disabled. See the wiki: Tuya Cloud Setup.');
224
+ this.log.info('Starting discovery...');
225
+
226
+ TuyaDiscovery.start({ ids: lanDeviceIds, log: this.log }).on(
227
+ 'discover',
228
+ (config) => {
229
+ if (!config || !config.id) return;
230
+ const tuyaDevice = this.tuyaDevices.get(config.id);
231
+ if (!tuyaDevice)
232
+ return this.log.warn(
233
+ 'Discovered a device that has not been configured yet (%s@%s).',
234
+ config.id,
235
+ config.ip,
236
+ );
237
+ if (connectedDevices.includes(config.id)) return;
238
+
239
+ connectedDevices.push(config.id);
240
+
241
+ this.log.info(
242
+ 'Discovered %s (%s) identified as %s (%s)',
243
+ tuyaDevice.context.name,
244
+ config.id,
245
+ tuyaDevice.context.type,
246
+ config.version,
247
+ );
248
+
249
+ // The version broadcast by the device wins over a configured `version`,
250
+ // but `forceVersion` overrides everything; attachLan applies that order.
251
+ tuyaDevice.attachLan({ ip: config.ip, version: config.version });
252
+ },
253
+ );
254
+
255
+ setTimeout(() => {
256
+ lanDeviceIds.forEach((deviceId) => {
257
+ if (connectedDevices.includes(deviceId)) return;
258
+
259
+ const tuyaDevice = this.tuyaDevices.get(deviceId);
260
+ if (!tuyaDevice) return;
261
+
262
+ if (tuyaDevice.context.ip) {
263
+ this.log.info(
264
+ 'Failed to discover %s (%s) in time but will connect via %s.',
265
+ tuyaDevice.context.name,
266
+ deviceId,
267
+ tuyaDevice.context.ip,
268
+ );
269
+ tuyaDevice.attachLan({ ip: tuyaDevice.context.ip });
270
+ } else if (tuyaDevice.cloud) {
271
+ this.log.info(
272
+ 'Failed to discover %s (%s) on the LAN; it will run over the Tuya Cloud fallback.',
273
+ tuyaDevice.context.name,
274
+ deviceId,
275
+ );
276
+ } else {
277
+ this.log.warn(
278
+ 'Failed to discover %s (%s) in time but will keep looking.',
279
+ tuyaDevice.context.name,
280
+ deviceId,
281
+ );
236
282
  }
237
-
238
- this.cloudApi = new TuyaCloudApi({...cloudCfg, log: this.log});
239
-
240
- const realtime = cloudCfg.realtime === undefined ? true : coerceBoolean(cloudCfg.realtime, true);
241
- this.cloudMessaging = realtime ? new TuyaCloudMessaging({api: this.cloudApi, log: this.log}) : null;
242
-
243
- this.log.info('Tuya Cloud fallback enabled via %s%s.', this.cloudApi.endpoint, this.cloudMessaging ? ' (with realtime updates)' : ' (realtime updates disabled)');
283
+ });
284
+ }, this.config.discoverTimeout ?? DEFAULT_DISCOVER_TIMEOUT);
285
+ }
286
+
287
+ /* ------------------------------------------------------------------ *
288
+ * Tuya Cloud — a single, global fallback session.
289
+ *
290
+ * This plugin stays LAN-first: every device is controlled locally when it
291
+ * can be. When a top-level `cloud` block is configured, the plugin keeps one
292
+ * shared Tuya Cloud session alive in the background, and every device gains a
293
+ * transparent cloud fallback for the moments the LAN can't be reached (a
294
+ * flaky connection, or a battery-powered "sleepy" device that never appears
295
+ * on the LAN at all). It's all opt-in — without `cloud`, nothing here runs.
296
+ * ------------------------------------------------------------------ */
297
+
298
+ _setupCloudSession() {
299
+ const cloudCfg =
300
+ this.config.cloud && typeof this.config.cloud === 'object'
301
+ ? this.config.cloud
302
+ : null;
303
+ if (!cloudCfg) return; // no cloud block: the plugin runs purely on the LAN
304
+
305
+ if (!cloudCfg.accessId || !cloudCfg.accessKey) {
306
+ return this.log.error(
307
+ 'A "cloud" block is present but is missing accessId/accessKey, so the Tuya Cloud fallback is disabled. See the wiki: Tuya Cloud Setup.',
308
+ );
244
309
  }
245
310
 
246
- _createDevice(device) {
247
- // The one shared cloud session, when configured, backs every device as its
248
- // fallback. There is no per-device cloud configuration.
249
- const usesCloud = !!this.cloudApi;
250
- return new TuyaDevice({
251
- ...device,
252
- cloudApi: usesCloud ? this.cloudApi : undefined,
253
- messaging: usesCloud ? this.cloudMessaging : undefined,
254
- cloudStartDelay: usesCloud ? this._nextCloudStartDelay() : 0,
255
- log: this.log,
256
- UUID: UUID.generate(UUID_SEED + ':' + device.id),
257
- connect: false
311
+ this.cloudApi = new TuyaCloudApi({ ...cloudCfg, log: this.log });
312
+ this.cloudMessaging = new TuyaCloudMessaging({
313
+ api: this.cloudApi,
314
+ log: this.log,
315
+ });
316
+
317
+ this.log.info(
318
+ 'Tuya Cloud fallback enabled via %s.',
319
+ this.cloudApi.endpoint,
320
+ );
321
+ }
322
+
323
+ _createDevice(device) {
324
+ // The one shared cloud session, when configured, backs every device as its
325
+ // fallback. There is no per-device cloud configuration.
326
+ const usesCloud = !!this.cloudApi;
327
+ return new TuyaDevice({
328
+ ...device,
329
+ cloudApi: usesCloud ? this.cloudApi : undefined,
330
+ messaging: usesCloud ? this.cloudMessaging : undefined,
331
+ cloudStartDelay: usesCloud ? this._nextCloudStartDelay() : 0,
332
+ log: this.log,
333
+ UUID: UUID.generate(UUID_SEED + ':' + device.id),
334
+ connect: false,
335
+ });
336
+ }
337
+
338
+ // Spread the cloud devices' first reads over a few seconds so a large install
339
+ // doesn't fire dozens of OpenAPI calls at once and trip Tuya's per-second rate
340
+ // limit at startup.
341
+ _nextCloudStartDelay() {
342
+ this._cloudStartCount = (this._cloudStartCount || 0) + 1;
343
+ return Math.min(15000, (this._cloudStartCount - 1) * 300);
344
+ }
345
+
346
+ registerPlatformAccessories(platformAccessories) {
347
+ this.api.registerPlatformAccessories(
348
+ PLUGIN_NAME,
349
+ PLATFORM_NAME,
350
+ Array.isArray(platformAccessories)
351
+ ? platformAccessories
352
+ : [platformAccessories],
353
+ );
354
+ }
355
+
356
+ configureAccessory(accessory) {
357
+ // also checks null objects or empty config - this._expectedUUIDs
358
+ if (
359
+ accessory instanceof PlatformAccessory &&
360
+ this._expectedUUIDs &&
361
+ this._expectedUUIDs.includes(accessory.UUID)
362
+ ) {
363
+ this.cachedAccessories.set(accessory.UUID, accessory);
364
+ accessory.services.forEach((service) => {
365
+ if (service.UUID === Service.AccessoryInformation.UUID) return;
366
+ service.characteristics.some((characteristic) => {
367
+ if (
368
+ !characteristic.props ||
369
+ !Array.isArray(characteristic.props.perms) ||
370
+ characteristic.props.perms.length !== 3 ||
371
+ !(
372
+ characteristic.props.perms.includes(Perms.WRITE) &&
373
+ characteristic.props.perms.includes(Perms.NOTIFY)
374
+ )
375
+ )
376
+ return;
377
+
378
+ this.log.info(
379
+ 'Marked %s unreachable by faulting Service.%s.%s',
380
+ accessory.displayName,
381
+ service.displayName,
382
+ characteristic.displayName,
383
+ );
384
+
385
+ characteristic.updateValue(new Error('Unreachable'));
386
+ return true;
258
387
  });
388
+ });
389
+ } else {
390
+ /*
391
+ * Irrespective of this unregistering, Homebridge continues
392
+ * to "_prepareAssociatedHAPAccessory" and "addBridgedAccessory".
393
+ * This timeout will hopefully remove the accessory after that has happened.
394
+ */
395
+ setTimeout(() => {
396
+ this.removeAccessory(accessory);
397
+ }, 1000);
259
398
  }
260
-
261
- // Spread the cloud devices' first reads over a few seconds so a large install
262
- // doesn't fire dozens of OpenAPI calls at once and trip Tuya's per-second rate
263
- // limit at startup.
264
- _nextCloudStartDelay() {
265
- this._cloudStartCount = (this._cloudStartCount || 0) + 1;
266
- return Math.min(15000, (this._cloudStartCount - 1) * 300);
399
+ }
400
+
401
+ addAccessory(device) {
402
+ const deviceConfig = device.context;
403
+ const type = (deviceConfig.type || '').toLowerCase();
404
+
405
+ const Accessory = CLASS_DEF[type];
406
+
407
+ let accessory = this.cachedAccessories.get(deviceConfig.UUID),
408
+ isCached = true;
409
+
410
+ const expectedCategory = Accessory.getCategory(Categories);
411
+
412
+ // Only treat a cached accessory as a "different type" when we actually
413
+ // have a category to compare against. If getCategory() resolves to
414
+ // undefined (e.g. an unknown HAP category constant), HomeKit stores the
415
+ // accessory as Categories.OTHER, so an undefined expectation would never
416
+ // match and we would needlessly unregister & recreate the accessory on
417
+ // every restart — wiping its HomeKit identity (name, room, automations).
418
+ if (
419
+ accessory &&
420
+ expectedCategory !== undefined &&
421
+ accessory.category !== expectedCategory
422
+ ) {
423
+ this.log.info(
424
+ '%s has a different type (%s vs %s)',
425
+ accessory.displayName,
426
+ accessory.category,
427
+ expectedCategory,
428
+ );
429
+ this.removeAccessory(accessory);
430
+ accessory = null;
267
431
  }
268
432
 
269
- registerPlatformAccessories(platformAccessories) {
270
- this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, Array.isArray(platformAccessories) ? platformAccessories : [platformAccessories]);
433
+ if (!accessory) {
434
+ accessory = new PlatformAccessory(
435
+ deviceConfig.name,
436
+ deviceConfig.UUID,
437
+ expectedCategory,
438
+ );
439
+ accessory
440
+ .getService(Service.AccessoryInformation)
441
+ .setCharacteristic(
442
+ Characteristic.Manufacturer,
443
+ deviceConfig.manufacturer || 'Unknown',
444
+ )
445
+ .setCharacteristic(
446
+ Characteristic.Model,
447
+ deviceConfig.model || 'Unknown',
448
+ )
449
+ .setCharacteristic(
450
+ Characteristic.SerialNumber,
451
+ deviceConfig.id.slice(8),
452
+ );
453
+
454
+ isCached = false;
271
455
  }
272
456
 
273
- configureAccessory(accessory) {
274
- // also checks null objects or empty config - this._expectedUUIDs
275
- if (accessory instanceof PlatformAccessory && this._expectedUUIDs && this._expectedUUIDs.includes(accessory.UUID)) {
276
- this.cachedAccessories.set(accessory.UUID, accessory);
277
- accessory.services.forEach(service => {
278
- if (service.UUID === Service.AccessoryInformation.UUID) return;
279
- service.characteristics.some(characteristic => {
280
- if (!characteristic.props ||
281
- !Array.isArray(characteristic.props.perms) ||
282
- characteristic.props.perms.length !== 3 ||
283
- !(characteristic.props.perms.includes(Perms.WRITE) && characteristic.props.perms.includes(Perms.NOTIFY))
284
- ) return;
285
-
286
- this.log.info('Marked %s unreachable by faulting Service.%s.%s', accessory.displayName, service.displayName, characteristic.displayName);
287
-
288
- characteristic.updateValue(new Error('Unreachable'));
289
- return true;
290
- });
291
- });
292
- } else {
293
- /*
294
- * Irrespective of this unregistering, Homebridge continues
295
- * to "_prepareAssociatedHAPAccessory" and "addBridgedAccessory".
296
- * This timeout will hopefully remove the accessory after that has happened.
297
- */
298
- setTimeout(() => {
299
- this.removeAccessory(accessory);
300
- }, 1000);
301
- }
457
+ if (accessory && accessory.displayName !== deviceConfig.name) {
458
+ this.log.info(
459
+ 'Configuration name %s differs from cached displayName %s. Updating cached displayName to %s ',
460
+ deviceConfig.name,
461
+ accessory.displayName,
462
+ deviceConfig.name,
463
+ );
464
+ accessory.displayName = deviceConfig.name;
302
465
  }
303
466
 
304
- addAccessory(device) {
305
- const deviceConfig = device.context;
306
- const type = (deviceConfig.type || '').toLowerCase();
307
-
308
- const Accessory = CLASS_DEF[type];
309
-
310
- let accessory = this.cachedAccessories.get(deviceConfig.UUID),
311
- isCached = true;
312
-
313
- const expectedCategory = Accessory.getCategory(Categories);
314
-
315
- // Only treat a cached accessory as a "different type" when we actually
316
- // have a category to compare against. If getCategory() resolves to
317
- // undefined (e.g. an unknown HAP category constant), HomeKit stores the
318
- // accessory as Categories.OTHER, so an undefined expectation would never
319
- // match and we would needlessly unregister & recreate the accessory on
320
- // every restart — wiping its HomeKit identity (name, room, automations).
321
- if (accessory && expectedCategory !== undefined && accessory.category !== expectedCategory) {
322
- this.log.info("%s has a different type (%s vs %s)", accessory.displayName, accessory.category, expectedCategory);
323
- this.removeAccessory(accessory);
324
- accessory = null;
325
- }
326
-
327
- if (!accessory) {
328
- accessory = new PlatformAccessory(deviceConfig.name, deviceConfig.UUID, expectedCategory);
329
- accessory.getService(Service.AccessoryInformation)
330
- .setCharacteristic(Characteristic.Manufacturer, deviceConfig.manufacturer || "Unknown")
331
- .setCharacteristic(Characteristic.Model, deviceConfig.model || "Unknown")
332
- .setCharacteristic(Characteristic.SerialNumber, deviceConfig.id.slice(8));
333
-
334
- isCached = false;
335
- }
336
-
337
- if (accessory && accessory.displayName !== deviceConfig.name) {
338
- this.log.info(
339
- "Configuration name %s differs from cached displayName %s. Updating cached displayName to %s ",
340
- deviceConfig.name, accessory.displayName, deviceConfig.name);
341
- accessory.displayName = deviceConfig.name;
342
- }
343
-
344
- this.cachedAccessories.set(deviceConfig.UUID, new Accessory(this, accessory, device, !isCached));
467
+ this.cachedAccessories.set(
468
+ deviceConfig.UUID,
469
+ new Accessory(this, accessory, device, !isCached),
470
+ );
471
+ }
472
+
473
+ removeAccessory(homebridgeAccessory) {
474
+ if (!homebridgeAccessory) return;
475
+
476
+ // Only real PlatformAccessory instances can be unregistered. cachedAccessories
477
+ // also holds accessory wrappers (set by addAccessory), and Homebridge throws a
478
+ // fatal TypeError when handed anything else, taking down the whole child bridge.
479
+ // Guard against it rather than trust every caller.
480
+ if (!(homebridgeAccessory instanceof PlatformAccessory)) {
481
+ return this.log.warn(
482
+ 'Skipped unregistering %s: not a PlatformAccessory.',
483
+ homebridgeAccessory.displayName,
484
+ );
345
485
  }
346
486
 
347
- removeAccessory(homebridgeAccessory) {
348
- if (!homebridgeAccessory) return;
487
+ this.log.warn('Unregistering', homebridgeAccessory.displayName);
349
488
 
350
- // Only real PlatformAccessory instances can be unregistered. cachedAccessories
351
- // also holds accessory wrappers (set by addAccessory), and Homebridge throws a
352
- // fatal TypeError when handed anything else, taking down the whole child bridge.
353
- // Guard against it rather than trust every caller.
354
- if (!(homebridgeAccessory instanceof PlatformAccessory)) {
355
- return this.log.warn('Skipped unregistering %s: not a PlatformAccessory.', homebridgeAccessory.displayName);
356
- }
489
+ this.cachedAccessories.delete(homebridgeAccessory.UUID);
490
+ this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [
491
+ homebridgeAccessory,
492
+ ]);
493
+ }
357
494
 
358
- this.log.warn('Unregistering', homebridgeAccessory.displayName);
359
-
360
- this.cachedAccessories.delete(homebridgeAccessory.UUID);
361
- this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [homebridgeAccessory]);
362
- }
363
-
364
- removeAccessoryByUUID(uuid) {
365
- if (uuid) this.removeAccessory(this.cachedAccessories.get(uuid));
366
- }
495
+ removeAccessoryByUUID(uuid) {
496
+ if (uuid) this.removeAccessory(this.cachedAccessories.get(uuid));
497
+ }
367
498
  }
@@ -6,36 +6,27 @@ const crypto = require('crypto');
6
6
  /*
7
7
  * TuyaCloudMessaging
8
8
  * ------------------
9
- * Optional realtime updates for cloud-backed devices, over Tuya's MQTT message
10
- * service. ONE instance is shared by every cloud device on the same Tuya
11
- * project (one broker connection delivers status for all of them); messages are
12
- * fanned out to the right device by its `devId`.
9
+ * Realtime updates for cloud-backed devices, over Tuya's MQTT message service.
10
+ * ONE instance is shared by every cloud device on the same Tuya project (one
11
+ * broker connection delivers status for all of them); messages are fanned out
12
+ * to the right device by its `devId`.
13
13
  *
14
- * This is intentionally a best-effort accelerator layered on top of polling:
15
- * if the optional `mqtt` package is missing, or the broker can't be reached, or
16
- * a message can't be decrypted, nothing breaks the devices simply keep
17
- * polling. When it IS working, changes (including physical button presses and
18
- * the rain sensor) show up in HomeKit within a second or two.
14
+ * Cloud updates (including physical button presses and the rain sensor) arrive
15
+ * over this stream rather than by polling, so they show up in HomeKit within a
16
+ * second or two. `mqtt` is a required dependency; if the broker can't be
17
+ * reached or a message can't be decrypted nothing crashes the stream simply
18
+ * reconnects and the cloud REST reads still cover initial state.
19
19
  *
20
20
  * The connection + AES-GCM message decryption are faithful to the
21
21
  * actively-maintained `0x5e/homebridge-tuya-platform` (`src/core/TuyaOpenMQ.ts`),
22
22
  * re-implemented here with Node's built-in `crypto`.
23
23
  */
24
24
 
25
+ const mqtt = require('mqtt');
26
+
25
27
  const GCM_TAG_LENGTH = 16;
26
28
  const PROTOCOL_DEVICE_STATUS = 4;
27
29
 
28
- // `mqtt` is an OPTIONAL dependency: realtime is a bonus, polling is the
29
- // guarantee. Loading it lazily keeps the plugin LAN-first and lets it run in
30
- // environments where mqtt isn't (or can't be) installed.
31
- let mqtt = null;
32
- let mqttLoadError = null;
33
- try {
34
- mqtt = require('mqtt');
35
- } catch (ex) {
36
- mqttLoadError = ex;
37
- }
38
-
39
30
  class TuyaCloudMessaging extends EventEmitter {
40
31
  constructor({api, log} = {}) {
41
32
  super();
@@ -56,10 +47,6 @@ class TuyaCloudMessaging extends EventEmitter {
56
47
  this.setMaxListeners(0);
57
48
  }
58
49
 
59
- static isAvailable() {
60
- return !!mqtt;
61
- }
62
-
63
50
  // Register a device's status handler and lazily start the shared connection.
64
51
  subscribeDevice(devId, handler) {
65
52
  const id = '' + devId;
@@ -72,13 +59,8 @@ class TuyaCloudMessaging extends EventEmitter {
72
59
  if (this._stopped || this._started) return;
73
60
  this._started = true;
74
61
 
75
- if (!mqtt) {
76
- this.log.warn(`Tuya Cloud realtime disabled: the optional "mqtt" package is not installed${mqttLoadError ? ` (${mqttLoadError.message})` : ''}. Devices will poll instead. Install it with "npm install mqtt" in the plugin folder to enable instant updates.`);
77
- return;
78
- }
79
-
80
62
  this._connect().catch(ex => {
81
- this.log.warn(`Tuya Cloud realtime could not start (${ex.message}); devices will poll instead.`);
63
+ this.log.warn(`Tuya Cloud realtime could not start (${ex.message}); it will keep retrying.`);
82
64
  this._scheduleRenew(60);
83
65
  });
84
66
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-tuya-plus",
3
- "version": "3.14.0-dev.29",
3
+ "version": "3.14.0-dev.30",
4
4
  "description": "A community-maintained Homebridge plugin for controlling Tuya devices in HomeKit. LAN-first, with an optional Tuya Cloud fallback for any device the LAN can't reach.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -29,12 +29,10 @@
29
29
  "fs-extra": "^8.1.0",
30
30
  "http-mitm-proxy": "^1.1.0",
31
31
  "json5": "^2.2.0",
32
+ "mqtt": "^5.15.1",
32
33
  "qrcode": "^1.4.1",
33
34
  "yaml": "^1.6.0"
34
35
  },
35
- "optionalDependencies": {
36
- "mqtt": "^5.15.1"
37
- },
38
36
  "keywords": [
39
37
  "homebridge-plugin",
40
38
  "homebridge",
@@ -110,9 +110,4 @@ describe('TuyaCloudMessaging — decryption + dispatch', () => {
110
110
  mq.subscribeDevice('DEV1', () => { throw new Error('should not be called'); });
111
111
  expect(() => mq._onMessage('topic', Buffer.from('not json'))).not.toThrow();
112
112
  });
113
-
114
- test('reports whether realtime is available (mqtt installed)', () => {
115
- // mqtt is an (optional) dependency of this project, so it should load.
116
- expect(typeof TuyaCloudMessaging.isAvailable()).toBe('boolean');
117
- });
118
113
  });
@@ -97,12 +97,6 @@ describe('TuyaLan — cloud session setup', () => {
97
97
  expect(TuyaCloudApi.mock.calls[0][0]).toMatchObject({accessId: 'aid', accessKey: 'akey', region: 'eu'});
98
98
  expect(TuyaCloudMessaging).toHaveBeenCalledTimes(1);
99
99
  });
100
-
101
- test('realtime:false skips the MQTT session', () => {
102
- run({cloud: {...CLOUD, realtime: false}, devices: [SW()]});
103
- expect(TuyaCloudApi).toHaveBeenCalledTimes(1);
104
- expect(TuyaCloudMessaging).not.toHaveBeenCalled();
105
- });
106
100
  });
107
101
 
108
102
  describe('TuyaLan — cloud participation', () => {
@@ -20,12 +20,23 @@ It helps in two situations:
20
20
 
21
21
  ## 2. Authorize the required API services
22
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
+
23
30
  In the project, go to **Service API → Go to Authorize** and make sure these are subscribed (all free):
24
31
 
25
- * **IoT Core**
26
- * **Authorization**
27
- * **Smart Home Basic Service**
28
- * **Device Status Notification** needed for realtime MQTT updates
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)
29
40
 
30
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).**
31
42