homebridge-tuya-plus 3.14.0-dev.3 → 3.14.0-dev.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish-dev.yml +298 -31
- package/AGENTS.md +120 -0
- package/CLAUDE.md +1 -0
- package/Changelog.md +21 -0
- package/Readme.MD +8 -24
- package/config.schema.json +122 -68
- package/index.js +498 -269
- package/lib/BaseAccessory.js +52 -15
- package/lib/ConvectorAccessory.js +1 -1
- package/lib/CustomMultiOutletAccessory.js +2 -1
- package/lib/IrrigationSystemAccessory.js +98 -95
- package/lib/MultiOutletAccessory.js +2 -1
- package/lib/OilDiffuserAccessory.js +10 -8
- package/lib/RGBTWLightAccessory.js +13 -11
- package/lib/RGBTWOutletAccessory.js +11 -9
- package/lib/SimpleBlindsAccessory.js +5 -5
- package/lib/SimpleDimmer2Accessory.js +1 -1
- package/lib/SimpleDimmerAccessory.js +10 -6
- package/lib/SimpleGarageDoorAccessory.js +78 -24
- package/lib/SimpleHeaterAccessory.js +3 -3
- package/lib/SwitchAccessory.js +2 -1
- package/lib/TWLightAccessory.js +2 -2
- package/lib/TuyaAccessory.js +0 -1
- package/lib/TuyaCloudApi.js +320 -0
- package/lib/TuyaCloudDevice.js +327 -0
- package/lib/TuyaCloudMessaging.js +219 -0
- package/lib/TuyaDevice.js +266 -0
- package/lib/ValveAccessory.js +16 -12
- package/lib/VerticalBlindsWithTilt.js +5 -3
- package/lib/WledDimmerAccessory.js +46 -81
- package/package.json +5 -3
- package/scripts/cleanup-pr-tags.js +122 -0
- package/test/BaseAccessory.test.js +94 -15
- package/test/IrrigationSystemAccessory.test.js +134 -31
- package/test/MultiOutletAccessory.test.js +18 -3
- package/test/RGBTWLightAccessory.test.js +3 -3
- package/test/SimpleFanLightAccessory.test.js +3 -3
- package/test/SimpleGarageDoorAccessory.test.js +63 -9
- package/test/TuyaCloudApi.test.js +221 -0
- package/test/TuyaCloudDevice.test.js +304 -0
- package/test/TuyaCloudMessaging.test.js +113 -0
- package/test/TuyaDevice.test.js +252 -0
- package/test/getCategory.test.js +1 -0
- package/test/index.test.js +183 -0
- package/test/support/mocks.js +13 -0
- package/wiki/Supported-Device-Types.md +60 -28
- package/wiki/Tuya-Cloud-Setup.md +71 -0
package/index.js
CHANGED
|
@@ -1,269 +1,498 @@
|
|
|
1
|
-
const TuyaAccessory = require('./lib/TuyaAccessory');
|
|
2
|
-
const
|
|
3
|
-
|
|
4
|
-
const
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
const
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
if (
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
if (
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
}
|
|
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
|
+
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);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
class TuyaLan {
|
|
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;
|
|
121
|
+
}
|
|
122
|
+
|
|
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
|
|
222
|
+
}
|
|
223
|
+
|
|
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
|
+
);
|
|
282
|
+
}
|
|
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
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
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;
|
|
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);
|
|
398
|
+
}
|
|
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;
|
|
431
|
+
}
|
|
432
|
+
|
|
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;
|
|
455
|
+
}
|
|
456
|
+
|
|
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;
|
|
465
|
+
}
|
|
466
|
+
|
|
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
|
+
);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
this.log.warn('Unregistering', homebridgeAccessory.displayName);
|
|
488
|
+
|
|
489
|
+
this.cachedAccessories.delete(homebridgeAccessory.UUID);
|
|
490
|
+
this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [
|
|
491
|
+
homebridgeAccessory,
|
|
492
|
+
]);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
removeAccessoryByUUID(uuid) {
|
|
496
|
+
if (uuid) this.removeAccessory(this.cachedAccessories.get(uuid));
|
|
497
|
+
}
|
|
498
|
+
}
|