homebridge-tuya-plus 3.13.0 → 3.13.1-dev.2
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/CODEOWNERS +5 -0
- package/.github/workflows/publish-dev.yml +91 -0
- package/Readme.MD +33 -0
- package/config.schema.json +231 -12
- package/index.js +21 -5
- package/lib/BaseAccessory.js +7 -0
- package/lib/DehumidifierAccessory.js +1 -1
- package/lib/IrrigationSystemAccessory.js +611 -0
- package/lib/OilDiffuserAccessory.js +1 -1
- package/lib/SimpleFanLightAccessory.js +54 -12
- package/lib/SimpleGarageDoorAccessory.js +178 -262
- package/lib/TuyaAccessory.js +95 -28
- package/lib/TuyaDiscovery.js +52 -19
- package/lib/WledDimmerAccessory.js +703 -0
- package/package.json +1 -1
- package/test/IrrigationSystemAccessory.test.js +446 -0
- package/test/SimpleFanLightAccessory.test.js +299 -0
- package/test/SimpleGarageDoorAccessory.test.js +258 -538
- package/test/TuyaAccessory.protocol.test.js +626 -0
- package/test/getCategory.test.js +65 -0
- package/test/support/mocks.js +30 -2
- package/wiki/Supported-Device-Types.md +167 -22
- package/wiki/User-documented-device-config.md +57 -0
|
@@ -0,0 +1,703 @@
|
|
|
1
|
+
const BaseAccessory = require('./BaseAccessory');
|
|
2
|
+
const http = require('http');
|
|
3
|
+
const maxWledBrightness = 255;
|
|
4
|
+
|
|
5
|
+
// Debounce and warmup settings for WLED calls
|
|
6
|
+
const wledDebounceMs = 50;
|
|
7
|
+
const wledWarmupMs = 8000;
|
|
8
|
+
|
|
9
|
+
class WledDimmerAccessory extends BaseAccessory {
|
|
10
|
+
static getCategory(Categories) {
|
|
11
|
+
return Categories.LIGHTBULB;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
constructor(...props) {
|
|
15
|
+
super(...props);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
_registerPlatformAccessory() {
|
|
19
|
+
const {Service} = this.hap;
|
|
20
|
+
|
|
21
|
+
this.accessory.addService(Service.Lightbulb, this.device.context.name);
|
|
22
|
+
|
|
23
|
+
super._registerPlatformAccessory();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
_registerCharacteristics(dps) {
|
|
27
|
+
const {Service, Characteristic} = this.hap;
|
|
28
|
+
const service = this.accessory.getService(Service.Lightbulb);
|
|
29
|
+
this._checkServiceName(service, this.device.context.name);
|
|
30
|
+
|
|
31
|
+
this.dpPower = this._getCustomDP(this.device.context.dpPower) || '1';
|
|
32
|
+
this.dpBrightness = this._getCustomDP(this.device.context.dpBrightness) || this._getCustomDP(this.device.context.dp) || '2';
|
|
33
|
+
|
|
34
|
+
// State for debounced / delayed WLED updates
|
|
35
|
+
this._wledPendingBri = null;
|
|
36
|
+
this._wledDebounceTimer = null;
|
|
37
|
+
this._wledReadyAt = 0;
|
|
38
|
+
|
|
39
|
+
// State for WLED preset effect switches
|
|
40
|
+
this._wledEffectSwitches = [];
|
|
41
|
+
this._wledCurrentEffectIndex = null;
|
|
42
|
+
|
|
43
|
+
// Allow a couple of different spellings just in case
|
|
44
|
+
const syncCfg =
|
|
45
|
+
this.device.context.syncBrightnessToWled ||
|
|
46
|
+
this.device.context.syncBrightnessToWLED ||
|
|
47
|
+
null;
|
|
48
|
+
this.syncBrightnessToWled = (syncCfg && ('' + syncCfg).trim()) || null;
|
|
49
|
+
|
|
50
|
+
this.log.info(
|
|
51
|
+
'[WLED Sync] %s: syncBrightnessToWled=%s',
|
|
52
|
+
this.device.context.name,
|
|
53
|
+
this.syncBrightnessToWled || 'disabled'
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const characteristicOn = service.getCharacteristic(Characteristic.On)
|
|
57
|
+
.updateValue(dps[this.dpPower])
|
|
58
|
+
.onGet(() => this.getStateAsync(this.dpPower))
|
|
59
|
+
.onSet(value => this.setStateAsync(this.dpPower, value));
|
|
60
|
+
|
|
61
|
+
const characteristicBrightness = service.getCharacteristic(Characteristic.Brightness);
|
|
62
|
+
// Keep a reference so we can update brightness from background WLED calls
|
|
63
|
+
this._characteristicBrightness = characteristicBrightness;
|
|
64
|
+
|
|
65
|
+
if (this.syncBrightnessToWled) {
|
|
66
|
+
// Start with 100% locally while we fetch the actual WLED brightness
|
|
67
|
+
characteristicBrightness
|
|
68
|
+
.updateValue(100)
|
|
69
|
+
.onGet(() => this.getBrightness())
|
|
70
|
+
.onSet(value => this.setBrightness(value));
|
|
71
|
+
|
|
72
|
+
// Force Tuya dimmer brightness to 100% on startup
|
|
73
|
+
const maxTuyaBrightness = this.convertBrightnessFromHomeKitToTuya(100);
|
|
74
|
+
if (dps[this.dpBrightness] !== maxTuyaBrightness) {
|
|
75
|
+
this.log.info(
|
|
76
|
+
'[WLED Sync] %s: setting Tuya brightness DP%s to max (%s)',
|
|
77
|
+
this.device.context.name,
|
|
78
|
+
this.dpBrightness,
|
|
79
|
+
maxTuyaBrightness
|
|
80
|
+
);
|
|
81
|
+
this.setState(this.dpBrightness, maxTuyaBrightness, () => {});
|
|
82
|
+
}
|
|
83
|
+
} else {
|
|
84
|
+
const initial = this.convertBrightnessFromTuyaToHomeKit(dps[this.dpBrightness]);
|
|
85
|
+
this.log.debug(
|
|
86
|
+
'%s: initial Tuya brightness DP%s=%s -> HomeKit %s%%',
|
|
87
|
+
this.device.context.name,
|
|
88
|
+
this.dpBrightness,
|
|
89
|
+
dps[this.dpBrightness],
|
|
90
|
+
initial
|
|
91
|
+
);
|
|
92
|
+
characteristicBrightness
|
|
93
|
+
.updateValue(initial)
|
|
94
|
+
.onGet(() => this.getBrightness())
|
|
95
|
+
.onSet(value => this.setBrightness(value));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Optional: create one HomeKit Switch per configured WLED preset effect.
|
|
99
|
+
// Each switch, when turned ON, will push the corresponding effect ID to WLED.
|
|
100
|
+
const presetEffects = this.device.context.presetEffects;
|
|
101
|
+
if (Array.isArray(presetEffects) && presetEffects.length) {
|
|
102
|
+
const {Service: HapService, Characteristic: HapCharacteristic} = this.hap;
|
|
103
|
+
|
|
104
|
+
const validEffectServices = [];
|
|
105
|
+
this._wledEffectSwitches = [];
|
|
106
|
+
|
|
107
|
+
presetEffects.forEach((effectCfg, index) => {
|
|
108
|
+
if (!effectCfg) return;
|
|
109
|
+
|
|
110
|
+
const effectId = effectCfg.fx != null ? effectCfg.fx : effectCfg.id;
|
|
111
|
+
if (!isFinite(effectId)) {
|
|
112
|
+
this.log.warn(
|
|
113
|
+
'[WLED Sync] %s: presetEffects[%s] is missing a numeric id/fx, skipping: %s',
|
|
114
|
+
this.device.context.name,
|
|
115
|
+
index,
|
|
116
|
+
JSON.stringify(effectCfg)
|
|
117
|
+
);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const effectName = (effectCfg.name && String(effectCfg.name).trim()) || `Effect ${index + 1}`;
|
|
122
|
+
const staticColor = effectCfg.staticColor && String(effectCfg.staticColor).trim();
|
|
123
|
+
const displayName = effectName;
|
|
124
|
+
const subtype = `wledEffect ${index + 1}`;
|
|
125
|
+
|
|
126
|
+
let effectService = this.accessory.getServiceById(HapService.Switch, subtype);
|
|
127
|
+
if (effectService) {
|
|
128
|
+
this._checkServiceName(effectService, displayName);
|
|
129
|
+
} else {
|
|
130
|
+
effectService = this.accessory.addService(HapService.Switch, displayName, subtype);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
validEffectServices.push(effectService);
|
|
134
|
+
|
|
135
|
+
const onCharacteristic = effectService
|
|
136
|
+
.getCharacteristic(HapCharacteristic.On)
|
|
137
|
+
.on('set', (value, callback) => {
|
|
138
|
+
// Only react to turning the switch ON; turning OFF just clears the toggle in HomeKit.
|
|
139
|
+
if (!value) {
|
|
140
|
+
return callback();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// If the light is off, ignore effect change and turn this switch back off.
|
|
144
|
+
if (!this.device.state[this.dpPower]) {
|
|
145
|
+
setImmediate(() => {
|
|
146
|
+
effectService.getCharacteristic(HapCharacteristic.On).updateValue(false);
|
|
147
|
+
});
|
|
148
|
+
return callback();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
this.log.info(
|
|
152
|
+
'[WLED Sync] %s: setting preset effect "%s" (id=%s, index=%s)',
|
|
153
|
+
this.device.context.name,
|
|
154
|
+
effectName,
|
|
155
|
+
effectId,
|
|
156
|
+
index + 1
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const rgb = this._parseStaticColor(staticColor);
|
|
160
|
+
|
|
161
|
+
if (!this.device.state[this.dpPower]) {
|
|
162
|
+
this.log.error(
|
|
163
|
+
'[WLED Sync] %s: lamp is off',
|
|
164
|
+
this.device.context.name
|
|
165
|
+
);
|
|
166
|
+
setImmediate(() => {
|
|
167
|
+
effectService.getCharacteristic(HapCharacteristic.On).updateValue(false);
|
|
168
|
+
});
|
|
169
|
+
return callback();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
this._setWledEffect(effectId, rgb, err => {
|
|
173
|
+
if (err) {
|
|
174
|
+
this.log.error(
|
|
175
|
+
'[WLED Sync] %s: failed to set preset effect "%s" (id=%s): %s',
|
|
176
|
+
this.device.context.name,
|
|
177
|
+
effectName,
|
|
178
|
+
effectId,
|
|
179
|
+
err
|
|
180
|
+
);
|
|
181
|
+
setImmediate(() => {
|
|
182
|
+
effectService.getCharacteristic(HapCharacteristic.On).updateValue(false);
|
|
183
|
+
});
|
|
184
|
+
return callback();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Remember active effect locally and turn off other switches for a radio-button-style UX.
|
|
188
|
+
this._wledCurrentEffectIndex = (index + 1);
|
|
189
|
+
this._wledEffectSwitches.forEach(sw => {
|
|
190
|
+
if (sw.effectName !== effectName && sw.characteristic.value) {
|
|
191
|
+
sw.characteristic.updateValue(false);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
callback();
|
|
196
|
+
});
|
|
197
|
+
})
|
|
198
|
+
.on('get', cb => {
|
|
199
|
+
cb(null, this.device.state[this.dpPower] && this._wledCurrentEffectIndex === (index + 1));
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
this._wledEffectSwitches.push({
|
|
203
|
+
effectId,
|
|
204
|
+
name: effectName,
|
|
205
|
+
characteristic: onCharacteristic
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// Clean up any stale WLED preset effect services that are no longer in config.
|
|
210
|
+
this.accessory.services
|
|
211
|
+
.filter(service => service.UUID === HapService.Switch.UUID && service.subtype && service.subtype.startsWith('wledEffect '))
|
|
212
|
+
.forEach(service => {
|
|
213
|
+
if (!validEffectServices.includes(service)) {
|
|
214
|
+
this.log.info('Removing', service.displayName);
|
|
215
|
+
this.accessory.removeService(service);
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
this.device.on('change', changes => {
|
|
221
|
+
// Log full DPS changes so we can see exactly what Tuya reported
|
|
222
|
+
this.log.info(
|
|
223
|
+
'%s DPS changes: %s %s was:',
|
|
224
|
+
this.device.context.name,
|
|
225
|
+
JSON.stringify(changes),
|
|
226
|
+
'dpPower=' + this.dpPower,
|
|
227
|
+
this.device.state[this.dpPower]
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
if (changes.hasOwnProperty(this.dpPower)) {
|
|
231
|
+
const oldPower = !!characteristicOn.value;
|
|
232
|
+
const newPower = !!changes[this.dpPower];
|
|
233
|
+
|
|
234
|
+
this.log.info(
|
|
235
|
+
'%s power change detected on DP%s: %s -> %s',
|
|
236
|
+
this.device.context.name,
|
|
237
|
+
this.dpPower,
|
|
238
|
+
oldPower,
|
|
239
|
+
newPower
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
if (oldPower !== newPower) {
|
|
243
|
+
characteristicOn.updateValue(newPower);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Track warmup window for WLED after power ON
|
|
247
|
+
if (this.syncBrightnessToWled && newPower) {
|
|
248
|
+
this._wledReadyAt = Date.now() + wledWarmupMs;
|
|
249
|
+
this.log.info(
|
|
250
|
+
'[WLED Sync] %s: power ON -> delaying WLED API calls for %sms',
|
|
251
|
+
this.device.context.name,
|
|
252
|
+
wledWarmupMs
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Cancel any pending brightness updates when turning OFF
|
|
257
|
+
if (this.syncBrightnessToWled && !newPower) {
|
|
258
|
+
if (this._wledDebounceTimer) {
|
|
259
|
+
clearTimeout(this._wledDebounceTimer);
|
|
260
|
+
this._wledDebounceTimer = null;
|
|
261
|
+
}
|
|
262
|
+
this._wledPendingBri = null;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// When WLED sync is enabled, also mirror manual Tuya (Smart Life) brightness changes to WLED
|
|
267
|
+
if (this.syncBrightnessToWled && changes.hasOwnProperty(this.dpBrightness)) {
|
|
268
|
+
const tuyaValue = changes[this.dpBrightness];
|
|
269
|
+
const maxTuyaBrightness = this.convertBrightnessFromHomeKitToTuya(100);
|
|
270
|
+
|
|
271
|
+
// Ignore the "forced back to 100%" update to avoid feedback loops
|
|
272
|
+
if (tuyaValue === maxTuyaBrightness) {
|
|
273
|
+
this.log.info(
|
|
274
|
+
'[WLED Sync] %s: Tuya DP%s reported max brightness (%s), ignoring',
|
|
275
|
+
this.device.context.name,
|
|
276
|
+
this.dpBrightness,
|
|
277
|
+
tuyaValue
|
|
278
|
+
);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const percent = this.convertBrightnessFromTuyaToHomeKit(tuyaValue);
|
|
283
|
+
const bri = Math.max(0, Math.min(maxWledBrightness, Math.round((percent / 100) * maxWledBrightness)));
|
|
284
|
+
|
|
285
|
+
this.log.info(
|
|
286
|
+
'[WLED Sync] %s: Tuya DP%s changed to %s -> %s%% -> WLED bri=%s (debounced)',
|
|
287
|
+
this.device.context.name,
|
|
288
|
+
this.dpBrightness,
|
|
289
|
+
tuyaValue,
|
|
290
|
+
percent,
|
|
291
|
+
bri
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
// Queue a debounced WLED update to match the Tuya app change
|
|
295
|
+
this._scheduleWledBrightness(bri, 'Tuya brightness change', err => {
|
|
296
|
+
if (err) {
|
|
297
|
+
this.log.error(
|
|
298
|
+
'[WLED Sync] %s: failed to push Tuya-origin brightness to WLED (err=%s)',
|
|
299
|
+
this.device.context.name,
|
|
300
|
+
err
|
|
301
|
+
);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Reflect that brightness back into HomeKit
|
|
306
|
+
characteristicBrightness.updateValue(percent);
|
|
307
|
+
|
|
308
|
+
// After a short delay, force Tuya brightness back to 100% so it stops dimming the strip
|
|
309
|
+
if (this._wledForceMaxTimeout) {
|
|
310
|
+
clearTimeout(this._wledForceMaxTimeout);
|
|
311
|
+
}
|
|
312
|
+
this._wledForceMaxTimeout = setTimeout(() => {
|
|
313
|
+
this.log.info(
|
|
314
|
+
'[WLED Sync] %s: forcing Tuya DP%s back to max (%s) after Tuya app change',
|
|
315
|
+
this.device.context.name,
|
|
316
|
+
this.dpBrightness,
|
|
317
|
+
maxTuyaBrightness
|
|
318
|
+
);
|
|
319
|
+
this.setState(this.dpBrightness, maxTuyaBrightness, () => {});
|
|
320
|
+
}, 5000);
|
|
321
|
+
});
|
|
322
|
+
} else if (!this.syncBrightnessToWled && changes.hasOwnProperty(this.dpBrightness) && this.convertBrightnessFromHomeKitToTuya(characteristicBrightness.value) !== changes[this.dpBrightness]) {
|
|
323
|
+
characteristicBrightness.updateValue(this.convertBrightnessFromTuyaToHomeKit(changes[this.dpBrightness]));
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
getBrightness() {
|
|
329
|
+
if (this.syncBrightnessToWled) {
|
|
330
|
+
// If we already have a cached WLED brightness, return it immediately.
|
|
331
|
+
if (this._lastWledPercent != null) {
|
|
332
|
+
this.log.info(
|
|
333
|
+
'[WLED Sync] %s: getBrightness() -> using cached %s%%',
|
|
334
|
+
this.device.context.name,
|
|
335
|
+
this._lastWledPercent
|
|
336
|
+
);
|
|
337
|
+
return this._lastWledPercent;
|
|
338
|
+
}
|
|
339
|
+
return 50;
|
|
340
|
+
} else {
|
|
341
|
+
return this.convertBrightnessFromTuyaToHomeKit(this.device.state[this.dpBrightness]);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
setBrightness(value) {
|
|
346
|
+
if (this.syncBrightnessToWled) {
|
|
347
|
+
this.log.info(
|
|
348
|
+
'[WLED Sync] %s: setBrightness(%s%%)',
|
|
349
|
+
this.device.context.name,
|
|
350
|
+
value
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
// Remember the target level immediately so HomeKit/UI stays in sync.
|
|
354
|
+
this._lastWledPercent = value;
|
|
355
|
+
|
|
356
|
+
// Ensure Tuya stays at 100% so it doesn't interfere with WLED.
|
|
357
|
+
const maxTuyaBrightness = this.convertBrightnessFromHomeKitToTuya(100);
|
|
358
|
+
if (this.device.state[this.dpBrightness] !== maxTuyaBrightness) {
|
|
359
|
+
this.log.info(
|
|
360
|
+
'[WLED Sync] %s: ensuring Tuya DP%s=%s (max)',
|
|
361
|
+
this.device.context.name,
|
|
362
|
+
this.dpBrightness,
|
|
363
|
+
maxTuyaBrightness
|
|
364
|
+
);
|
|
365
|
+
// Fire-and-forget; don't tie HomeKit callback to Tuya I/O.
|
|
366
|
+
this.setStateAsync(this.dpBrightness, maxTuyaBrightness);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Compute desired WLED brightness once.
|
|
370
|
+
const bri = Math.max(0, Math.min(maxWledBrightness, Math.round((value / 100) * maxWledBrightness)));
|
|
371
|
+
this.log.info(
|
|
372
|
+
'[WLED Sync] %s: mapped HomeKit %s%% -> WLED bri=%s (debounced background)',
|
|
373
|
+
this.device.context.name,
|
|
374
|
+
value,
|
|
375
|
+
bri
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
// Schedule the WLED update in the background with debounce and warmup handling.
|
|
379
|
+
this._scheduleWledBrightness(bri, 'HomeKit setBrightness');
|
|
380
|
+
|
|
381
|
+
// Return to HomeKit immediately; don't wait for network I/O.
|
|
382
|
+
return null;
|
|
383
|
+
} else {
|
|
384
|
+
return this.setStateAsync(this.dpBrightness, this.convertBrightnessFromHomeKitToTuya(value));
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
_getWledTarget() {
|
|
389
|
+
if (!this.syncBrightnessToWled) return null;
|
|
390
|
+
const parts = String(this.syncBrightnessToWled).split(':');
|
|
391
|
+
const host = parts[0];
|
|
392
|
+
const port = parts[1] ? parseInt(parts[1], 10) || 80 : 80;
|
|
393
|
+
this.log.info(
|
|
394
|
+
'[WLED Sync] %s: target host=%s port=%s',
|
|
395
|
+
this.device.context.name,
|
|
396
|
+
host,
|
|
397
|
+
port
|
|
398
|
+
);
|
|
399
|
+
return {host, port};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
_getWledBrightness(callback) {
|
|
403
|
+
const target = this._getWledTarget();
|
|
404
|
+
if (!target) {
|
|
405
|
+
this.log.error(
|
|
406
|
+
'[WLED Sync] %s: _getWledBrightness but no target configured',
|
|
407
|
+
this.device.context.name
|
|
408
|
+
);
|
|
409
|
+
return callback(true);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Ensure we never call the provided callback more than once
|
|
413
|
+
const done = (err, bri) => {
|
|
414
|
+
if (done.called) return;
|
|
415
|
+
done.called = true;
|
|
416
|
+
callback(err, bri);
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
const options = {
|
|
420
|
+
host: target.host,
|
|
421
|
+
port: target.port,
|
|
422
|
+
path: '/json/state',
|
|
423
|
+
method: 'GET',
|
|
424
|
+
timeout: 1000
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
this.log.info(
|
|
428
|
+
'[WLED Sync] %s: GET http://%s:%s/json/state',
|
|
429
|
+
this.device.context.name,
|
|
430
|
+
target.host,
|
|
431
|
+
target.port
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
const req = http.request(options, res => {
|
|
435
|
+
let data = '';
|
|
436
|
+
res.on('data', chunk => data += chunk);
|
|
437
|
+
res.on('end', () => {
|
|
438
|
+
try {
|
|
439
|
+
const json = JSON.parse(data || '{}');
|
|
440
|
+
// WLED exposes brightness as "bri" in the root of the state response
|
|
441
|
+
const bri = json.bri != null ? json.bri : (json.state && json.state.bri);
|
|
442
|
+
if (!isFinite(bri)) {
|
|
443
|
+
this.log.error(
|
|
444
|
+
'[WLED Sync] %s: /json/state response has no numeric bri: %s',
|
|
445
|
+
this.device.context.name,
|
|
446
|
+
data
|
|
447
|
+
);
|
|
448
|
+
return done(true);
|
|
449
|
+
}
|
|
450
|
+
this.log.info(
|
|
451
|
+
'[WLED Sync] %s: /json/state -> bri=%s',
|
|
452
|
+
this.device.context.name,
|
|
453
|
+
bri
|
|
454
|
+
);
|
|
455
|
+
done(null, bri);
|
|
456
|
+
} catch (e) {
|
|
457
|
+
this.log.error('Failed to parse WLED state from %s: %s', this.syncBrightnessToWled, e.message);
|
|
458
|
+
done(true);
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
req.on('error', err => {
|
|
464
|
+
this.log.error('Error talking to WLED at %s: %s', this.syncBrightnessToWled, err.message);
|
|
465
|
+
done(true);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
req.on('timeout', () => {
|
|
469
|
+
req.destroy();
|
|
470
|
+
done(true);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
req.end();
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
_setWledBrightness(brightness, callback) {
|
|
477
|
+
const target = this._getWledTarget();
|
|
478
|
+
if (!target) {
|
|
479
|
+
this.log.error(
|
|
480
|
+
'[WLED Sync] %s: _setWledBrightness but no target configured',
|
|
481
|
+
this.device.context.name
|
|
482
|
+
);
|
|
483
|
+
if (callback) callback(true);
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Ensure we never call the provided callback more than once
|
|
488
|
+
const done = (err) => {
|
|
489
|
+
if (!callback) return;
|
|
490
|
+
if (done.called) return;
|
|
491
|
+
done.called = true;
|
|
492
|
+
callback(err);
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
const body = JSON.stringify({bri: brightness});
|
|
496
|
+
|
|
497
|
+
const options = {
|
|
498
|
+
host: target.host,
|
|
499
|
+
port: target.port,
|
|
500
|
+
path: '/json/state',
|
|
501
|
+
method: 'POST',
|
|
502
|
+
headers: {
|
|
503
|
+
'Content-Type': 'application/json',
|
|
504
|
+
'Content-Length': Buffer.byteLength(body)
|
|
505
|
+
},
|
|
506
|
+
timeout: 1000
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
this.log.info(
|
|
510
|
+
'[WLED Sync] %s: POST http://%s:%s/json/state body=%s',
|
|
511
|
+
this.device.context.name,
|
|
512
|
+
target.host,
|
|
513
|
+
target.port,
|
|
514
|
+
body
|
|
515
|
+
);
|
|
516
|
+
|
|
517
|
+
const req = http.request(options, res => {
|
|
518
|
+
// Consume response and ignore body
|
|
519
|
+
res.on('data', () => {});
|
|
520
|
+
res.on('end', () => {
|
|
521
|
+
this.log.info(
|
|
522
|
+
'[WLED Sync] %s: WLED brightness set, HTTP %s',
|
|
523
|
+
this.device.context.name,
|
|
524
|
+
res.statusCode
|
|
525
|
+
);
|
|
526
|
+
done && done();
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
req.on('error', err => {
|
|
531
|
+
this.log.error('Error setting WLED brightness on %s: %s', this.syncBrightnessToWled, err.message);
|
|
532
|
+
done && done(true);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
req.on('timeout', () => {
|
|
536
|
+
req.destroy();
|
|
537
|
+
done && done(true);
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
req.write(body);
|
|
541
|
+
req.end();
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
_setWledEffect(effectId, rgbColor, callback) {
|
|
545
|
+
const target = this._getWledTarget();
|
|
546
|
+
if (!target) {
|
|
547
|
+
this.log.error(
|
|
548
|
+
'[WLED Sync] %s: _setWledEffect but no target configured',
|
|
549
|
+
this.device.context.name
|
|
550
|
+
);
|
|
551
|
+
if (callback) callback(true);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Ensure we never call the provided callback more than once
|
|
556
|
+
const done = (err) => {
|
|
557
|
+
if (!callback) return;
|
|
558
|
+
if (done.called) return;
|
|
559
|
+
done.called = true;
|
|
560
|
+
callback(err);
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
// Build WLED JSON: always set effect (fx), and if a valid staticColor
|
|
564
|
+
// is provided, also override the first segment color.
|
|
565
|
+
const seg = {fx: effectId};
|
|
566
|
+
if (Array.isArray(rgbColor) && rgbColor.length === 3 && rgbColor.every(c => Number.isInteger(c))) {
|
|
567
|
+
seg.col = [rgbColor];
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const body = JSON.stringify({seg: [seg]});
|
|
571
|
+
|
|
572
|
+
const options = {
|
|
573
|
+
host: target.host,
|
|
574
|
+
port: target.port,
|
|
575
|
+
path: '/json/state',
|
|
576
|
+
method: 'POST',
|
|
577
|
+
headers: {
|
|
578
|
+
'Content-Type': 'application/json',
|
|
579
|
+
'Content-Length': Buffer.byteLength(body)
|
|
580
|
+
},
|
|
581
|
+
timeout: 1000
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
this.log.info(
|
|
585
|
+
'[WLED Sync] %s: POST http://%s:%s/json/state body=%s (preset effect)',
|
|
586
|
+
this.device.context.name,
|
|
587
|
+
target.host,
|
|
588
|
+
target.port,
|
|
589
|
+
body
|
|
590
|
+
);
|
|
591
|
+
|
|
592
|
+
const req = http.request(options, res => {
|
|
593
|
+
// Consume response and ignore body
|
|
594
|
+
res.on('data', () => {});
|
|
595
|
+
res.on('end', () => {
|
|
596
|
+
this.log.info(
|
|
597
|
+
'[WLED Sync] %s: WLED effect set to %s, HTTP %s',
|
|
598
|
+
this.device.context.name,
|
|
599
|
+
effectId,
|
|
600
|
+
res.statusCode
|
|
601
|
+
);
|
|
602
|
+
done && done();
|
|
603
|
+
});
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
req.on('error', err => {
|
|
607
|
+
this.log.error('Error setting WLED effect on %s: %s', this.syncBrightnessToWled, err.message);
|
|
608
|
+
done && done(true);
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
req.on('timeout', () => {
|
|
612
|
+
req.destroy();
|
|
613
|
+
done && done(true);
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
req.write(body);
|
|
617
|
+
req.end();
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
_parseStaticColor(color) {
|
|
621
|
+
if (!color) return null;
|
|
622
|
+
|
|
623
|
+
let hex = String(color).trim().toLowerCase();
|
|
624
|
+
if (hex[0] === '#') hex = hex.slice(1);
|
|
625
|
+
|
|
626
|
+
if (hex.length !== 6 || !/^[0-9a-f]{6}$/.test(hex)) {
|
|
627
|
+
this.log.warn(
|
|
628
|
+
'[WLED Sync] %s: staticColor "%s" is not a valid 6-digit hex string, ignoring',
|
|
629
|
+
this.device.context.name,
|
|
630
|
+
color
|
|
631
|
+
);
|
|
632
|
+
return null;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const r = parseInt(hex.slice(0, 2), 16);
|
|
636
|
+
const g = parseInt(hex.slice(2, 4), 16);
|
|
637
|
+
const b = parseInt(hex.slice(4, 6), 16);
|
|
638
|
+
|
|
639
|
+
return [r, g, b];
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
_scheduleWledBrightness(brightness, reason, callback) {
|
|
643
|
+
if (!this.syncBrightnessToWled) {
|
|
644
|
+
callback && callback(true);
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Remember the last requested brightness; multiple calls within the debounce window collapse into one.
|
|
649
|
+
this._wledPendingBri = brightness;
|
|
650
|
+
|
|
651
|
+
if (this._wledDebounceTimer) {
|
|
652
|
+
clearTimeout(this._wledDebounceTimer);
|
|
653
|
+
this._wledDebounceTimer = null;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const now = Date.now();
|
|
657
|
+
const warmupDelay = this._wledReadyAt && now < this._wledReadyAt ? (this._wledReadyAt - now) : 0;
|
|
658
|
+
const delay = warmupDelay + wledDebounceMs;
|
|
659
|
+
|
|
660
|
+
this.log.info(
|
|
661
|
+
'[WLED Sync] %s: scheduling WLED bri=%s in %sms (%s)',
|
|
662
|
+
this.device.context.name,
|
|
663
|
+
brightness,
|
|
664
|
+
delay,
|
|
665
|
+
reason || 'unspecified'
|
|
666
|
+
);
|
|
667
|
+
|
|
668
|
+
this._wledDebounceTimer = setTimeout(() => {
|
|
669
|
+
this._wledDebounceTimer = null;
|
|
670
|
+
|
|
671
|
+
// If the light is now off, skip the call.
|
|
672
|
+
if (!this.device.state[this.dpPower]) {
|
|
673
|
+
this.log.info(
|
|
674
|
+
'[WLED Sync] %s: skipping scheduled WLED bri=%s because power is OFF',
|
|
675
|
+
this.device.context.name,
|
|
676
|
+
this._wledPendingBri
|
|
677
|
+
);
|
|
678
|
+
this._wledPendingBri = null;
|
|
679
|
+
callback && callback(true);
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const briToSend = this._wledPendingBri;
|
|
684
|
+
this._wledPendingBri = null;
|
|
685
|
+
|
|
686
|
+
setImmediate(() => {
|
|
687
|
+
this._setWledBrightness(briToSend, err => {
|
|
688
|
+
if (err) {
|
|
689
|
+
this.log.error(
|
|
690
|
+
'[WLED Sync] %s: scheduled WLED bri=%s failed: %s',
|
|
691
|
+
this.device.context.name,
|
|
692
|
+
briToSend,
|
|
693
|
+
err
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
callback && callback(err);
|
|
697
|
+
});
|
|
698
|
+
});
|
|
699
|
+
}, delay);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
module.exports = WledDimmerAccessory;
|