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.
- package/config.schema.json +19 -13
- package/index.js +430 -299
- package/lib/TuyaCloudMessaging.js +12 -30
- package/package.json +2 -4
- package/test/TuyaCloudMessaging.test.js +0 -5
- package/test/index.test.js +0 -6
- package/wiki/Tuya-Cloud-Setup.md +15 -4
package/config.schema.json
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"cloud": {
|
|
23
23
|
"type": "object",
|
|
24
24
|
"title": "Tuya Cloud fallback (optional)",
|
|
25
|
-
"description": "
|
|
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": {
|
|
758
|
-
|
|
759
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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,
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
if (
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
270
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
348
|
-
if (!homebridgeAccessory) return;
|
|
487
|
+
this.log.warn('Unregistering', homebridgeAccessory.displayName);
|
|
349
488
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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});
|
|
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.
|
|
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
|
});
|
package/test/index.test.js
CHANGED
|
@@ -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', () => {
|
package/wiki/Tuya-Cloud-Setup.md
CHANGED
|
@@ -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
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
|