homebridge-tuya-plus 3.14.0-dev.5 → 3.14.0-dev.52

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 (53) hide show
  1. package/.github/workflows/publish-dev.yml +298 -31
  2. package/AGENTS.md +162 -0
  3. package/CLAUDE.md +1 -0
  4. package/Changelog.md +16 -3
  5. package/Readme.MD +8 -32
  6. package/config.schema.json +79 -79
  7. package/index.js +599 -358
  8. package/lib/AirPurifierAccessory.js +1 -1
  9. package/lib/BaseAccessory.js +54 -17
  10. package/lib/ConvectorAccessory.js +1 -1
  11. package/lib/CustomMultiOutletAccessory.js +2 -1
  12. package/lib/DoorbellAccessory.js +9 -16
  13. package/lib/GarageDoorAccessory.js +1 -1
  14. package/lib/IrrigationSystemAccessory.js +260 -120
  15. package/lib/MultiOutletAccessory.js +3 -2
  16. package/lib/OilDiffuserAccessory.js +10 -8
  17. package/lib/RGBTWLightAccessory.js +13 -11
  18. package/lib/RGBTWOutletAccessory.js +11 -9
  19. package/lib/SimpleBlindsAccessory.js +5 -5
  20. package/lib/SimpleDimmer2Accessory.js +1 -1
  21. package/lib/SimpleDimmerAccessory.js +10 -6
  22. package/lib/SimpleGarageDoorAccessory.js +122 -26
  23. package/lib/SimpleHeaterAccessory.js +4 -4
  24. package/lib/SimpleLightAccessory.js +1 -1
  25. package/lib/SwitchAccessory.js +3 -2
  26. package/lib/TWLightAccessory.js +2 -2
  27. package/lib/TuyaAccessory.js +75 -42
  28. package/lib/TuyaCloudApi.js +121 -8
  29. package/lib/TuyaCloudDevice.js +434 -31
  30. package/lib/TuyaCloudMessaging.js +34 -41
  31. package/lib/TuyaDevice.js +269 -0
  32. package/lib/TuyaDiscovery.js +25 -9
  33. package/lib/ValveAccessory.js +16 -12
  34. package/lib/VerticalBlindsWithTilt.js +27 -25
  35. package/lib/WledDimmerAccessory.js +46 -81
  36. package/package.json +5 -6
  37. package/scripts/cleanup-pr-tags.js +122 -0
  38. package/test/BaseAccessory.test.js +94 -15
  39. package/test/IrrigationSystemAccessory.test.js +293 -67
  40. package/test/MultiOutletAccessory.test.js +18 -3
  41. package/test/RGBTWLightAccessory.test.js +3 -3
  42. package/test/SimpleFanLightAccessory.test.js +3 -3
  43. package/test/SimpleGarageDoorAccessory.test.js +193 -19
  44. package/test/TuyaAccessory.protocol.test.js +76 -3
  45. package/test/TuyaCloudApi.test.js +110 -1
  46. package/test/TuyaCloudDevice.test.js +564 -2
  47. package/test/TuyaCloudMessaging.test.js +24 -5
  48. package/test/TuyaDevice.test.js +266 -0
  49. package/test/getCategory.test.js +1 -0
  50. package/test/index.test.js +271 -0
  51. package/test/support/mocks.js +13 -0
  52. package/wiki/Supported-Device-Types.md +64 -34
  53. package/wiki/Tuya-Cloud-Setup.md +31 -108
package/index.js CHANGED
@@ -1,358 +1,599 @@
1
- const TuyaAccessory = require('./lib/TuyaAccessory');
2
- const TuyaDiscovery = require('./lib/TuyaDiscovery');
3
- const TuyaCloudApi = require('./lib/TuyaCloudApi');
4
- const TuyaCloudDevice = require('./lib/TuyaCloudDevice');
5
- const TuyaCloudMessaging = require('./lib/TuyaCloudMessaging');
6
-
7
- const OutletAccessory = require('./lib/OutletAccessory');
8
- const SimpleLightAccessory = require('./lib/SimpleLightAccessory');
9
- const MultiOutletAccessory = require('./lib/MultiOutletAccessory');
10
- const CustomMultiOutletAccessory = require('./lib/CustomMultiOutletAccessory');
11
- const RGBTWLightAccessory = require('./lib/RGBTWLightAccessory');
12
- const RGBTWOutletAccessory = require('./lib/RGBTWOutletAccessory');
13
- const TWLightAccessory = require('./lib/TWLightAccessory');
14
- const AirConditionerAccessory = require('./lib/AirConditionerAccessory');
15
- const AirPurifierAccessory = require('./lib/AirPurifierAccessory');
16
- const DehumidifierAccessory = require('./lib/DehumidifierAccessory');
17
- const ConvectorAccessory = require('./lib/ConvectorAccessory');
18
- const GarageDoorAccessory = require('./lib/GarageDoorAccessory');
19
- const SimpleGarageDoorAccessory = require('./lib/SimpleGarageDoorAccessory');
20
- const WledDimmerAccessory = require('./lib/WledDimmerAccessory');
21
- const SimpleDimmer2Accessory = require('./lib/SimpleDimmer2Accessory');
22
- const SimpleBlindsAccessory = require('./lib/SimpleBlindsAccessory');
23
- const SimpleHeaterAccessory = require('./lib/SimpleHeaterAccessory');
24
- const SimpleFanAccessory = require('./lib/SimpleFanAccessory');
25
- const SimpleFanLightAccessory = require('./lib/SimpleFanLightAccessory');
26
- const SwitchAccessory = require('./lib/SwitchAccessory');
27
- const ValveAccessory = require('./lib/ValveAccessory');
28
- const IrrigationSystemAccessory = require('./lib/IrrigationSystemAccessory');
29
- const OilDiffuserAccessory = require('./lib/OilDiffuserAccessory');
30
- const DoorbellAccessory = require('./lib/DoorbellAccessory');
31
- const VerticalBlindsWithTilt = require('./lib/VerticalBlindsWithTilt');
32
- const PercentBlindsAccessory = require('./lib/PercentBlindsAccessory');
33
-
34
- const PLUGIN_NAME = 'homebridge-tuya-plus';
35
- const PLATFORM_NAME = 'TuyaLan';
36
- // Seed used to derive accessory UUIDs. Must remain 'homebridge-tuya' so that
37
- // devices already paired with HomeKit keep their existing identity (names,
38
- // rooms, automations).
39
- const UUID_SEED = 'homebridge-tuya';
40
- const DEFAULT_DISCOVER_TIMEOUT = 60000;
41
-
42
- // Lenient boolean coercion (matches BaseAccessory._coerceBoolean) so config
43
- // values like true / "true" / 1 all read as true.
44
- const coerceBoolean = (b, df = false) =>
45
- typeof b === 'boolean' ? b :
46
- typeof b === 'string' ? b.toLowerCase().trim() === 'true' :
47
- typeof b === 'number' ? b !== 0 : df;
48
-
49
- const CLASS_DEF = {
50
- outlet: OutletAccessory,
51
- simplelight: SimpleLightAccessory,
52
- rgbtwlight: RGBTWLightAccessory,
53
- rgbtwoutlet: RGBTWOutletAccessory,
54
- twlight: TWLightAccessory,
55
- multioutlet: MultiOutletAccessory,
56
- custommultioutlet: CustomMultiOutletAccessory,
57
- airconditioner: AirConditionerAccessory,
58
- airpurifier: AirPurifierAccessory,
59
- dehumidifier: DehumidifierAccessory,
60
- convector: ConvectorAccessory,
61
- garagedoor: GarageDoorAccessory,
62
- simplegaragedoor: SimpleGarageDoorAccessory,
63
- simpledimmer: WledDimmerAccessory,
64
- wleddimmer: WledDimmerAccessory,
65
- simpledimmer2: SimpleDimmer2Accessory,
66
- simpleblinds: SimpleBlindsAccessory,
67
- simpleheater: SimpleHeaterAccessory,
68
- switch: SwitchAccessory,
69
- fan: SimpleFanAccessory,
70
- fanlight: SimpleFanLightAccessory,
71
- watervalve: ValveAccessory,
72
- irrigationsystem: IrrigationSystemAccessory,
73
- oildiffuser: OilDiffuserAccessory,
74
- doorbell: DoorbellAccessory,
75
- verticalblindswithtilt: VerticalBlindsWithTilt,
76
- percentblinds: PercentBlindsAccessory
77
- };
78
-
79
- let Characteristic, Formats, Perms, Categories, PlatformAccessory, Service, AdaptiveLightingController, UUID;
80
-
81
- module.exports = function(homebridge) {
82
- ({
83
- platformAccessory: PlatformAccessory,
84
- hap: {Characteristic, Formats, Perms, Categories, Service, AdaptiveLightingController, uuid: UUID}
85
- } = homebridge);
86
-
87
- homebridge.registerPlatform(PLUGIN_NAME, PLATFORM_NAME, TuyaLan, true);
88
- };
89
-
90
- class TuyaLan {
91
- constructor(...props) {
92
- [this.log, this.config, this.api] = [...props];
93
-
94
- this.cachedAccessories = new Map();
95
- // Shared Tuya Cloud clients, keyed by credential set, so several
96
- // cloud devices on the same project share one token + one realtime
97
- // (MQTT) connection. Empty unless cloud devices are configured.
98
- this.cloudApis = new Map();
99
- this.cloudMessagers = new Map();
100
- this.api.hap.EnergyCharacteristics = require('./lib/EnergyCharacteristics')(this.api.hap);
101
-
102
- if(!this.config || !this.config.devices) {
103
- this.log("No devices found. Check that you have specified them in your config.json file.");
104
- return false;
105
- }
106
-
107
- this._expectedUUIDs = this.config.devices.map(device => UUID.generate(UUID_SEED +(device.fake ? ':fake:' : ':') + device.id));
108
-
109
- this.api.on('didFinishLaunching', () => {
110
- this.discoverDevices();
111
- });
112
- }
113
-
114
- discoverDevices() {
115
- const devices = {};
116
- const connectedDevices = [];
117
- const fakeDevices = [];
118
- const cloudDevices = [];
119
- this.config.devices.forEach(device => {
120
- try {
121
- device.id = ('' + device.id).trim();
122
- // Cloud devices don't need a local key; only trim when present.
123
- if (device.key != null) device.key = ('' + device.key).trim();
124
- device.type = ('' + device.type).trim();
125
-
126
- device.ip = ('' + (device.ip || '')).trim();
127
- } catch(ex) {}
128
-
129
- if (!device.type) return this.log.error('%s (%s) doesn\'t have a type defined.', device.name || 'Unnamed device', device.id);
130
- 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);
131
-
132
- if (this._isCloudDevice(device)) cloudDevices.push({name: device.id.slice(8), ...device});
133
- else if (device.fake) fakeDevices.push({name: device.id.slice(8), ...device});
134
- else devices[device.id] = {name: device.id.slice(8), ...device};
135
- });
136
-
137
- // Cloud devices are reached over the internet, not the LAN, so they need
138
- // no discovery wire them up right away.
139
- cloudDevices.forEach(config => this._addCloudAccessory(config));
140
-
141
- const deviceIds = Object.keys(devices);
142
- if (deviceIds.length === 0) {
143
- if (cloudDevices.length === 0) this.log.error('No valid configured devices found.');
144
- return; // cloud-only (or empty) configuration: nothing to discover over LAN
145
- }
146
-
147
- this.log.info('Starting discovery...');
148
-
149
- TuyaDiscovery.start({ids: deviceIds, log: this.log})
150
- .on('discover', config => {
151
- if (!config || !config.id) return;
152
- if (!devices[config.id]) return this.log.warn('Discovered a device that has not been configured yet (%s@%s).', config.id, config.ip);
153
-
154
- connectedDevices.push(config.id);
155
-
156
- this.log.info('Discovered %s (%s) identified as %s (%s)', devices[config.id].name, config.id, devices[config.id].type, config.version);
157
-
158
- // The version broadcast by the device wins over a configured `version`,
159
- // but `forceVersion` overrides everything (e.g. to pin a device that
160
- // reports a newer protocol, like 3.6, to a specific stack).
161
- const device = new TuyaAccessory({
162
- ...devices[config.id], ...config,
163
- ...(devices[config.id].forceVersion ? {version: devices[config.id].forceVersion} : {}),
164
- log: this.log,
165
- UUID: UUID.generate(UUID_SEED + ':' + config.id),
166
- connect: false
167
- });
168
- this.addAccessory(device);
169
- });
170
-
171
- fakeDevices.forEach(config => {
172
- this.log.info('Adding fake device: %s', config.name);
173
- this.addAccessory(new TuyaAccessory({
174
- ...config,
175
- log: this.log,
176
- UUID: UUID.generate(UUID_SEED + ':fake:' + config.id),
177
- connect: false
178
- }));
179
- });
180
-
181
- setTimeout(() => {
182
- deviceIds.forEach(deviceId => {
183
- if (connectedDevices.includes(deviceId)) return;
184
-
185
- if (devices[deviceId].ip) {
186
-
187
- this.log.info('Failed to discover %s (%s) in time but will connect via %s.', devices[deviceId].name, deviceId, devices[deviceId].ip);
188
-
189
- const device = new TuyaAccessory({
190
- ...devices[deviceId],
191
- ...(devices[deviceId].forceVersion ? {version: devices[deviceId].forceVersion} : {}),
192
- log: this.log,
193
- UUID: UUID.generate(UUID_SEED + ':' + deviceId),
194
- connect: false
195
- });
196
- this.addAccessory(device);
197
- } else {
198
- this.log.warn('Failed to discover %s (%s) in time but will keep looking.', devices[deviceId].name, deviceId);
199
- }
200
- });
201
- }, this.config.discoverTimeout ?? DEFAULT_DISCOVER_TIMEOUT);
202
- }
203
-
204
- /* ------------------------------------------------------------------ *
205
- * Tuya Cloud helpers (for devices that can't be reached over the LAN,
206
- * e.g. battery-powered "sleepy" irrigation timers). This plugin stays
207
- * LAN-first; these paths are only exercised by devices opting in with
208
- * `cloud: true` (or a per-device `cloud` credentials object).
209
- * ------------------------------------------------------------------ */
210
-
211
- _isCloudDevice(device) {
212
- return !!(device && (device.cloud === true || (typeof device.cloud === 'object' && device.cloud)));
213
- }
214
-
215
- // Effective cloud credentials/options for a device: the platform-level
216
- // `cloud` block, overlaid with any per-device `cloud` object.
217
- _resolveCloudConfig(device) {
218
- const platform = (this.config.cloud && typeof this.config.cloud === 'object') ? this.config.cloud : {};
219
- const perDevice = (typeof device.cloud === 'object' && device.cloud) ? device.cloud : {};
220
- return {...platform, ...perDevice};
221
- }
222
-
223
- // One TuyaCloudApi per credential set, so multiple cloud devices on the
224
- // same Tuya project share a single token.
225
- _getCloudApi(cloudCfg) {
226
- const key = TuyaCloudApi.keyFor(cloudCfg);
227
- if (!this.cloudApis.has(key)) {
228
- this.cloudApis.set(key, new TuyaCloudApi({...cloudCfg, log: this.log}));
229
- }
230
- return this.cloudApis.get(key);
231
- }
232
-
233
- // One shared realtime (MQTT) stream per credential set, unless realtime is
234
- // disabled. Returns null when realtime is off — the device then shows its
235
- // initial state and stays controllable, but won't receive live updates.
236
- _getCloudMessaging(api, cloudCfg) {
237
- const realtime = cloudCfg.realtime === undefined ? true : coerceBoolean(cloudCfg.realtime, true);
238
- if (!realtime) return null;
239
- const key = TuyaCloudApi.keyFor(cloudCfg);
240
- if (!this.cloudMessagers.has(key)) {
241
- this.cloudMessagers.set(key, new TuyaCloudMessaging({api, log: this.log}));
242
- }
243
- return this.cloudMessagers.get(key);
244
- }
245
-
246
- _addCloudAccessory(config) {
247
- const cloudCfg = this._resolveCloudConfig(config);
248
- if (!cloudCfg.accessId || !cloudCfg.accessKey) {
249
- return this.log.error('%s (%s) is configured for the Tuya Cloud, but no credentials were found. Add a top-level "cloud" block (accessId, accessKey, region) or a per-device "cloud" object.', config.name, config.id);
250
- }
251
-
252
- const api = this._getCloudApi(cloudCfg);
253
- const messaging = this._getCloudMessaging(api, cloudCfg);
254
-
255
- this.log.info('Adding cloud device: %s (%s) via %s', config.name, config.id, api.endpoint);
256
-
257
- this.addAccessory(new TuyaCloudDevice({
258
- ...config,
259
- cloud: true, // normalise so accessories can detect cloud mode
260
- cloudApi: api,
261
- messaging,
262
- log: this.log,
263
- UUID: UUID.generate(UUID_SEED + ':' + config.id),
264
- connect: false
265
- }));
266
- }
267
-
268
- registerPlatformAccessories(platformAccessories) {
269
- this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, Array.isArray(platformAccessories) ? platformAccessories : [platformAccessories]);
270
- }
271
-
272
- configureAccessory(accessory) {
273
- // also checks null objects or empty config - this._expectedUUIDs
274
- if (accessory instanceof PlatformAccessory && this._expectedUUIDs && this._expectedUUIDs.includes(accessory.UUID)) {
275
- this.cachedAccessories.set(accessory.UUID, accessory);
276
- accessory.services.forEach(service => {
277
- if (service.UUID === Service.AccessoryInformation.UUID) return;
278
- service.characteristics.some(characteristic => {
279
- if (!characteristic.props ||
280
- !Array.isArray(characteristic.props.perms) ||
281
- characteristic.props.perms.length !== 3 ||
282
- !(characteristic.props.perms.includes(Perms.WRITE) && characteristic.props.perms.includes(Perms.NOTIFY))
283
- ) return;
284
-
285
- this.log.info('Marked %s unreachable by faulting Service.%s.%s', accessory.displayName, service.displayName, characteristic.displayName);
286
-
287
- characteristic.updateValue(new Error('Unreachable'));
288
- return true;
289
- });
290
- });
291
- } else {
292
- /*
293
- * Irrespective of this unregistering, Homebridge continues
294
- * to "_prepareAssociatedHAPAccessory" and "addBridgedAccessory".
295
- * This timeout will hopefully remove the accessory after that has happened.
296
- */
297
- setTimeout(() => {
298
- this.removeAccessory(accessory);
299
- }, 1000);
300
- }
301
- }
302
-
303
- addAccessory(device) {
304
- const deviceConfig = device.context;
305
- const type = (deviceConfig.type || '').toLowerCase();
306
-
307
- const Accessory = CLASS_DEF[type];
308
-
309
- let accessory = this.cachedAccessories.get(deviceConfig.UUID),
310
- isCached = true;
311
-
312
- const expectedCategory = Accessory.getCategory(Categories);
313
-
314
- // Only treat a cached accessory as a "different type" when we actually
315
- // have a category to compare against. If getCategory() resolves to
316
- // undefined (e.g. an unknown HAP category constant), HomeKit stores the
317
- // accessory as Categories.OTHER, so an undefined expectation would never
318
- // match and we would needlessly unregister & recreate the accessory on
319
- // every restart — wiping its HomeKit identity (name, room, automations).
320
- if (accessory && expectedCategory !== undefined && accessory.category !== expectedCategory) {
321
- this.log.info("%s has a different type (%s vs %s)", accessory.displayName, accessory.category, expectedCategory);
322
- this.removeAccessory(accessory);
323
- accessory = null;
324
- }
325
-
326
- if (!accessory) {
327
- accessory = new PlatformAccessory(deviceConfig.name, deviceConfig.UUID, expectedCategory);
328
- accessory.getService(Service.AccessoryInformation)
329
- .setCharacteristic(Characteristic.Manufacturer, deviceConfig.manufacturer || "Unknown")
330
- .setCharacteristic(Characteristic.Model, deviceConfig.model || "Unknown")
331
- .setCharacteristic(Characteristic.SerialNumber, deviceConfig.id.slice(8));
332
-
333
- isCached = false;
334
- }
335
-
336
- if (accessory && accessory.displayName !== deviceConfig.name) {
337
- this.log.info(
338
- "Configuration name %s differs from cached displayName %s. Updating cached displayName to %s ",
339
- deviceConfig.name, accessory.displayName, deviceConfig.name);
340
- accessory.displayName = deviceConfig.name;
341
- }
342
-
343
- this.cachedAccessories.set(deviceConfig.UUID, new Accessory(this, accessory, device, !isCached));
344
- }
345
-
346
- removeAccessory(homebridgeAccessory) {
347
- if (!homebridgeAccessory) return;
348
-
349
- this.log.warn('Unregistering', homebridgeAccessory.displayName);
350
-
351
- delete this.cachedAccessories[homebridgeAccessory.UUID];
352
- this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [homebridgeAccessory]);
353
- }
354
-
355
- removeAccessoryByUUID(uuid) {
356
- if (uuid) this.removeAccessory(this.cachedAccessories.get(uuid));
357
- }
358
- }
1
+ const TuyaAccessory = require('./lib/TuyaAccessory');
2
+ const TuyaDevice = require('./lib/TuyaDevice');
3
+ const TuyaDiscovery = require('./lib/TuyaDiscovery');
4
+ const TuyaCloudApi = require('./lib/TuyaCloudApi');
5
+ const TuyaCloudMessaging = require('./lib/TuyaCloudMessaging');
6
+
7
+ const OutletAccessory = require('./lib/OutletAccessory');
8
+ const SimpleLightAccessory = require('./lib/SimpleLightAccessory');
9
+ const MultiOutletAccessory = require('./lib/MultiOutletAccessory');
10
+ const CustomMultiOutletAccessory = require('./lib/CustomMultiOutletAccessory');
11
+ const RGBTWLightAccessory = require('./lib/RGBTWLightAccessory');
12
+ const RGBTWOutletAccessory = require('./lib/RGBTWOutletAccessory');
13
+ const TWLightAccessory = require('./lib/TWLightAccessory');
14
+ const AirConditionerAccessory = require('./lib/AirConditionerAccessory');
15
+ const AirPurifierAccessory = require('./lib/AirPurifierAccessory');
16
+ const DehumidifierAccessory = require('./lib/DehumidifierAccessory');
17
+ const ConvectorAccessory = require('./lib/ConvectorAccessory');
18
+ const GarageDoorAccessory = require('./lib/GarageDoorAccessory');
19
+ const SimpleGarageDoorAccessory = require('./lib/SimpleGarageDoorAccessory');
20
+ const WledDimmerAccessory = require('./lib/WledDimmerAccessory');
21
+ const SimpleDimmerAccessory = require('./lib/SimpleDimmerAccessory');
22
+ const SimpleDimmer2Accessory = require('./lib/SimpleDimmer2Accessory');
23
+ const SimpleBlindsAccessory = require('./lib/SimpleBlindsAccessory');
24
+ const SimpleHeaterAccessory = require('./lib/SimpleHeaterAccessory');
25
+ const SimpleFanAccessory = require('./lib/SimpleFanAccessory');
26
+ const SimpleFanLightAccessory = require('./lib/SimpleFanLightAccessory');
27
+ const SwitchAccessory = require('./lib/SwitchAccessory');
28
+ const ValveAccessory = require('./lib/ValveAccessory');
29
+ const IrrigationSystemAccessory = require('./lib/IrrigationSystemAccessory');
30
+ const OilDiffuserAccessory = require('./lib/OilDiffuserAccessory');
31
+ const DoorbellAccessory = require('./lib/DoorbellAccessory');
32
+ const VerticalBlindsWithTilt = require('./lib/VerticalBlindsWithTilt');
33
+ const PercentBlindsAccessory = require('./lib/PercentBlindsAccessory');
34
+
35
+ const PLUGIN_NAME = 'homebridge-tuya-plus';
36
+ const PLATFORM_NAME = 'TuyaLan';
37
+ // Seed used to derive accessory UUIDs. Must remain 'homebridge-tuya' so that
38
+ // devices already paired with HomeKit keep their existing identity (names,
39
+ // rooms, automations).
40
+ const UUID_SEED = 'homebridge-tuya';
41
+ const DEFAULT_DISCOVER_TIMEOUT = 60000;
42
+
43
+ const CLASS_DEF = {
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,
71
+ };
72
+
73
+ let Characteristic,
74
+ Formats,
75
+ Perms,
76
+ Categories,
77
+ PlatformAccessory,
78
+ Service,
79
+ AdaptiveLightingController,
80
+ UUID;
81
+
82
+ // Lenient boolean coercion for platform-level config flags, mirroring
83
+ // BaseAccessory._coerceBoolean so the same true / 'true' / 1 spellings users
84
+ // already use on device options are accepted on top-level options too.
85
+ function coerceBoolean(value, defaultValue) {
86
+ const df = defaultValue || false;
87
+ return typeof value === 'boolean'
88
+ ? value
89
+ : typeof value === 'string'
90
+ ? value.toLowerCase().trim() === 'true'
91
+ : typeof value === 'number'
92
+ ? value !== 0
93
+ : df;
94
+ }
95
+
96
+ module.exports = function (homebridge) {
97
+ ({
98
+ platformAccessory: PlatformAccessory,
99
+ hap: {
100
+ Characteristic,
101
+ Formats,
102
+ Perms,
103
+ Categories,
104
+ Service,
105
+ AdaptiveLightingController,
106
+ uuid: UUID,
107
+ },
108
+ } = homebridge);
109
+
110
+ homebridge.registerPlatform(PLUGIN_NAME, PLATFORM_NAME, TuyaLan, true);
111
+ };
112
+
113
+ class TuyaLan {
114
+ constructor(...props) {
115
+ [this.log, this.config, this.api] = [...props];
116
+
117
+ this.cachedAccessories = new Map();
118
+ // One TuyaDevice per configured device, keyed by Tuya id, so discovery can
119
+ // hand each its LAN target once it's found on the network.
120
+ this.tuyaDevices = new Map();
121
+ // A SINGLE shared Tuya Cloud session (OpenAPI token + realtime MQTT) for the
122
+ // whole platform the global fallback every device can lean on. Stays null
123
+ // unless cloud credentials are configured.
124
+ this.cloudApi = null;
125
+ this.cloudMessaging = null;
126
+ this.api.hap.EnergyCharacteristics = require('./lib/EnergyCharacteristics')(
127
+ this.api.hap,
128
+ );
129
+
130
+ if (!this.config || !this.config.devices) {
131
+ this.log(
132
+ 'No devices found. Check that you have specified them in your config.json file.',
133
+ );
134
+ return false;
135
+ }
136
+
137
+ this._expectedUUIDs = this.config.devices.map((device) =>
138
+ UUID.generate(UUID_SEED + (device.fake ? ':fake:' : ':') + device.id),
139
+ );
140
+
141
+ this.api.on('didFinishLaunching', () => {
142
+ this.discoverDevices();
143
+ });
144
+ }
145
+
146
+ discoverDevices() {
147
+ // Bring up the single shared cloud session first, so every device that opts
148
+ // into (or falls back to) the cloud shares one token and one MQTT stream.
149
+ this._setupCloudSession();
150
+
151
+ const lanDeviceIds = []; // ids we still want to find on the LAN
152
+ const connectedDevices = []; // ids discovered on the LAN
153
+ const fakeDevices = [];
154
+ const seenUUIDs = new Set(); // accessory UUIDs already configured this run
155
+
156
+ this.config.devices.forEach((device) => {
157
+ try {
158
+ device.id = ('' + device.id).trim();
159
+ // Cloud-only devices don't need a local key; only trim when present.
160
+ if (device.key != null) device.key = ('' + device.key).trim();
161
+ device.type = ('' + device.type).trim();
162
+
163
+ device.ip = ('' + (device.ip || '')).trim();
164
+ } catch (ex) {}
165
+
166
+ if (!device.type)
167
+ return this.log.error(
168
+ "%s (%s) doesn't have a type defined.",
169
+ device.name || 'Unnamed device',
170
+ device.id,
171
+ );
172
+ if (!CLASS_DEF[device.type.toLowerCase()])
173
+ return this.log.error(
174
+ "%s (%s) doesn't have a valid type defined.",
175
+ device.name || 'Unnamed device',
176
+ device.id,
177
+ );
178
+
179
+ // Two config entries that resolve to the same accessory UUID (the same
180
+ // Tuya id, real or fake) make addAccessory process that UUID twice. The
181
+ // second pass reads back the accessory wrapper the first pass cached
182
+ // instead of a PlatformAccessory and crashes the child bridge while
183
+ // trying to unregister it. Keep the first entry; skip the rest.
184
+ const uuid = UUID.generate(
185
+ UUID_SEED + (device.fake ? ':fake:' : ':') + device.id,
186
+ );
187
+ if (seenUUIDs.has(uuid))
188
+ return this.log.warn(
189
+ '%s (%s) is configured more than once; ignoring the duplicate entry.',
190
+ device.name || 'Unnamed device',
191
+ device.id,
192
+ );
193
+ seenUUIDs.add(uuid);
194
+
195
+ if (device.fake) {
196
+ fakeDevices.push({ name: device.id.slice(8), ...device });
197
+ return;
198
+ }
199
+
200
+ const tuyaDevice = this._createDevice({
201
+ name: device.id.slice(8),
202
+ ...device,
203
+ });
204
+ this.tuyaDevices.set(device.id, tuyaDevice);
205
+ this.addAccessory(tuyaDevice);
206
+
207
+ // A device with a local key can be reached on the LAN, so it wants
208
+ // discovery; the cloud, when configured, is its fallback. A device with
209
+ // no key is cloud-only (e.g. a battery-powered "sleepy" unit) and relies
210
+ // on the cloud session to be reachable at all.
211
+ if (device.key) lanDeviceIds.push(device.id);
212
+ else if (!this.cloudApi)
213
+ this.log.error(
214
+ '%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.',
215
+ tuyaDevice.context.name,
216
+ device.id,
217
+ );
218
+ });
219
+
220
+ fakeDevices.forEach((config) => {
221
+ this.log.info('Adding fake device: %s', config.name);
222
+ this.addAccessory(
223
+ new TuyaAccessory({
224
+ ...config,
225
+ log: this.log,
226
+ UUID: UUID.generate(UUID_SEED + ':fake:' + config.id),
227
+ connect: false,
228
+ }),
229
+ );
230
+ });
231
+
232
+ if (lanDeviceIds.length === 0) {
233
+ if (this.tuyaDevices.size === 0 && fakeDevices.length === 0)
234
+ this.log.error('No valid configured devices found.');
235
+ return; // cloud-only (or empty) configuration: nothing to discover over the LAN
236
+ }
237
+
238
+ // Debug switch: pretend LAN discovery fails for every device so the whole
239
+ // platform runs over the Tuya Cloud fallback — a way to exercise the cloud
240
+ // path end-to-end without taking devices off the LAN. We simply never start
241
+ // discovery (and never attach a LAN backend), leaving the cloud session each
242
+ // device was already wired with as its only transport. Useless without a
243
+ // configured cloud session — the affected devices then have no transport at
244
+ // all, so say so per device rather than failing silently.
245
+ if (coerceBoolean(this._debugConfig().forceCloudFallback)) {
246
+ this.log.warn(
247
+ 'debug.forceCloudFallback is set: skipping LAN discovery; every device is forced onto the Tuya Cloud fallback.',
248
+ );
249
+ lanDeviceIds.forEach((deviceId) => {
250
+ const tuyaDevice = this.tuyaDevices.get(deviceId);
251
+ if (!tuyaDevice) return;
252
+ if (tuyaDevice.cloud) {
253
+ this.log.info(
254
+ 'Forcing %s (%s) onto the Tuya Cloud fallback (debug.forceCloudFallback).',
255
+ tuyaDevice.context.name,
256
+ deviceId,
257
+ );
258
+ } else {
259
+ this.log.error(
260
+ 'debug.forceCloudFallback is set but %s (%s) has no Tuya Cloud session, so it can\'t be reached.',
261
+ tuyaDevice.context.name,
262
+ deviceId,
263
+ );
264
+ }
265
+ });
266
+ return;
267
+ }
268
+
269
+ this.log.info('Starting discovery...');
270
+
271
+ TuyaDiscovery.start({ ids: lanDeviceIds, log: this.log }).on(
272
+ 'discover',
273
+ (config) => {
274
+ if (!config || !config.id) return;
275
+ const tuyaDevice = this.tuyaDevices.get(config.id);
276
+ if (!tuyaDevice) return this._warnUnconfiguredDevice(config);
277
+ if (connectedDevices.includes(config.id)) return;
278
+
279
+ connectedDevices.push(config.id);
280
+
281
+ this.log.info(
282
+ 'Discovered %s (%s) identified as %s (%s)',
283
+ tuyaDevice.context.name,
284
+ config.id,
285
+ tuyaDevice.context.type,
286
+ config.version,
287
+ );
288
+
289
+ // The version broadcast by the device wins over a configured `version`,
290
+ // but `forceVersion` overrides everything; attachLan applies that order.
291
+ tuyaDevice.attachLan({ ip: config.ip, version: config.version });
292
+ },
293
+ );
294
+
295
+ setTimeout(() => {
296
+ lanDeviceIds.forEach((deviceId) => {
297
+ if (connectedDevices.includes(deviceId)) return;
298
+
299
+ const tuyaDevice = this.tuyaDevices.get(deviceId);
300
+ if (!tuyaDevice) return;
301
+
302
+ if (tuyaDevice.context.ip) {
303
+ this.log.info(
304
+ 'Failed to discover %s (%s) in time but will connect via %s.',
305
+ tuyaDevice.context.name,
306
+ deviceId,
307
+ tuyaDevice.context.ip,
308
+ );
309
+ tuyaDevice.attachLan({ ip: tuyaDevice.context.ip });
310
+ } else if (tuyaDevice.cloud) {
311
+ this.log.info(
312
+ 'Failed to discover %s (%s) on the LAN; it will run over the Tuya Cloud fallback.',
313
+ tuyaDevice.context.name,
314
+ deviceId,
315
+ );
316
+ } else {
317
+ this.log.warn(
318
+ 'Failed to discover %s (%s) in time but will keep looking.',
319
+ tuyaDevice.context.name,
320
+ deviceId,
321
+ );
322
+ }
323
+ });
324
+ }, this.config.discoverTimeout ?? DEFAULT_DISCOVER_TIMEOUT);
325
+ }
326
+
327
+ // The undocumented top-level `debug` block holds switches that only matter
328
+ // when developing or testing the plugin (e.g. forcing the Tuya Cloud path).
329
+ // It is intentionally absent from config.schema.json so it never surfaces in
330
+ // the Homebridge UI. Always returns an object so callers can read a flag
331
+ // without first guarding for the block's presence.
332
+ _debugConfig() {
333
+ return this.config.debug && typeof this.config.debug === 'object'
334
+ ? this.config.debug
335
+ : {};
336
+ }
337
+
338
+ // A device showed up on the LAN that isn't in the user's config. When the
339
+ // optional cloud session is available we look the device up there first, so
340
+ // the warning can name it (and hint at its product/category) and the user
341
+ // can tell which physical device still needs configuring — far more useful
342
+ // than a bare id@ip. If there's no cloud session, or the lookup fails (the
343
+ // device belongs to another account, the device-management API isn't
344
+ // authorised, the network is down, …), fall back to the plain warning.
345
+ // Never throws: enrichment is best-effort and must not disrupt discovery.
346
+ async _warnUnconfiguredDevice(config) {
347
+ const bareWarning = () =>
348
+ this.log.warn(
349
+ 'Discovered a device that has not been configured yet (%s@%s).',
350
+ config.id,
351
+ config.ip,
352
+ );
353
+
354
+ if (!this.cloudApi) return bareWarning();
355
+
356
+ let info;
357
+ try {
358
+ info = await this.cloudApi.getDeviceInfo(config.id);
359
+ } catch (ex) {
360
+ this.log.debug(
361
+ 'Tuya Cloud lookup for unconfigured device %s failed: %s',
362
+ config.id,
363
+ ex.message,
364
+ );
365
+ }
366
+ if (!info || !info.name) return bareWarning();
367
+
368
+ const details = [];
369
+ if (info.product_name) details.push(info.product_name);
370
+ if (info.category) details.push(`category ${info.category}`);
371
+
372
+ this.log.warn(
373
+ 'Discovered a device that has not been configured yet: %s%s (%s@%s).',
374
+ `"${info.name}"`,
375
+ details.length ? ` — ${details.join(', ')}` : '',
376
+ config.id,
377
+ config.ip,
378
+ );
379
+ }
380
+
381
+ /* ------------------------------------------------------------------ *
382
+ * Tuya Cloud — a single, global fallback session.
383
+ *
384
+ * This plugin stays LAN-first: every device is controlled locally when it
385
+ * can be. When a top-level `cloud` block is configured, the plugin keeps one
386
+ * shared Tuya Cloud session alive in the background, and every device gains a
387
+ * transparent cloud fallback for the moments the LAN can't be reached (a
388
+ * flaky connection, or a battery-powered "sleepy" device that never appears
389
+ * on the LAN at all). It's all opt-in — without `cloud`, nothing here runs.
390
+ * ------------------------------------------------------------------ */
391
+
392
+ _setupCloudSession() {
393
+ const cloudCfg =
394
+ this.config.cloud && typeof this.config.cloud === 'object'
395
+ ? this.config.cloud
396
+ : null;
397
+ if (!cloudCfg) return; // no cloud block: the plugin runs purely on the LAN
398
+
399
+ if (!cloudCfg.accessId || !cloudCfg.accessKey) {
400
+ return this.log.error(
401
+ 'A "cloud" block is present but is missing accessId/accessKey, so the Tuya Cloud fallback is disabled. See the wiki: Tuya Cloud Setup.',
402
+ );
403
+ }
404
+
405
+ this.cloudApi = new TuyaCloudApi({
406
+ ...cloudCfg,
407
+ log: this.log,
408
+ logHttp: coerceBoolean(this._debugConfig().logCloudHttp),
409
+ });
410
+ this.cloudMessaging = new TuyaCloudMessaging({
411
+ api: this.cloudApi,
412
+ log: this.log,
413
+ });
414
+
415
+ this.log.info(
416
+ 'Tuya Cloud fallback enabled via %s.',
417
+ this.cloudApi.endpoint,
418
+ );
419
+ }
420
+
421
+ _createDevice(device) {
422
+ // The one shared cloud session, when configured, backs every device as its
423
+ // fallback. There is no per-device cloud configuration.
424
+ const usesCloud = !!this.cloudApi;
425
+ return new TuyaDevice({
426
+ ...device,
427
+ cloudApi: usesCloud ? this.cloudApi : undefined,
428
+ messaging: usesCloud ? this.cloudMessaging : undefined,
429
+ cloudStartDelay: usesCloud ? this._nextCloudStartDelay() : 0,
430
+ log: this.log,
431
+ UUID: UUID.generate(UUID_SEED + ':' + device.id),
432
+ connect: false,
433
+ });
434
+ }
435
+
436
+ // Spread the cloud devices' first reads over a few seconds so a large install
437
+ // doesn't fire dozens of OpenAPI calls at once and trip Tuya's per-second rate
438
+ // limit at startup.
439
+ _nextCloudStartDelay() {
440
+ this._cloudStartCount = (this._cloudStartCount || 0) + 1;
441
+ return Math.min(15000, (this._cloudStartCount - 1) * 300);
442
+ }
443
+
444
+ registerPlatformAccessories(platformAccessories) {
445
+ this.api.registerPlatformAccessories(
446
+ PLUGIN_NAME,
447
+ PLATFORM_NAME,
448
+ Array.isArray(platformAccessories)
449
+ ? platformAccessories
450
+ : [platformAccessories],
451
+ );
452
+ }
453
+
454
+ configureAccessory(accessory) {
455
+ // also checks null objects or empty config - this._expectedUUIDs
456
+ if (
457
+ accessory instanceof PlatformAccessory &&
458
+ this._expectedUUIDs &&
459
+ this._expectedUUIDs.includes(accessory.UUID)
460
+ ) {
461
+ this.cachedAccessories.set(accessory.UUID, accessory);
462
+ accessory.services.forEach((service) => {
463
+ if (service.UUID === Service.AccessoryInformation.UUID) return;
464
+ service.characteristics.some((characteristic) => {
465
+ if (
466
+ !characteristic.props ||
467
+ !Array.isArray(characteristic.props.perms) ||
468
+ characteristic.props.perms.length !== 3 ||
469
+ !(
470
+ characteristic.props.perms.includes(Perms.WRITE) &&
471
+ characteristic.props.perms.includes(Perms.NOTIFY)
472
+ )
473
+ )
474
+ return;
475
+
476
+ // Each cached accessory starts faulted ("No Response") until its
477
+ // device connects — expected, and once per accessory at startup, so
478
+ // it belongs at debug rather than as a wall of cryptic info lines.
479
+ this.log.debug(
480
+ 'Marked %s unreachable by faulting Service.%s.%s',
481
+ accessory.displayName,
482
+ service.displayName,
483
+ characteristic.displayName,
484
+ );
485
+
486
+ characteristic.updateValue(new Error('Unreachable'));
487
+ return true;
488
+ });
489
+ });
490
+ } else {
491
+ /*
492
+ * Irrespective of this unregistering, Homebridge continues
493
+ * to "_prepareAssociatedHAPAccessory" and "addBridgedAccessory".
494
+ * This timeout will hopefully remove the accessory after that has happened.
495
+ */
496
+ setTimeout(() => {
497
+ this.removeAccessory(accessory);
498
+ }, 1000);
499
+ }
500
+ }
501
+
502
+ addAccessory(device) {
503
+ const deviceConfig = device.context;
504
+ const type = (deviceConfig.type || '').toLowerCase();
505
+
506
+ const Accessory = CLASS_DEF[type];
507
+
508
+ let accessory = this.cachedAccessories.get(deviceConfig.UUID),
509
+ isCached = true;
510
+
511
+ const expectedCategory = Accessory.getCategory(Categories);
512
+
513
+ // Only treat a cached accessory as a "different type" when we actually
514
+ // have a category to compare against. If getCategory() resolves to
515
+ // undefined (e.g. an unknown HAP category constant), HomeKit stores the
516
+ // accessory as Categories.OTHER, so an undefined expectation would never
517
+ // match and we would needlessly unregister & recreate the accessory on
518
+ // every restart — wiping its HomeKit identity (name, room, automations).
519
+ if (
520
+ accessory &&
521
+ expectedCategory !== undefined &&
522
+ accessory.category !== expectedCategory
523
+ ) {
524
+ this.log.info(
525
+ '%s has a different type (%s vs %s)',
526
+ accessory.displayName,
527
+ accessory.category,
528
+ expectedCategory,
529
+ );
530
+ this.removeAccessory(accessory);
531
+ accessory = null;
532
+ }
533
+
534
+ if (!accessory) {
535
+ accessory = new PlatformAccessory(
536
+ deviceConfig.name,
537
+ deviceConfig.UUID,
538
+ expectedCategory,
539
+ );
540
+ accessory
541
+ .getService(Service.AccessoryInformation)
542
+ .setCharacteristic(
543
+ Characteristic.Manufacturer,
544
+ deviceConfig.manufacturer || 'Unknown',
545
+ )
546
+ .setCharacteristic(
547
+ Characteristic.Model,
548
+ deviceConfig.model || 'Unknown',
549
+ )
550
+ .setCharacteristic(
551
+ Characteristic.SerialNumber,
552
+ deviceConfig.id.slice(8),
553
+ );
554
+
555
+ isCached = false;
556
+ }
557
+
558
+ if (accessory && accessory.displayName !== deviceConfig.name) {
559
+ this.log.info(
560
+ 'Configuration name %s differs from cached displayName %s. Updating cached displayName to %s ',
561
+ deviceConfig.name,
562
+ accessory.displayName,
563
+ deviceConfig.name,
564
+ );
565
+ accessory.displayName = deviceConfig.name;
566
+ }
567
+
568
+ this.cachedAccessories.set(
569
+ deviceConfig.UUID,
570
+ new Accessory(this, accessory, device, !isCached),
571
+ );
572
+ }
573
+
574
+ removeAccessory(homebridgeAccessory) {
575
+ if (!homebridgeAccessory) return;
576
+
577
+ // Only real PlatformAccessory instances can be unregistered. cachedAccessories
578
+ // also holds accessory wrappers (set by addAccessory), and Homebridge throws a
579
+ // fatal TypeError when handed anything else, taking down the whole child bridge.
580
+ // Guard against it rather than trust every caller.
581
+ if (!(homebridgeAccessory instanceof PlatformAccessory)) {
582
+ return this.log.warn(
583
+ 'Skipped unregistering %s: not a PlatformAccessory.',
584
+ homebridgeAccessory.displayName,
585
+ );
586
+ }
587
+
588
+ this.log.warn('Unregistering', homebridgeAccessory.displayName);
589
+
590
+ this.cachedAccessories.delete(homebridgeAccessory.UUID);
591
+ this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [
592
+ homebridgeAccessory,
593
+ ]);
594
+ }
595
+
596
+ removeAccessoryByUUID(uuid) {
597
+ if (uuid) this.removeAccessory(this.cachedAccessories.get(uuid));
598
+ }
599
+ }