iobroker.tint 0.3.0

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 (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +201 -0
  3. package/admin/build/assets/__virtual_mf___mfe_internal__tintComponents__loadShare__react__loadShare__.js_commonjs-proxy-Cl6Kn7gP.js +9 -0
  4. package/admin/build/assets/_virtual_mf-localSharedImportMap___mfe_internal__tintComponents-B1A16Tgp.js +1 -0
  5. package/admin/build/assets/_virtual_mf___mfe_internal__tintComponents__loadShare___mf_0_emotion_mf_1_react__loadShare__.js-C8Vyx7Bj.js +8 -0
  6. package/admin/build/assets/_virtual_mf___mfe_internal__tintComponents__loadShare___mf_0_emotion_mf_1_styled__loadShare__.js-ByxO1Xun.js +1 -0
  7. package/admin/build/assets/_virtual_mf___mfe_internal__tintComponents__loadShare___mf_0_mui_mf_1_material__loadShare__.js-Dg1UrPxy.js +248 -0
  8. package/admin/build/assets/_virtual_mf___mfe_internal__tintComponents__loadShare__react__loadShare__.js-CAwea2Mm.js +1 -0
  9. package/admin/build/assets/_virtual_mf___mfe_internal__tintComponents__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__.js-B7t36uFG.js +9 -0
  10. package/admin/build/assets/_virtual_mf___mfe_internal__tintComponents__loadShare__react_mf_2_dom__loadShare__.js-rV2HHyiS.js +24 -0
  11. package/admin/build/assets/bootstrap-DdKMNh18.js +1 -0
  12. package/admin/build/assets/hostInit-C5jswnkw.js +1 -0
  13. package/admin/build/assets/index-C-tjmgJM.js +1 -0
  14. package/admin/build/assets/preload-helper-BlTxHScW.js +1 -0
  15. package/admin/build/assets/virtualExposes-Bu4cv-kd.js +1 -0
  16. package/admin/build/customComponents.js +7 -0
  17. package/admin/build/customComponents.ssr.js +48 -0
  18. package/admin/i18n/de.json +35 -0
  19. package/admin/i18n/en.json +35 -0
  20. package/admin/i18n/es.json +35 -0
  21. package/admin/i18n/fr.json +35 -0
  22. package/admin/i18n/it.json +35 -0
  23. package/admin/i18n/nl.json +35 -0
  24. package/admin/i18n/pl.json +35 -0
  25. package/admin/i18n/pt.json +35 -0
  26. package/admin/i18n/ru.json +35 -0
  27. package/admin/i18n/uk.json +35 -0
  28. package/admin/i18n/zh-cn.json +35 -0
  29. package/admin/jsonConfig.json +329 -0
  30. package/admin/tint.png +0 -0
  31. package/io-package.json +227 -0
  32. package/lib/adapter-config.d.ts +20 -0
  33. package/lib/admin-projections.js +61 -0
  34. package/lib/color-utils.js +230 -0
  35. package/lib/deconz-api.js +379 -0
  36. package/lib/deconz-ws.js +151 -0
  37. package/lib/device-category.js +42 -0
  38. package/lib/objects.js +1002 -0
  39. package/lib/remote-handler.js +218 -0
  40. package/main.js +1924 -0
  41. package/package.json +84 -0
package/main.js ADDED
@@ -0,0 +1,1924 @@
1
+ 'use strict';
2
+
3
+ /*
4
+ * Created with @iobroker/create-adapter v3.1.5
5
+ * Extended with deCONZ/ConBee integration for Müller Licht tint
6
+ */
7
+
8
+ const utils = require('@iobroker/adapter-core');
9
+ const DeconzApi = require('./lib/deconz-api');
10
+ const DeconzWebSocket = require('./lib/deconz-ws');
11
+ const RemoteHandler = require('./lib/remote-handler');
12
+ const { briToPercent, percentToBri, miredToKelvin, kelvinToMired, xyToHex, hexToXy } = require('./lib/color-utils');
13
+ const { isPlug, isCover, isTintRemote } = require('./lib/device-category');
14
+ const { trimLightForAdmin, trimGroupForAdmin } = require('./lib/admin-projections');
15
+ const {
16
+ lightDevice,
17
+ lightInfoChannel,
18
+ lightStateChannel,
19
+ LIGHT_STATES,
20
+ plugDevice,
21
+ plugInfoChannel,
22
+ plugStateChannel,
23
+ PLUG_STATES,
24
+ groupDevice,
25
+ groupInfoChannel,
26
+ groupActionChannel,
27
+ groupScenesChannel,
28
+ GROUP_INFO_STATES,
29
+ GROUP_ACTION_STATES,
30
+ remoteDevice,
31
+ remoteChannel,
32
+ REMOTE_INFO_STATES,
33
+ REMOTE_BUTTON_STATES,
34
+ REMOTE_COLORWHEEL_STATES,
35
+ REMOTE_COLORTEMP_STATES,
36
+ switchDevice,
37
+ switchChannel,
38
+ SWITCH_INFO_STATES,
39
+ SWITCH_BUTTON_STATES,
40
+ sensorDevice,
41
+ sensorChannel,
42
+ SENSOR_INFO_STATES,
43
+ SENSOR_VALUE_STATES,
44
+ SENSOR_GENERIC_VALUE_STATE,
45
+ coverDevice,
46
+ coverInfoChannel,
47
+ coverStateChannel,
48
+ COVER_STATES,
49
+ thermostatDevice,
50
+ thermostatInfoChannel,
51
+ thermostatStateChannel,
52
+ THERMOSTAT_STATES,
53
+ buildStateObj,
54
+ } = require('./lib/objects');
55
+
56
+ class Tint extends utils.Adapter {
57
+ /**
58
+ * @param {Partial<utils.AdapterOptions>} [options] - Adapter options
59
+ */
60
+ constructor(options) {
61
+ super({
62
+ ...options,
63
+ name: 'tint',
64
+ });
65
+
66
+ this._api = null;
67
+ this._ws = null;
68
+ this._remote = null;
69
+ this._pollTimer = null;
70
+ this._rediscoverTimer = null;
71
+ this._stopped = false;
72
+
73
+ this._lightMap = {};
74
+ this._plugMap = {};
75
+ this._coverMap = {};
76
+ this._groupMap = {};
77
+ this._remoteMap = {};
78
+ this._switchMap = {};
79
+ this._sensorMap = {};
80
+ this._thermostatMap = {};
81
+ this._sceneMap = {};
82
+
83
+ this.on('ready', this.onReady.bind(this));
84
+ this.on('stateChange', this.onStateChange.bind(this));
85
+ this.on('message', this.onMessage.bind(this));
86
+ this.on('unload', this.onUnload.bind(this));
87
+ }
88
+
89
+ // ─────────────────────────────────────────────────────────────────────────
90
+ // Lifecycle
91
+ // ─────────────────────────────────────────────────────────────────────────
92
+
93
+ /**
94
+ * Is called when databases are connected and adapter received configuration.
95
+ */
96
+ async onReady() {
97
+ this.setState('info.connection', false, true);
98
+
99
+ const { ip, port, wsPort, apiKey, pollingInterval, watchdogMinutes, autoApplyColorWheel, transitionTime } =
100
+ this.config;
101
+
102
+ this.log.info(
103
+ `Adapter starting — ` +
104
+ `deCONZ REST: ${ip}:${port || 80}, ` +
105
+ `WebSocket: ${ip}:${wsPort || 443}, ` +
106
+ `poll: ${pollingInterval || 60}s, ` +
107
+ `watchdog: ${watchdogMinutes || 120}min, ` +
108
+ `transitionTime: ${transitionTime ?? 4}×100ms, ` +
109
+ `autoColorWheel: ${autoApplyColorWheel}`,
110
+ );
111
+ this.log.debug(`API key configured: ${apiKey ? `yes (${apiKey.length} chars)` : 'NOT SET'}`);
112
+
113
+ if (!ip || !apiKey) {
114
+ this.log.warn(
115
+ 'Adapter not fully configured — IP address or API key is missing. ' +
116
+ 'Use the "deCONZ Pairing" button in the adapter settings to obtain the API key.',
117
+ );
118
+ // Subscribe to a dummy pattern so adapter-core keeps the event loop alive
119
+ // and onMessage() remains reachable for the pairing sendTo command.
120
+ await this.subscribeStatesAsync('info.connection');
121
+ return;
122
+ }
123
+
124
+ this.log.debug(`Creating DeconzApi instance for http://${ip}:${port || 80}/api/<key>`);
125
+ this._api = new DeconzApi({
126
+ ip,
127
+ port: port || 80,
128
+ apiKey,
129
+ log: this.log,
130
+ });
131
+
132
+ this.log.debug('Testing deCONZ connection...');
133
+ const gatewayConfig = await this._api.getConfig();
134
+ if (!gatewayConfig || !gatewayConfig.name) {
135
+ this.log.error(
136
+ `Cannot connect to deCONZ at ${ip}:${port || 80} — ` +
137
+ 'verify that the IP address, port, and API key are correct, ' +
138
+ 'and that the deCONZ gateway is reachable.',
139
+ );
140
+ return;
141
+ }
142
+ this.log.info(
143
+ `Gateway info — name: "${gatewayConfig.name}", firmware: ${gatewayConfig.swversion || 'n/a'}, ` +
144
+ `model: ${gatewayConfig.modelid || 'unknown'}, api: v${gatewayConfig.apiversion || 'n/a'}`,
145
+ );
146
+ this.setState('info.connection', true, true);
147
+ this.log.info(`Successfully connected to deCONZ at ${ip}:${port || 80}`);
148
+
149
+ // deCONZ reports the websocket port it actually listens on; this is authoritative
150
+ // and frequently differs from any value the user guessed/configured.
151
+ const effectiveWsPort = gatewayConfig.websocketport || wsPort || 443;
152
+ if (gatewayConfig.websocketport && gatewayConfig.websocketport !== wsPort) {
153
+ this.log.info(
154
+ `deCONZ reports websocket port ${gatewayConfig.websocketport} ` +
155
+ `(configured: ${wsPort || 'default 443'}) — using the port reported by deCONZ`,
156
+ );
157
+ }
158
+
159
+ this.log.debug('Creating RemoteHandler');
160
+ this._remote = new RemoteHandler(this, this._onColorWheelEvent.bind(this));
161
+
162
+ this.log.info('Starting device discovery...');
163
+ await this._discoverAll();
164
+
165
+ this.log.debug(`Opening WebSocket to ws://${ip}:${effectiveWsPort}`);
166
+ this._ws = new DeconzWebSocket({
167
+ ip,
168
+ wsPort: effectiveWsPort,
169
+ log: this.log,
170
+ onEvent: this._onWsEvent.bind(this),
171
+ onOpen: () => {
172
+ this.log.debug('WebSocket onOpen callback — setting info.connection=true');
173
+ this.setState('info.connection', true, true);
174
+ },
175
+ onClose: () => {
176
+ this.log.debug('WebSocket onClose callback — setting info.connection=false');
177
+ this.setState('info.connection', false, true);
178
+ },
179
+ });
180
+ this._ws.connect();
181
+
182
+ this.log.info(
183
+ 'Subscribing to state changes: lights.*.state.*, plugs.*.state.*, covers.*.state.*, ' +
184
+ 'thermostats.*.state.setpoint, groups.*.action.*, groups.*.scenes.*',
185
+ );
186
+ await this.subscribeStatesAsync('lights.*.state.*');
187
+ await this.subscribeStatesAsync('plugs.*.state.*');
188
+ await this.subscribeStatesAsync('covers.*.state.*');
189
+ await this.subscribeStatesAsync('thermostats.*.state.setpoint');
190
+ await this.subscribeStatesAsync('groups.*.action.*');
191
+ await this.subscribeStatesAsync('groups.*.scenes.*');
192
+
193
+ this.log.debug(`Scheduling fallback poll every ${pollingInterval || 60}s`);
194
+ this._schedulePoll();
195
+
196
+ this.log.info('Adapter ready');
197
+ }
198
+
199
+ /**
200
+ * Is called when adapter shuts down - callback has to be called under any circumstances!
201
+ *
202
+ * @param {() => void} callback - Callback function
203
+ */
204
+ onUnload(callback) {
205
+ this.log.info('Adapter stopping — cleaning up resources');
206
+ try {
207
+ this._stopped = true;
208
+
209
+ if (this._remote) {
210
+ this.log.debug('Stopping RemoteHandler');
211
+ this._remote.stop();
212
+ }
213
+ if (this._ws) {
214
+ this.log.debug('Closing WebSocket connection');
215
+ this._ws.close();
216
+ }
217
+ if (this._pollTimer) {
218
+ this.log.debug('Clearing fallback poll timer');
219
+ this.clearTimeout(this._pollTimer);
220
+ this._pollTimer = null;
221
+ }
222
+ if (this._rediscoverTimer) {
223
+ this.log.debug('Clearing pending re-discovery timer');
224
+ this.clearTimeout(this._rediscoverTimer);
225
+ this._rediscoverTimer = null;
226
+ }
227
+
228
+ this.setState('info.connection', false, true);
229
+ this.log.info('Adapter stopped — all resources cleaned up');
230
+ callback();
231
+ } catch (error) {
232
+ this.log.error(`Error during adapter unload: ${error.message}`);
233
+ callback();
234
+ }
235
+ }
236
+
237
+ // ─────────────────────────────────────────────────────────────────────────
238
+ // Discovery
239
+ // ─────────────────────────────────────────────────────────────────────────
240
+
241
+ /**
242
+ * Discover all deCONZ resources and create ioBroker objects.
243
+ */
244
+ async _discoverAll() {
245
+ const startedAt = Date.now();
246
+ this.log.debug('Starting full device discovery (lights → groups → remotes)');
247
+ // Reset before re-populating so removed devices/groups/scenes don't
248
+ // linger in memory — _reconcileObjectTree() relies on these reflecting
249
+ // exactly what deCONZ reports right now.
250
+ this._lightMap = {};
251
+ this._plugMap = {};
252
+ this._coverMap = {};
253
+ this._groupMap = {};
254
+ this._remoteMap = {};
255
+ this._switchMap = {};
256
+ this._sensorMap = {};
257
+ this._thermostatMap = {};
258
+ this._sceneMap = {};
259
+ await this._discoverLights();
260
+ await this._discoverGroups();
261
+ await this._discoverRemotes();
262
+ await this._reconcileObjectTree();
263
+ const elapsedMs = Date.now() - startedAt;
264
+ this.log.info(
265
+ `Discovery complete in ${elapsedMs} ms: ` +
266
+ `${Object.keys(this._lightMap).length} light(s), ` +
267
+ `${Object.keys(this._plugMap).length} plug(s), ` +
268
+ `${Object.keys(this._coverMap).length} cover(s), ` +
269
+ `${Object.keys(this._groupMap).length} group(s), ` +
270
+ `${Object.keys(this._remoteMap).length} remote(s), ` +
271
+ `${Object.keys(this._switchMap).length} switch(es), ` +
272
+ `${Object.keys(this._sensorMap).length} sensor(s), ` +
273
+ `${Object.keys(this._thermostatMap).length} thermostat(s)`,
274
+ );
275
+ if (elapsedMs > 3000) {
276
+ this.log.warn(
277
+ `Discovery took ${elapsedMs} ms — unusually slow. This can momentarily strain the ioBroker ` +
278
+ 'host (large objects-DB write burst) and is worth investigating if it happens repeatedly.',
279
+ );
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Discover all lights, smart plugs, and window coverings, creating their
285
+ * state objects under lights.*, plugs.*, or covers.* respectively.
286
+ */
287
+ async _discoverLights() {
288
+ try {
289
+ this.log.debug('Discovering lights...');
290
+ const lights = await this._api.getLights();
291
+ for (const [id, light] of Object.entries(lights)) {
292
+ const category = isPlug(light) ? 'Plug' : isCover(light) ? 'Cover' : 'Light';
293
+ this.log.debug(
294
+ ` ${category} ${id}: "${light.name}" — ` +
295
+ `model=${light.modelid || 'unknown'}, ` +
296
+ `manufacturer=${light.manufacturername || 'unknown'}, ` +
297
+ `reachable=${light.state?.reachable}, ` +
298
+ `on=${light.state?.on}, ` +
299
+ `bri=${light.state?.bri}`,
300
+ );
301
+ if (isPlug(light)) {
302
+ this._plugMap[id] = light.name;
303
+ await this._createPlugObjects(id, light);
304
+ await this._updatePlugStates(id, light);
305
+ } else if (isCover(light)) {
306
+ this._coverMap[id] = light.name;
307
+ await this._createCoverObjects(id, light);
308
+ await this._updateCoverStates(id, light);
309
+ } else {
310
+ this._lightMap[id] = light.name;
311
+ await this._createLightObjects(id, light);
312
+ await this._updateLightStates(id, light);
313
+ }
314
+ }
315
+ const count = Object.keys(lights).length;
316
+ const names = Object.values(lights)
317
+ .map(l => `"${l.name}"`)
318
+ .join(', ');
319
+ this.log.info(`Discovered ${count} light(s)/plug(s)/cover(s): ${names}`);
320
+ } catch (err) {
321
+ this.log.error(`Light discovery failed: ${err.message}`);
322
+ }
323
+ }
324
+
325
+ /**
326
+ * Discover all groups including their scenes and create state objects.
327
+ */
328
+ async _discoverGroups() {
329
+ try {
330
+ this.log.debug('Discovering groups...');
331
+ const groups = await this._api.getGroups();
332
+ for (const [id, group] of Object.entries(groups)) {
333
+ const sceneMap = {};
334
+ if (Array.isArray(group.scenes)) {
335
+ for (const sc of group.scenes) {
336
+ sceneMap[sc.name] = sc.id;
337
+ }
338
+ }
339
+ this._groupMap[id] = { name: group.name, scenes: sceneMap };
340
+ this._sceneMap[id] = sceneMap;
341
+ const sceneNames = Object.keys(sceneMap);
342
+ this.log.debug(
343
+ ` Group ${id}: "${group.name}" — ` +
344
+ `members=[${(group.lights || []).join(', ')}], ` +
345
+ `scenes=[${sceneNames.join(', ') || '(none)'}]`,
346
+ );
347
+ await this._createGroupObjects(id, group);
348
+ await this._updateGroupStates(id, group);
349
+ }
350
+ const count = Object.keys(groups).length;
351
+ const names = Object.values(groups)
352
+ .map(g => `"${g.name}"`)
353
+ .join(', ');
354
+ this.log.info(`Discovered ${count} group(s): ${names}`);
355
+ } catch (err) {
356
+ this.log.error(`Group discovery failed: ${err.message}`);
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Discover all sensors and create state objects: Tint remotes (remotes.*),
362
+ * generic Zigbee switches (switches.*), thermostats (thermostats.*), and
363
+ * everything else as a read-only sensor (sensors.*).
364
+ */
365
+ async _discoverRemotes() {
366
+ try {
367
+ this.log.debug('Discovering sensors...');
368
+ const sensors = await this._api.getSensors();
369
+ for (const [id, sensor] of Object.entries(sensors)) {
370
+ if (sensor.type === 'ZHAThermostat') {
371
+ this._thermostatMap[id] = sensor.name;
372
+ this.log.debug(` Thermostat ${id}: "${sensor.name}" — battery=${sensor.config?.battery}%`);
373
+ await this._createThermostatObjects(id, sensor);
374
+ await this._updateThermostatStates(id, sensor);
375
+ } else if (sensor.type && sensor.type.includes('Switch')) {
376
+ if (isTintRemote(sensor)) {
377
+ this._remoteMap[id] = sensor.name;
378
+ this.log.debug(
379
+ ` Remote ${id}: "${sensor.name}" — ` +
380
+ `type=${sensor.type}, ` +
381
+ `battery=${sensor.config?.battery}%, ` +
382
+ `reachable=${sensor.config?.reachable}`,
383
+ );
384
+ await this._createRemoteObjects(id, sensor);
385
+ await this._updateRemoteInfo(id, sensor);
386
+ } else {
387
+ this._switchMap[id] = sensor.name;
388
+ this.log.debug(
389
+ ` Switch ${id}: "${sensor.name}" — ` +
390
+ `type=${sensor.type}, manufacturer=${sensor.manufacturername || 'unknown'}`,
391
+ );
392
+ await this._createSwitchObjects(id, sensor);
393
+ await this._updateSwitchInfo(id, sensor);
394
+ }
395
+ } else {
396
+ this._sensorMap[id] = sensor.name;
397
+ this.log.debug(` Sensor ${id}: "${sensor.name}" — type=${sensor.type}`);
398
+ await this._createSensorObjects(id, sensor);
399
+ await this._updateSensorStates(id, sensor);
400
+ }
401
+ }
402
+ const count = Object.keys(sensors).length;
403
+ this.log.info(
404
+ `Discovered ${count} sensor(s) total: ` +
405
+ `${Object.keys(this._remoteMap).length} remote(s), ` +
406
+ `${Object.keys(this._switchMap).length} switch(es), ` +
407
+ `${Object.keys(this._sensorMap).length} sensor(s), ` +
408
+ `${Object.keys(this._thermostatMap).length} thermostat(s)`,
409
+ );
410
+ } catch (err) {
411
+ this.log.error(`Sensor discovery failed: ${err.message}`);
412
+ }
413
+ }
414
+
415
+ /**
416
+ * Remove ioBroker objects under the lights/plugs/covers/groups/remotes/
417
+ * switches/sensors/thermostats namespaces that no longer correspond to
418
+ * anything deCONZ reported in the discovery that just ran (e.g. a deleted
419
+ * light, a renamed/removed scene). Relies on all the _xxxMap fields having
420
+ * just been reset and fully re-populated by _discoverLights/
421
+ * _discoverGroups/_discoverRemotes.
422
+ */
423
+ async _reconcileObjectTree() {
424
+ try {
425
+ const expected = new Set([
426
+ ...Object.keys(this._lightMap).map(id => `lights.${id}`),
427
+ ...Object.keys(this._plugMap).map(id => `plugs.${id}`),
428
+ ...Object.keys(this._coverMap).map(id => `covers.${id}`),
429
+ ...Object.keys(this._groupMap).map(id => `groups.${id}`),
430
+ ...Object.keys(this._remoteMap).map(id => `remotes.${id}`),
431
+ ...Object.keys(this._switchMap).map(id => `switches.${id}`),
432
+ ...Object.keys(this._sensorMap).map(id => `sensors.${id}`),
433
+ ...Object.keys(this._thermostatMap).map(id => `thermostats.${id}`),
434
+ ]);
435
+ const prefixes = [
436
+ 'lights.',
437
+ 'plugs.',
438
+ 'covers.',
439
+ 'groups.',
440
+ 'remotes.',
441
+ 'switches.',
442
+ 'sensors.',
443
+ 'thermostats.',
444
+ ];
445
+ const devices = await this.getDevicesAsync();
446
+ for (const dev of devices) {
447
+ const relId = dev._id.slice(this.namespace.length + 1);
448
+ if (!prefixes.some(p => relId.startsWith(p))) {
449
+ continue;
450
+ }
451
+ if (!expected.has(relId)) {
452
+ this.log.info(`Removing stale object tree "${relId}" (no longer present in deCONZ)`);
453
+ await this.delObjectAsync(relId, { recursive: true });
454
+ }
455
+ }
456
+
457
+ // Orphaned scene states inside groups we keep (renamed/deleted scenes)
458
+ for (const [groupId, sceneMap] of Object.entries(this._sceneMap)) {
459
+ const expectedScenes = new Set(Object.keys(sceneMap).map(name => name.replace(/[^a-zA-Z0-9_]/g, '_')));
460
+ const existing = await this.getStatesOfAsync(`groups.${groupId}`, 'scenes').catch(() => []);
461
+ for (const st of existing || []) {
462
+ const safeKey = st._id.split('.').pop();
463
+ if (!expectedScenes.has(safeKey)) {
464
+ this.log.info(`Removing stale scene state "${st._id}"`);
465
+ await this.delObjectAsync(st._id.slice(this.namespace.length + 1));
466
+ }
467
+ }
468
+ }
469
+ } catch (err) {
470
+ this.log.error(`Object tree reconciliation failed: ${err.message}`);
471
+ }
472
+ }
473
+
474
+ // ─────────────────────────────────────────────────────────────────────────
475
+ // Object creation
476
+ // ─────────────────────────────────────────────────────────────────────────
477
+
478
+ /**
479
+ * Create all ioBroker objects for a light.
480
+ *
481
+ * @param {string} id - deCONZ light id
482
+ * @param {object} light - deCONZ light object
483
+ */
484
+ async _createLightObjects(id, light) {
485
+ this.log.debug(`Creating/updating objects for light ${id} ("${light.name}")`);
486
+ await this.extendObjectAsync(`lights.${id}`, lightDevice(id, light.name));
487
+ await this.setObjectNotExistsAsync(`lights.${id}.info`, lightInfoChannel(id));
488
+ await this.setObjectNotExistsAsync(`lights.${id}.state`, lightStateChannel(id));
489
+ for (const def of LIGHT_STATES) {
490
+ await this.setObjectNotExistsAsync(`lights.${id}.${def.sub}`, buildStateObj(`lights.${id}`, def));
491
+ }
492
+ this.log.debug(`Objects for light ${id} ready`);
493
+ }
494
+
495
+ /**
496
+ * Create all ioBroker objects for a smart plug/switch.
497
+ *
498
+ * @param {string} id - deCONZ light id
499
+ * @param {object} light - deCONZ light object
500
+ */
501
+ async _createPlugObjects(id, light) {
502
+ this.log.debug(`Creating/updating objects for plug ${id} ("${light.name}")`);
503
+ await this.extendObjectAsync(`plugs.${id}`, plugDevice(id, light.name));
504
+ await this.setObjectNotExistsAsync(`plugs.${id}.info`, plugInfoChannel(id));
505
+ await this.setObjectNotExistsAsync(`plugs.${id}.state`, plugStateChannel(id));
506
+ for (const def of PLUG_STATES) {
507
+ await this.setObjectNotExistsAsync(`plugs.${id}.${def.sub}`, buildStateObj(`plugs.${id}`, def));
508
+ }
509
+ this.log.debug(`Objects for plug ${id} ready`);
510
+ }
511
+
512
+ /**
513
+ * Create all ioBroker objects for a window covering motor.
514
+ *
515
+ * @param {string} id - deCONZ light id
516
+ * @param {object} light - deCONZ light object
517
+ */
518
+ async _createCoverObjects(id, light) {
519
+ this.log.debug(`Creating/updating objects for cover ${id} ("${light.name}")`);
520
+ await this.extendObjectAsync(`covers.${id}`, coverDevice(id, light.name));
521
+ await this.setObjectNotExistsAsync(`covers.${id}.info`, coverInfoChannel(id));
522
+ await this.setObjectNotExistsAsync(`covers.${id}.state`, coverStateChannel(id));
523
+ for (const def of COVER_STATES) {
524
+ await this.setObjectNotExistsAsync(`covers.${id}.${def.sub}`, buildStateObj(`covers.${id}`, def));
525
+ }
526
+ this.log.debug(`Objects for cover ${id} ready`);
527
+ }
528
+
529
+ /**
530
+ * Create all ioBroker objects for a group including scene boolean states.
531
+ *
532
+ * @param {string} id - deCONZ group id
533
+ * @param {object} group - deCONZ group object
534
+ */
535
+ async _createGroupObjects(id, group) {
536
+ this.log.debug(`Creating/updating objects for group ${id} ("${group.name}")`);
537
+ await this.extendObjectAsync(`groups.${id}`, groupDevice(id, group.name));
538
+ await this.setObjectNotExistsAsync(`groups.${id}.info`, groupInfoChannel(id));
539
+ await this.setObjectNotExistsAsync(`groups.${id}.action`, groupActionChannel(id));
540
+ await this.setObjectNotExistsAsync(`groups.${id}.scenes`, groupScenesChannel(id));
541
+ for (const def of GROUP_INFO_STATES) {
542
+ await this.setObjectNotExistsAsync(`groups.${id}.${def.sub}`, buildStateObj(`groups.${id}`, def));
543
+ }
544
+ for (const def of GROUP_ACTION_STATES) {
545
+ await this.setObjectNotExistsAsync(`groups.${id}.${def.sub}`, buildStateObj(`groups.${id}`, def));
546
+ }
547
+ const sceneMap = this._sceneMap[id] || {};
548
+ for (const sceneName of Object.keys(sceneMap)) {
549
+ const safeKey = sceneName.replace(/[^a-zA-Z0-9_]/g, '_');
550
+ this.log.debug(` Creating scene state: groups.${id}.scenes.${safeKey} ("${sceneName}")`);
551
+ await this.setObjectNotExistsAsync(`groups.${id}.scenes.${safeKey}`, {
552
+ _id: `groups.${id}.scenes.${safeKey}`,
553
+ type: 'state',
554
+ common: {
555
+ name: sceneName,
556
+ type: 'boolean',
557
+ role: 'button',
558
+ read: true,
559
+ write: true,
560
+ def: false,
561
+ },
562
+ native: { sceneName },
563
+ });
564
+ }
565
+ this.log.debug(`Objects for group ${id} ready (${Object.keys(sceneMap).length} scene(s))`);
566
+ }
567
+
568
+ /**
569
+ * Create all ioBroker objects for a remote sensor.
570
+ *
571
+ * @param {string} id - deCONZ sensor id
572
+ * @param {object} sensor - deCONZ sensor object
573
+ */
574
+ async _createRemoteObjects(id, sensor) {
575
+ this.log.debug(`Creating/updating objects for remote ${id} ("${sensor.name}")`);
576
+ await this.extendObjectAsync(`remotes.${id}`, remoteDevice(id, sensor.name));
577
+ for (const [sub, name] of [
578
+ ['info', 'Info'],
579
+ ['button', 'Button'],
580
+ ['colorWheel', 'Color Wheel'],
581
+ ['colorTemp', 'Color Temp'],
582
+ ]) {
583
+ await this.setObjectNotExistsAsync(`remotes.${id}.${sub}`, remoteChannel(id, sub, name));
584
+ }
585
+ for (const def of [
586
+ ...REMOTE_INFO_STATES,
587
+ ...REMOTE_BUTTON_STATES,
588
+ ...REMOTE_COLORWHEEL_STATES,
589
+ ...REMOTE_COLORTEMP_STATES,
590
+ ]) {
591
+ await this.setObjectNotExistsAsync(`remotes.${id}.${def.sub}`, buildStateObj(`remotes.${id}`, def));
592
+ }
593
+ this.log.debug(`Objects for remote ${id} ready`);
594
+ }
595
+
596
+ /**
597
+ * Create all ioBroker objects for a generic (non-Tint) Zigbee switch.
598
+ *
599
+ * @param {string} id - deCONZ sensor id
600
+ * @param {object} sensor - deCONZ sensor object
601
+ */
602
+ async _createSwitchObjects(id, sensor) {
603
+ this.log.debug(`Creating/updating objects for switch ${id} ("${sensor.name}")`);
604
+ await this.extendObjectAsync(`switches.${id}`, switchDevice(id, sensor.name));
605
+ await this.setObjectNotExistsAsync(`switches.${id}.info`, switchChannel(id, 'info', 'Info'));
606
+ await this.setObjectNotExistsAsync(`switches.${id}.button`, switchChannel(id, 'button', 'Button'));
607
+ for (const def of [...SWITCH_INFO_STATES, ...SWITCH_BUTTON_STATES]) {
608
+ await this.setObjectNotExistsAsync(`switches.${id}.${def.sub}`, buildStateObj(`switches.${id}`, def));
609
+ }
610
+ this.log.debug(`Objects for switch ${id} ready`);
611
+ }
612
+
613
+ /**
614
+ * Create all ioBroker objects for a generic sensor. Only the value
615
+ * state(s) relevant to this sensor's deCONZ "type" get created — unknown
616
+ * types fall back to a single generic raw-value state.
617
+ *
618
+ * @param {string} id - deCONZ sensor id
619
+ * @param {object} sensor - deCONZ sensor object
620
+ */
621
+ async _createSensorObjects(id, sensor) {
622
+ this.log.debug(`Creating/updating objects for sensor ${id} ("${sensor.name}")`);
623
+ await this.extendObjectAsync(`sensors.${id}`, sensorDevice(id, sensor.name));
624
+ await this.setObjectNotExistsAsync(`sensors.${id}.info`, sensorChannel(id, 'info', 'Info'));
625
+ await this.setObjectNotExistsAsync(`sensors.${id}.value`, sensorChannel(id, 'value', 'Value'));
626
+ const valueStates = SENSOR_VALUE_STATES[sensor.type] || [SENSOR_GENERIC_VALUE_STATE];
627
+ for (const def of [...SENSOR_INFO_STATES, ...valueStates]) {
628
+ await this.setObjectNotExistsAsync(`sensors.${id}.${def.sub}`, buildStateObj(`sensors.${id}`, def));
629
+ }
630
+ this.log.debug(`Objects for sensor ${id} ready (type=${sensor.type})`);
631
+ }
632
+
633
+ /**
634
+ * Create all ioBroker objects for a thermostat.
635
+ *
636
+ * @param {string} id - deCONZ sensor id
637
+ * @param {object} sensor - deCONZ sensor object
638
+ */
639
+ async _createThermostatObjects(id, sensor) {
640
+ this.log.debug(`Creating/updating objects for thermostat ${id} ("${sensor.name}")`);
641
+ await this.extendObjectAsync(`thermostats.${id}`, thermostatDevice(id, sensor.name));
642
+ await this.setObjectNotExistsAsync(`thermostats.${id}.info`, thermostatInfoChannel(id));
643
+ await this.setObjectNotExistsAsync(`thermostats.${id}.state`, thermostatStateChannel(id));
644
+ for (const def of THERMOSTAT_STATES) {
645
+ await this.setObjectNotExistsAsync(`thermostats.${id}.${def.sub}`, buildStateObj(`thermostats.${id}`, def));
646
+ }
647
+ this.log.debug(`Objects for thermostat ${id} ready`);
648
+ }
649
+
650
+ // ─────────────────────────────────────────────────────────────────────────
651
+ // State sync helpers
652
+ // ─────────────────────────────────────────────────────────────────────────
653
+
654
+ /**
655
+ * Sync all states of a single light from its deCONZ object.
656
+ *
657
+ * @param {string} id - deCONZ light id
658
+ * @param {object} light - deCONZ light object
659
+ */
660
+ async _updateLightStates(id, light) {
661
+ const s = light.state || {};
662
+ const p = `lights.${id}`;
663
+
664
+ this.log.debug(
665
+ `Syncing light ${id} ("${light.name}"): ` +
666
+ `on=${s.on}, bri=${s.bri} (${briToPercent(s.bri ?? 0)}%), ` +
667
+ `ct=${s.ct}mired, colormode=${s.colormode || 'n/a'}, ` +
668
+ `reachable=${s.reachable}`,
669
+ );
670
+
671
+ await this._set(`${p}.info.name`, light.name);
672
+ await this._set(`${p}.info.modelid`, light.modelid || '');
673
+ await this._set(`${p}.info.manufacturer`, light.manufacturername || '');
674
+ await this._set(`${p}.info.reachable`, s.reachable ?? false);
675
+ await this._set(`${p}.info.uniqueid`, light.uniqueid || '');
676
+ await this._set(`${p}.state.on`, s.on ?? false);
677
+ await this._set(`${p}.state.brightness`, briToPercent(s.bri ?? 0));
678
+ await this._set(`${p}.state.colorMode`, s.colormode || '');
679
+
680
+ if (s.ct !== undefined) {
681
+ await this._set(`${p}.state.colorTemp`, miredToKelvin(s.ct));
682
+ }
683
+ if (s.hue !== undefined) {
684
+ await this._set(`${p}.state.hue`, s.hue);
685
+ }
686
+ if (s.sat !== undefined) {
687
+ await this._set(`${p}.state.saturation`, s.sat);
688
+ }
689
+ if (Array.isArray(s.xy)) {
690
+ await this._set(`${p}.state.x`, s.xy[0]);
691
+ await this._set(`${p}.state.y`, s.xy[1]);
692
+ await this._set(`${p}.state.hex`, xyToHex(s.xy[0], s.xy[1], s.bri));
693
+ }
694
+ if (s.effect !== undefined) {
695
+ await this._set(`${p}.state.effect`, s.effect);
696
+ }
697
+ if (s.colorspeed !== undefined) {
698
+ await this._set(`${p}.state.effectSpeed`, s.colorspeed);
699
+ }
700
+ }
701
+
702
+ /**
703
+ * Sync states of a single smart plug/switch from its deCONZ object.
704
+ * Plugs only get info.* and state.on — no brightness/color support.
705
+ *
706
+ * @param {string} id - deCONZ light id
707
+ * @param {object} light - deCONZ light object
708
+ */
709
+ async _updatePlugStates(id, light) {
710
+ const s = light.state || {};
711
+ const p = `plugs.${id}`;
712
+
713
+ this.log.debug(`Syncing plug ${id} ("${light.name}"): on=${s.on}, reachable=${s.reachable}`);
714
+
715
+ await this._set(`${p}.info.name`, light.name);
716
+ await this._set(`${p}.info.modelid`, light.modelid || '');
717
+ await this._set(`${p}.info.manufacturer`, light.manufacturername || '');
718
+ await this._set(`${p}.info.reachable`, s.reachable ?? false);
719
+ await this._set(`${p}.info.uniqueid`, light.uniqueid || '');
720
+ await this._set(`${p}.state.on`, s.on ?? false);
721
+ }
722
+
723
+ /**
724
+ * Sync states of a single window covering from its deCONZ object.
725
+ * deCONZ's "lift" is the percentage CLOSED (0=open, 100=closed); ioBroker's
726
+ * level.blind role is the opposite (0=closed, 100=open), hence 100-lift.
727
+ *
728
+ * @param {string} id - deCONZ light id
729
+ * @param {object} light - deCONZ light object
730
+ */
731
+ async _updateCoverStates(id, light) {
732
+ const s = light.state || {};
733
+ const p = `covers.${id}`;
734
+ const position = s.lift !== undefined ? 100 - s.lift : s.open ? 100 : 0;
735
+
736
+ this.log.debug(`Syncing cover ${id} ("${light.name}"): lift=${s.lift}, open=${s.open}, position=${position}`);
737
+
738
+ await this._set(`${p}.info.name`, light.name);
739
+ await this._set(`${p}.info.modelid`, light.modelid || '');
740
+ await this._set(`${p}.info.manufacturer`, light.manufacturername || '');
741
+ await this._set(`${p}.info.reachable`, s.reachable ?? false);
742
+ await this._set(`${p}.info.uniqueid`, light.uniqueid || '');
743
+ await this._set(`${p}.state.position`, position);
744
+ }
745
+
746
+ /**
747
+ * Sync all states of a single group from its deCONZ object.
748
+ *
749
+ * @param {string} id - deCONZ group id
750
+ * @param {object} group - deCONZ group object
751
+ */
752
+ async _updateGroupStates(id, group) {
753
+ const st = group.state || {};
754
+ const act = group.action || {};
755
+ const p = `groups.${id}`;
756
+
757
+ this.log.debug(
758
+ `Syncing group ${id} ("${group.name}"): ` +
759
+ `allOn=${st.all_on}, anyOn=${st.any_on}, ` +
760
+ `action.on=${act.on}, action.bri=${act.bri} (${briToPercent(act.bri ?? 0)}%), ` +
761
+ `action.ct=${act.ct}mired`,
762
+ );
763
+
764
+ await this._set(`${p}.info.name`, group.name);
765
+ await this._set(`${p}.info.memberCount`, (group.lights || []).length);
766
+ await this._set(`${p}.info.allOn`, st.all_on ?? false);
767
+ await this._set(`${p}.info.anyOn`, st.any_on ?? false);
768
+ await this._set(`${p}.action.on`, act.on ?? false);
769
+ await this._set(`${p}.action.brightness`, briToPercent(act.bri ?? 0));
770
+ if (act.ct !== undefined) {
771
+ await this._set(`${p}.action.colorTemp`, miredToKelvin(act.ct));
772
+ }
773
+ if (Array.isArray(act.xy)) {
774
+ await this._set(`${p}.action.hex`, xyToHex(act.xy[0], act.xy[1], act.bri));
775
+ }
776
+ if (act.effect !== undefined) {
777
+ await this._set(`${p}.action.effect`, act.effect);
778
+ }
779
+ }
780
+
781
+ /**
782
+ * Sync info states of a remote sensor.
783
+ *
784
+ * @param {string} id - deCONZ sensor id
785
+ * @param {object} sensor - deCONZ sensor object
786
+ */
787
+ async _updateRemoteInfo(id, sensor) {
788
+ const p = `remotes.${id}`;
789
+
790
+ this.log.debug(
791
+ `Syncing remote ${id} ("${sensor.name}"): ` +
792
+ `reachable=${sensor.config?.reachable}, ` +
793
+ `battery=${sensor.config?.battery}%, ` +
794
+ `lastSeen=${sensor.lastseen || 'n/a'}`,
795
+ );
796
+
797
+ await this._set(`${p}.info.name`, sensor.name);
798
+ await this._set(`${p}.info.reachable`, sensor.config?.reachable ?? false);
799
+ if (sensor.config?.battery !== undefined) {
800
+ await this._set(`${p}.info.battery`, sensor.config.battery);
801
+ }
802
+ if (sensor.lastseen) {
803
+ await this._set(`${p}.info.lastSeen`, sensor.lastseen);
804
+ }
805
+ }
806
+
807
+ /**
808
+ * Sync info states of a generic (non-Tint) Zigbee switch.
809
+ *
810
+ * @param {string} id - deCONZ sensor id
811
+ * @param {object} sensor - deCONZ sensor object
812
+ */
813
+ async _updateSwitchInfo(id, sensor) {
814
+ const p = `switches.${id}`;
815
+
816
+ this.log.debug(
817
+ `Syncing switch ${id} ("${sensor.name}"): ` +
818
+ `reachable=${sensor.config?.reachable}, buttonevent=${sensor.state?.buttonevent}`,
819
+ );
820
+
821
+ await this._set(`${p}.info.name`, sensor.name);
822
+ await this._set(`${p}.info.reachable`, sensor.config?.reachable ?? false);
823
+ if (sensor.config?.battery !== undefined) {
824
+ await this._set(`${p}.info.battery`, sensor.config.battery);
825
+ }
826
+ if (sensor.lastseen) {
827
+ await this._set(`${p}.info.lastSeen`, sensor.lastseen);
828
+ }
829
+ if (sensor.state?.buttonevent !== undefined) {
830
+ await this._set(`${p}.button.lastEvent`, sensor.state.buttonevent);
831
+ }
832
+ }
833
+
834
+ /**
835
+ * Sync states of a generic sensor from its deCONZ object. Only the
836
+ * value(s) relevant to its type are written — see SENSOR_VALUE_STATES in
837
+ * lib/objects.js for the type → state mapping and the scaling notes
838
+ * there (deCONZ reports temperature/humidity in hundredths, etc.).
839
+ *
840
+ * @param {string} id - deCONZ sensor id
841
+ * @param {object} sensor - deCONZ sensor object
842
+ */
843
+ async _updateSensorStates(id, sensor) {
844
+ const s = sensor.state || {};
845
+ const p = `sensors.${id}`;
846
+
847
+ this.log.debug(`Syncing sensor ${id} ("${sensor.name}"): type=${sensor.type}, state=${JSON.stringify(s)}`);
848
+
849
+ await this._set(`${p}.info.name`, sensor.name);
850
+ await this._set(`${p}.info.reachable`, sensor.config?.reachable ?? false);
851
+ if (sensor.config?.battery !== undefined) {
852
+ await this._set(`${p}.info.battery`, sensor.config.battery);
853
+ }
854
+ if (sensor.lastseen) {
855
+ await this._set(`${p}.info.lastSeen`, sensor.lastseen);
856
+ }
857
+
858
+ switch (sensor.type) {
859
+ case 'ZHATemperature':
860
+ if (s.temperature !== undefined) {
861
+ await this._set(`${p}.value.temperature`, s.temperature / 100);
862
+ }
863
+ break;
864
+ case 'ZHAHumidity':
865
+ if (s.humidity !== undefined) {
866
+ await this._set(`${p}.value.humidity`, s.humidity / 100);
867
+ }
868
+ break;
869
+ case 'ZHAPressure':
870
+ if (s.pressure !== undefined) {
871
+ await this._set(`${p}.value.pressure`, s.pressure);
872
+ }
873
+ break;
874
+ case 'ZHAOpenClose':
875
+ if (s.open !== undefined) {
876
+ await this._set(`${p}.value.open`, s.open);
877
+ }
878
+ break;
879
+ case 'ZHAPresence':
880
+ if (s.presence !== undefined) {
881
+ await this._set(`${p}.value.presence`, s.presence);
882
+ }
883
+ break;
884
+ case 'ZHALightLevel': {
885
+ let lux = s.lux;
886
+ if (lux === undefined && s.lightlevel !== undefined) {
887
+ lux = Math.round(Math.pow(10, (s.lightlevel - 1) / 10000));
888
+ }
889
+ if (lux !== undefined) {
890
+ await this._set(`${p}.value.brightness`, lux);
891
+ }
892
+ break;
893
+ }
894
+ case 'ZHAPower':
895
+ if (s.power !== undefined) {
896
+ await this._set(`${p}.value.power`, s.power);
897
+ }
898
+ break;
899
+ case 'ZHAConsumption':
900
+ if (s.consumption !== undefined) {
901
+ await this._set(`${p}.value.consumption`, s.consumption / 1000);
902
+ }
903
+ break;
904
+ default:
905
+ await this._set(`${p}.value.raw`, JSON.stringify(s));
906
+ }
907
+ }
908
+
909
+ /**
910
+ * Sync states of a single thermostat from its deCONZ object. deCONZ
911
+ * reports temperature/heatsetpoint in hundredths of a degree Celsius.
912
+ *
913
+ * @param {string} id - deCONZ sensor id
914
+ * @param {object} sensor - deCONZ sensor object
915
+ */
916
+ async _updateThermostatStates(id, sensor) {
917
+ const s = sensor.state || {};
918
+ const c = sensor.config || {};
919
+ const p = `thermostats.${id}`;
920
+
921
+ this.log.debug(
922
+ `Syncing thermostat ${id} ("${sensor.name}"): ` +
923
+ `temperature=${s.temperature}, valve=${s.valve}, heatsetpoint=${c.heatsetpoint}`,
924
+ );
925
+
926
+ await this._set(`${p}.info.name`, sensor.name);
927
+ await this._set(`${p}.info.reachable`, c.reachable ?? false);
928
+ if (c.battery !== undefined) {
929
+ await this._set(`${p}.info.battery`, c.battery);
930
+ }
931
+ if (s.temperature !== undefined) {
932
+ await this._set(`${p}.state.temperature`, s.temperature / 100);
933
+ }
934
+ if (s.valve !== undefined) {
935
+ await this._set(`${p}.state.valve`, s.valve);
936
+ }
937
+ if (c.heatsetpoint !== undefined) {
938
+ await this._set(`${p}.state.setpoint`, c.heatsetpoint / 100);
939
+ }
940
+ }
941
+
942
+ // ─────────────────────────────────────────────────────────────────────────
943
+ // WebSocket event handler
944
+ // ─────────────────────────────────────────────────────────────────────────
945
+
946
+ /**
947
+ * Handle a deCONZ WebSocket push event.
948
+ *
949
+ * @param {object} event - Parsed WebSocket event object
950
+ */
951
+ async _onWsEvent(event) {
952
+ if (!event || !event.e || !event.r || !event.id) {
953
+ this.log.debug(`WS event ignored — missing required fields: ${JSON.stringify(event)}`);
954
+ return;
955
+ }
956
+ const { e, r, id, state, action, config, attr } = event;
957
+
958
+ this.log.debug(`Processing WS event: e="${e}" r="${r}" id="${id}"`);
959
+
960
+ if (e === 'changed') {
961
+ if (r === 'lights' && state) {
962
+ if (this._plugMap[id]) {
963
+ this.log.debug(`WS plug ${id} state changed: ${JSON.stringify(state)}`);
964
+ await this._applyPlugStateUpdate(id, state);
965
+ } else if (this._coverMap[id]) {
966
+ this.log.debug(`WS cover ${id} state changed: ${JSON.stringify(state)}`);
967
+ await this._applyCoverStateUpdate(id, state);
968
+ } else {
969
+ this.log.debug(`WS light ${id} state changed: ${JSON.stringify(state)}`);
970
+ await this._applyLightStateUpdate(id, state);
971
+ }
972
+ } else if (r === 'groups') {
973
+ if (state) {
974
+ this.log.debug(`WS group ${id} state changed: ${JSON.stringify(state)}`);
975
+ await this._applyGroupStateUpdate(id, state);
976
+ }
977
+ if (action) {
978
+ this.log.debug(`WS group ${id} action changed: ${JSON.stringify(action)}`);
979
+ await this._applyGroupActionUpdate(id, action);
980
+ }
981
+ if (!state && !action) {
982
+ this.log.debug(`WS group ${id} changed event has no state or action payload — skipping`);
983
+ }
984
+ } else if (r === 'sensors') {
985
+ if (this._remoteMap[id] && state && state.buttonevent !== undefined) {
986
+ this.log.debug(`WS remote ${id} button event: ${state.buttonevent} — dispatching to RemoteHandler`);
987
+ await this._remote.handleEvent(id, state, config, attr);
988
+ } else if (this._switchMap[id] && state && state.buttonevent !== undefined) {
989
+ this.log.debug(`WS switch ${id} button event: ${state.buttonevent}`);
990
+ await this._set(`switches.${id}.button.lastEvent`, state.buttonevent);
991
+ } else if (this._thermostatMap[id] && (state || config)) {
992
+ this.log.debug(
993
+ `WS thermostat ${id} changed: state=${JSON.stringify(state)} config=${JSON.stringify(config)}`,
994
+ );
995
+ if (state?.temperature !== undefined) {
996
+ await this._set(`thermostats.${id}.state.temperature`, state.temperature / 100);
997
+ }
998
+ if (state?.valve !== undefined) {
999
+ await this._set(`thermostats.${id}.state.valve`, state.valve);
1000
+ }
1001
+ if (config?.heatsetpoint !== undefined) {
1002
+ await this._set(`thermostats.${id}.state.setpoint`, config.heatsetpoint / 100);
1003
+ }
1004
+ } else if (this._sensorMap[id] && state) {
1005
+ this.log.debug(`WS sensor ${id} state changed: ${JSON.stringify(state)}`);
1006
+ await this._applySensorStateUpdate(id, state);
1007
+ } else {
1008
+ this.log.debug(`WS sensor ${id} changed event has no relevant payload — skipping`);
1009
+ }
1010
+ } else {
1011
+ this.log.debug(`WS changed event for r="${r}" id="${id}" has no relevant payload — skipping`);
1012
+ }
1013
+ } else if (e === 'added') {
1014
+ this.log.info(`WS: new ${r} (id=${id}) detected — scheduling full re-discovery`);
1015
+ this._scheduleRediscover();
1016
+ } else if (e === 'deleted') {
1017
+ this.log.info(`WS: ${r} id=${id} was removed from deCONZ — scheduling re-discovery to update object tree`);
1018
+ this._scheduleRediscover();
1019
+ } else {
1020
+ this.log.debug(`WS event type "${e}" for r="${r}" id="${id}" — no handler, ignoring`);
1021
+ }
1022
+ }
1023
+
1024
+ /**
1025
+ * Debounce repeated added/deleted WS events into a single _discoverAll()
1026
+ * call. deCONZ can emit several such events in quick succession (e.g.
1027
+ * after a brief network hiccup causes it to re-announce multiple
1028
+ * devices) — without debouncing, each one would trigger its own full
1029
+ * discovery pass back-to-back, multiplying the object-tree write load
1030
+ * for no benefit (one discovery after the dust settles covers all of
1031
+ * them).
1032
+ */
1033
+ _scheduleRediscover() {
1034
+ if (this._stopped) {
1035
+ this.log.debug('_scheduleRediscover called after stop — ignoring');
1036
+ return;
1037
+ }
1038
+ if (this._rediscoverTimer) {
1039
+ this.log.debug('Re-discovery already scheduled — coalescing this trigger into it');
1040
+ return;
1041
+ }
1042
+ this._rediscoverTimer = this.setTimeout(() => {
1043
+ this._rediscoverTimer = null;
1044
+ this._discoverAll().catch(err => this.log.error(`Scheduled re-discovery failed: ${err.message}`));
1045
+ }, 2000);
1046
+ }
1047
+
1048
+ /**
1049
+ * Apply a partial light state update received via WebSocket.
1050
+ *
1051
+ * @param {string} id - deCONZ light id
1052
+ * @param {object} s - Partial state object
1053
+ */
1054
+ async _applyLightStateUpdate(id, s) {
1055
+ if (!this._lightMap[id]) {
1056
+ this.log.debug(`WS: light ${id} not in lightMap — skipping state update`);
1057
+ return;
1058
+ }
1059
+ const name = this._lightMap[id];
1060
+ const p = `lights.${id}`;
1061
+ this.log.debug(`Applying WS state update for light ${id} ("${name}"): ${JSON.stringify(s)}`);
1062
+
1063
+ if (s.on !== undefined) {
1064
+ await this._set(`${p}.state.on`, s.on);
1065
+ }
1066
+ if (s.bri !== undefined) {
1067
+ await this._set(`${p}.state.brightness`, briToPercent(s.bri));
1068
+ }
1069
+ if (s.ct !== undefined) {
1070
+ await this._set(`${p}.state.colorTemp`, miredToKelvin(s.ct));
1071
+ }
1072
+ if (s.hue !== undefined) {
1073
+ await this._set(`${p}.state.hue`, s.hue);
1074
+ }
1075
+ if (s.sat !== undefined) {
1076
+ await this._set(`${p}.state.saturation`, s.sat);
1077
+ }
1078
+ if (Array.isArray(s.xy)) {
1079
+ await this._set(`${p}.state.x`, s.xy[0]);
1080
+ await this._set(`${p}.state.y`, s.xy[1]);
1081
+ await this._set(`${p}.state.hex`, xyToHex(s.xy[0], s.xy[1]));
1082
+ }
1083
+ if (s.reachable !== undefined) {
1084
+ this.log.debug(`Light ${id} ("${name}") reachability changed: ${s.reachable}`);
1085
+ await this._set(`${p}.info.reachable`, s.reachable);
1086
+ }
1087
+ if (s.colormode !== undefined) {
1088
+ await this._set(`${p}.state.colorMode`, s.colormode);
1089
+ }
1090
+ if (s.effect !== undefined) {
1091
+ await this._set(`${p}.state.effect`, s.effect);
1092
+ }
1093
+ if (s.colorspeed !== undefined) {
1094
+ await this._set(`${p}.state.effectSpeed`, s.colorspeed);
1095
+ }
1096
+ }
1097
+
1098
+ /**
1099
+ * Apply a partial plug state update received via WebSocket.
1100
+ * Plugs only get state.on and info.reachable — no brightness/color support.
1101
+ *
1102
+ * @param {string} id - deCONZ light id
1103
+ * @param {object} s - Partial state object
1104
+ */
1105
+ async _applyPlugStateUpdate(id, s) {
1106
+ if (!this._plugMap[id]) {
1107
+ this.log.debug(`WS: plug ${id} not in plugMap — skipping state update`);
1108
+ return;
1109
+ }
1110
+ const name = this._plugMap[id];
1111
+ const p = `plugs.${id}`;
1112
+ this.log.debug(`Applying WS state update for plug ${id} ("${name}"): ${JSON.stringify(s)}`);
1113
+
1114
+ if (s.on !== undefined) {
1115
+ await this._set(`${p}.state.on`, s.on);
1116
+ }
1117
+ if (s.reachable !== undefined) {
1118
+ this.log.debug(`Plug ${id} ("${name}") reachability changed: ${s.reachable}`);
1119
+ await this._set(`${p}.info.reachable`, s.reachable);
1120
+ }
1121
+ }
1122
+
1123
+ /**
1124
+ * Apply a partial cover state update received via WebSocket.
1125
+ *
1126
+ * @param {string} id - deCONZ light id
1127
+ * @param {object} s - Partial state object
1128
+ */
1129
+ async _applyCoverStateUpdate(id, s) {
1130
+ if (!this._coverMap[id]) {
1131
+ this.log.debug(`WS: cover ${id} not in coverMap — skipping state update`);
1132
+ return;
1133
+ }
1134
+ const name = this._coverMap[id];
1135
+ const p = `covers.${id}`;
1136
+ this.log.debug(`Applying WS state update for cover ${id} ("${name}"): ${JSON.stringify(s)}`);
1137
+
1138
+ if (s.lift !== undefined) {
1139
+ await this._set(`${p}.state.position`, 100 - s.lift);
1140
+ } else if (s.open !== undefined) {
1141
+ await this._set(`${p}.state.position`, s.open ? 100 : 0);
1142
+ }
1143
+ if (s.reachable !== undefined) {
1144
+ await this._set(`${p}.info.reachable`, s.reachable);
1145
+ }
1146
+ }
1147
+
1148
+ /**
1149
+ * Apply a partial sensor state update received via WebSocket. Field
1150
+ * names already uniquely identify the metric, so no need to know the
1151
+ * sensor's deCONZ "type" here — see lib/objects.js#SENSOR_VALUE_STATES
1152
+ * for the same field→state mapping used at discovery time.
1153
+ *
1154
+ * @param {string} id - deCONZ sensor id
1155
+ * @param {object} s - Partial state object
1156
+ */
1157
+ async _applySensorStateUpdate(id, s) {
1158
+ const p = `sensors.${id}`;
1159
+ if (s.temperature !== undefined) {
1160
+ await this._set(`${p}.value.temperature`, s.temperature / 100);
1161
+ }
1162
+ if (s.humidity !== undefined) {
1163
+ await this._set(`${p}.value.humidity`, s.humidity / 100);
1164
+ }
1165
+ if (s.pressure !== undefined) {
1166
+ await this._set(`${p}.value.pressure`, s.pressure);
1167
+ }
1168
+ if (s.open !== undefined) {
1169
+ await this._set(`${p}.value.open`, s.open);
1170
+ }
1171
+ if (s.presence !== undefined) {
1172
+ await this._set(`${p}.value.presence`, s.presence);
1173
+ }
1174
+ if (s.power !== undefined) {
1175
+ await this._set(`${p}.value.power`, s.power);
1176
+ }
1177
+ if (s.consumption !== undefined) {
1178
+ await this._set(`${p}.value.consumption`, s.consumption / 1000);
1179
+ }
1180
+ if (s.lux !== undefined) {
1181
+ await this._set(`${p}.value.brightness`, s.lux);
1182
+ } else if (s.lightlevel !== undefined) {
1183
+ await this._set(`${p}.value.brightness`, Math.round(Math.pow(10, (s.lightlevel - 1) / 10000)));
1184
+ }
1185
+ }
1186
+
1187
+ /**
1188
+ * Apply a partial group state update received via WebSocket.
1189
+ *
1190
+ * @param {string} id - deCONZ group id
1191
+ * @param {object} s - Partial state object (all_on, any_on)
1192
+ */
1193
+ async _applyGroupStateUpdate(id, s) {
1194
+ if (!this._groupMap[id]) {
1195
+ this.log.debug(`WS: group ${id} not in groupMap — skipping state update`);
1196
+ return;
1197
+ }
1198
+ const name = this._groupMap[id].name;
1199
+ this.log.debug(`Applying WS state update for group ${id} ("${name}"): ${JSON.stringify(s)}`);
1200
+ const p = `groups.${id}`;
1201
+ if (s.all_on !== undefined) {
1202
+ await this._set(`${p}.info.allOn`, s.all_on);
1203
+ }
1204
+ if (s.any_on !== undefined) {
1205
+ await this._set(`${p}.info.anyOn`, s.any_on);
1206
+ }
1207
+ }
1208
+
1209
+ /**
1210
+ * Apply a partial group action update received via WebSocket.
1211
+ *
1212
+ * @param {string} id - deCONZ group id
1213
+ * @param {object} a - Partial action object
1214
+ */
1215
+ async _applyGroupActionUpdate(id, a) {
1216
+ if (!this._groupMap[id]) {
1217
+ this.log.debug(`WS: group ${id} not in groupMap — skipping action update`);
1218
+ return;
1219
+ }
1220
+ const name = this._groupMap[id].name;
1221
+ this.log.debug(`Applying WS action update for group ${id} ("${name}"): ${JSON.stringify(a)}`);
1222
+ const p = `groups.${id}`;
1223
+ if (a.on !== undefined) {
1224
+ await this._set(`${p}.action.on`, a.on);
1225
+ }
1226
+ if (a.bri !== undefined) {
1227
+ await this._set(`${p}.action.brightness`, briToPercent(a.bri));
1228
+ }
1229
+ if (a.ct !== undefined) {
1230
+ await this._set(`${p}.action.colorTemp`, miredToKelvin(a.ct));
1231
+ }
1232
+ if (Array.isArray(a.xy)) {
1233
+ await this._set(`${p}.action.hex`, xyToHex(a.xy[0], a.xy[1], a.bri));
1234
+ }
1235
+ if (a.effect !== undefined) {
1236
+ await this._set(`${p}.action.effect`, a.effect);
1237
+ }
1238
+ }
1239
+
1240
+ // ─────────────────────────────────────────────────────────────────────────
1241
+ // Admin message handler (sendTo)
1242
+ // ─────────────────────────────────────────────────────────────────────────
1243
+
1244
+ /**
1245
+ * Handle sendTo messages from the admin UI.
1246
+ *
1247
+ * @param {ioBroker.Message} obj - Message object
1248
+ */
1249
+ async onMessage(obj) {
1250
+ if (!obj || !obj.command) {
1251
+ this.log.debug('onMessage: received empty or command-less message — ignoring');
1252
+ return;
1253
+ }
1254
+
1255
+ this.log.debug(
1256
+ `Admin command received: "${obj.command}" from "${obj.from}"${
1257
+ obj.message ? ` data=${JSON.stringify(obj.message)}` : ''
1258
+ }`,
1259
+ );
1260
+
1261
+ const respond = result => {
1262
+ if (obj.callback) {
1263
+ this.log.debug(`Responding to "${obj.command}": ${JSON.stringify(result).slice(0, 200)}`);
1264
+ this.sendTo(obj.from, obj.command, result, obj.callback);
1265
+ } else {
1266
+ this.log.debug(`No callback for "${obj.command}" — response discarded`);
1267
+ }
1268
+ };
1269
+
1270
+ // pair does not require an existing connection — it IS how the connection is bootstrapped.
1271
+ // Polls deCONZ every 3 s until the pairing window is detected (null → retry)
1272
+ // or up to 60 s (matching deCONZ's own pairing window duration).
1273
+ if (obj.command === 'pair') {
1274
+ const ip = (obj.message?.ip || this.config.ip || '').trim();
1275
+ const port = Number(obj.message?.port) || Number(this.config.port) || 80;
1276
+ this.log.info(`Pairing command received — target: ${ip}:${port}`);
1277
+ if (!ip) {
1278
+ this.log.warn('Pairing aborted — no IP address available (not in message and not configured)');
1279
+ respond({ error: 'IP address is required. Please enter the deCONZ IP address first.' });
1280
+ return;
1281
+ }
1282
+ const POLL_MS = 3000;
1283
+ const TIMEOUT_MS = 60000;
1284
+ const deadline = Date.now() + TIMEOUT_MS;
1285
+ let attempt = 0;
1286
+
1287
+ const tryPair = async () => {
1288
+ attempt++;
1289
+ this.log.debug(`Pairing attempt #${attempt} — POST http://${ip}:${port}/api`);
1290
+ try {
1291
+ const apiKey = await DeconzApi.pair(ip, port, this.log);
1292
+ if (apiKey !== null) {
1293
+ this.log.info(`Pairing successful after ${attempt} attempt(s) — API key received`);
1294
+ // admin's sendTo button only writes values back into the config form
1295
+ // when the response carries them under `native` and the jsonConfig
1296
+ // item has `useNative: true` — a bare top-level key is ignored.
1297
+ respond({ native: { apiKey } });
1298
+ return;
1299
+ }
1300
+ // null = pairing window not open yet
1301
+ const remaining = Math.ceil((deadline - Date.now()) / 1000);
1302
+ if (Date.now() >= deadline) {
1303
+ this.log.warn(
1304
+ `Pairing timed out after ${attempt} attempt(s) — ` +
1305
+ 'pairing window was never detected within 60 s',
1306
+ );
1307
+ respond({
1308
+ error: 'Pairing timeout (60 s). Please open the pairing window in deCONZ/Phoscon first.',
1309
+ });
1310
+ return;
1311
+ }
1312
+ this.log.debug(
1313
+ `Pairing: window not yet open — retrying in ${POLL_MS / 1000}s ` + `(${remaining}s remaining)`,
1314
+ );
1315
+ setTimeout(tryPair, POLL_MS);
1316
+ } catch (err) {
1317
+ this.log.error(`Pairing failed on attempt #${attempt}: ${err.message}`);
1318
+ respond({ error: err.message });
1319
+ }
1320
+ };
1321
+ tryPair();
1322
+ return;
1323
+ }
1324
+
1325
+ if (!this._api) {
1326
+ this.log.warn(`Command "${obj.command}" received but adapter is not connected to deCONZ`);
1327
+ respond({ error: 'Adapter not connected to deCONZ' });
1328
+ return;
1329
+ }
1330
+
1331
+ try {
1332
+ switch (obj.command) {
1333
+ case 'getLights': {
1334
+ const lights = await this._api.getLights();
1335
+ const trimmed = {};
1336
+ for (const [id, light] of Object.entries(lights)) {
1337
+ trimmed[id] = trimLightForAdmin(light);
1338
+ }
1339
+ this.log.debug(`Admin: returning light list (${Object.keys(trimmed).length} light(s), trimmed)`);
1340
+ respond({ lights: trimmed });
1341
+ break;
1342
+ }
1343
+
1344
+ case 'getGroups': {
1345
+ const groups = await this._api.getGroups();
1346
+ const trimmed = {};
1347
+ for (const [id, group] of Object.entries(groups)) {
1348
+ trimmed[id] = trimGroupForAdmin(group);
1349
+ }
1350
+ this.log.debug(`Admin: returning group list (${Object.keys(trimmed).length} group(s), trimmed)`);
1351
+ respond({ groups: trimmed });
1352
+ break;
1353
+ }
1354
+
1355
+ case 'getSensors':
1356
+ this.log.debug('Admin: returning sensor list');
1357
+ respond({ sensors: await this._api.getSensors() });
1358
+ break;
1359
+
1360
+ case 'createGroup': {
1361
+ this.log.info(
1362
+ `Admin: create group command — name="${obj.message.name}", ` +
1363
+ `lights=[${(obj.message.lights || []).join(', ')}]`,
1364
+ );
1365
+ const res = await this._api.createGroup(obj.message.name, obj.message.lights || []);
1366
+ this.log.debug('Admin: re-discovering groups after create');
1367
+ await this._discoverGroups();
1368
+ respond({ ok: true, result: res });
1369
+ break;
1370
+ }
1371
+
1372
+ case 'updateGroup': {
1373
+ this.log.info(
1374
+ `Admin: update group command — id=${obj.message.id}, ` +
1375
+ `name="${obj.message.name}", lights=[${(obj.message.lights || []).join(', ')}]`,
1376
+ );
1377
+ await this._api.updateGroup(obj.message.id, obj.message.name, obj.message.lights || []);
1378
+ this.log.debug('Admin: re-discovering groups after update');
1379
+ await this._discoverGroups();
1380
+ respond({ ok: true });
1381
+ break;
1382
+ }
1383
+
1384
+ case 'deleteGroup': {
1385
+ this.log.info(`Admin: delete group command — id=${obj.message.id}`);
1386
+ await this._api.deleteGroup(obj.message.id);
1387
+ this.log.debug('Admin: re-discovering groups after delete');
1388
+ await this._discoverGroups();
1389
+ respond({ ok: true });
1390
+ break;
1391
+ }
1392
+
1393
+ case 'activateScene': {
1394
+ const { groupId, sceneId, sceneName } = obj.message || {};
1395
+ if (!groupId || sceneId === undefined || !sceneName) {
1396
+ respond({ error: 'groupId, sceneId and sceneName are required' });
1397
+ break;
1398
+ }
1399
+ this.log.info(
1400
+ `Admin: activate scene command — group=${groupId}, scene="${sceneName}" (id=${sceneId})`,
1401
+ );
1402
+ await this._recallScene(groupId, sceneId, sceneName);
1403
+ respond({ ok: true });
1404
+ break;
1405
+ }
1406
+
1407
+ default:
1408
+ this.log.warn(`Admin: unknown command "${obj.command}" — no handler registered`);
1409
+ respond({ error: `Unknown command: ${obj.command}` });
1410
+ }
1411
+ } catch (err) {
1412
+ this.log.error(`Admin command "${obj.command}" failed: ${err.message}`);
1413
+ respond({ error: err.message });
1414
+ }
1415
+ }
1416
+
1417
+ // ─────────────────────────────────────────────────────────────────────────
1418
+ // State change handler
1419
+ // ─────────────────────────────────────────────────────────────────────────
1420
+
1421
+ /**
1422
+ * Is called if a subscribed state changes.
1423
+ *
1424
+ * @param {string} id - State ID
1425
+ * @param {ioBroker.State | null | undefined} state - State object
1426
+ */
1427
+ async onStateChange(id, state) {
1428
+ if (!state || state.ack) {
1429
+ return;
1430
+ }
1431
+ if (this._stopped) {
1432
+ this.log.debug(`State change for ${id} ignored — adapter is stopping`);
1433
+ return;
1434
+ }
1435
+
1436
+ // tint.0.lights.3.state.on → parts[2]=lights, [3]=id, [4]=channel, [5]=name
1437
+ const parts = id.split('.');
1438
+ const resource = parts[2];
1439
+ const deconzId = parts[3];
1440
+ const channel = parts[4];
1441
+ const stateName = parts[5];
1442
+
1443
+ this.log.debug(
1444
+ `State change: ${id} = ${JSON.stringify(state.val)} ` +
1445
+ `(resource=${resource}, deconzId=${deconzId}, channel=${channel}, state=${stateName})`,
1446
+ );
1447
+
1448
+ try {
1449
+ if ((resource === 'lights' || resource === 'plugs') && channel === 'state') {
1450
+ await this._handleLightCommand(deconzId, stateName, state.val);
1451
+ } else if (resource === 'covers' && channel === 'state') {
1452
+ await this._handleCoverCommand(deconzId, stateName, state.val);
1453
+ } else if (resource === 'thermostats' && channel === 'state') {
1454
+ await this._handleThermostatCommand(deconzId, stateName, state.val);
1455
+ } else if (resource === 'groups') {
1456
+ if (channel === 'action') {
1457
+ await this._handleGroupCommand(deconzId, stateName, state.val);
1458
+ } else if (channel === 'scenes') {
1459
+ await this._handleSceneCommand(deconzId, stateName, state.val, id);
1460
+ }
1461
+ } else {
1462
+ this.log.debug(`State change for resource "${resource}" not handled`);
1463
+ }
1464
+ } catch (err) {
1465
+ this.log.error(`Command failed for ${id}: ${err.message}`);
1466
+ }
1467
+ }
1468
+
1469
+ /**
1470
+ * Send a command to a single light.
1471
+ *
1472
+ * @param {string} lightId - deCONZ light id
1473
+ * @param {string} stateName - State name e.g. "on", "brightness"
1474
+ * @param {unknown} val - New value
1475
+ */
1476
+ async _handleLightCommand(lightId, stateName, val) {
1477
+ const transitionTime = this.config.transitionTime ?? 4;
1478
+ const name = this._lightMap[lightId] || this._plugMap[lightId] || `id=${lightId}`;
1479
+ let body = {};
1480
+
1481
+ switch (stateName) {
1482
+ case 'on':
1483
+ body = { on: Boolean(val) };
1484
+ this.log.info(`Light ${lightId} ("${name}"): switched ${val ? 'ON' : 'OFF'}`);
1485
+ break;
1486
+ case 'brightness':
1487
+ body = { on: true, bri: percentToBri(Number(val)), transitiontime: transitionTime };
1488
+ this.log.info(
1489
+ `Light ${lightId} ("${name}"): brightness → ${val}% ` +
1490
+ `(bri=${percentToBri(Number(val))}, transition=${transitionTime}×100ms)`,
1491
+ );
1492
+ break;
1493
+ case 'colorTemp':
1494
+ body = { ct: kelvinToMired(Number(val)), transitiontime: transitionTime };
1495
+ this.log.info(
1496
+ `Light ${lightId} ("${name}"): color temperature → ${val}K ` +
1497
+ `(${kelvinToMired(Number(val))} mired)`,
1498
+ );
1499
+ break;
1500
+ case 'hue':
1501
+ body = { hue: Number(val), transitiontime: transitionTime };
1502
+ this.log.debug(`Light ${lightId} ("${name}"): hue → ${val}`);
1503
+ break;
1504
+ case 'saturation':
1505
+ body = { sat: Number(val), transitiontime: transitionTime };
1506
+ this.log.debug(`Light ${lightId} ("${name}"): saturation → ${val}`);
1507
+ break;
1508
+ case 'hex': {
1509
+ const [x, y] = hexToXy(String(val));
1510
+ body = { xy: [x, y], transitiontime: transitionTime };
1511
+ this.log.info(
1512
+ `Light ${lightId} ("${name}"): color → ${val} ` + `(xy=[${x.toFixed(4)}, ${y.toFixed(4)}])`,
1513
+ );
1514
+ break;
1515
+ }
1516
+ case 'x':
1517
+ case 'y': {
1518
+ const xState = await this.getStateAsync(`lights.${lightId}.state.x`);
1519
+ const yState = await this.getStateAsync(`lights.${lightId}.state.y`);
1520
+ const xVal = stateName === 'x' ? Number(val) : (xState?.val ?? 0);
1521
+ const yVal = stateName === 'y' ? Number(val) : (yState?.val ?? 0);
1522
+ body = { xy: [xVal, yVal], transitiontime: transitionTime };
1523
+ this.log.debug(
1524
+ `Light ${lightId} ("${name}"): CIE ${stateName} → ${val} ` +
1525
+ `(xy=[${xVal.toFixed(4)}, ${yVal.toFixed(4)}])`,
1526
+ );
1527
+ break;
1528
+ }
1529
+ case 'effect':
1530
+ body = { effect: String(val) };
1531
+ this.log.info(`Light ${lightId} ("${name}"): effect → "${val}"`);
1532
+ break;
1533
+ case 'effectSpeed':
1534
+ body = { colorspeed: Number(val) };
1535
+ this.log.debug(`Light ${lightId} ("${name}"): effectSpeed → ${val}`);
1536
+ break;
1537
+ default:
1538
+ this.log.warn(`Light ${lightId} ("${name}"): unknown state "${stateName}" — no API call made`);
1539
+ return;
1540
+ }
1541
+
1542
+ this.log.debug(`Light ${lightId} ("${name}"): PUT /lights/${lightId}/state ${JSON.stringify(body)}`);
1543
+ await this._api.setLightState(lightId, body);
1544
+ }
1545
+
1546
+ /**
1547
+ * Send a command to a window covering. Covers are technically /lights
1548
+ * entries in deCONZ, so this reuses setLightState — only the ioBroker
1549
+ * state names and the lift/position conversion differ from real lights.
1550
+ *
1551
+ * @param {string} coverId - deCONZ light id
1552
+ * @param {string} stateName - State name e.g. "position", "stop"
1553
+ * @param {unknown} val - New value
1554
+ */
1555
+ async _handleCoverCommand(coverId, stateName, val) {
1556
+ const name = this._coverMap[coverId] || `id=${coverId}`;
1557
+ let body = {};
1558
+
1559
+ switch (stateName) {
1560
+ case 'position': {
1561
+ const lift = 100 - Number(val);
1562
+ body = { lift };
1563
+ this.log.info(`Cover ${coverId} ("${name}"): position → ${val}% (lift=${lift})`);
1564
+ break;
1565
+ }
1566
+ case 'stop':
1567
+ if (!val) {
1568
+ this.log.debug(`Cover ${coverId} ("${name}"): stop set to false — no action`);
1569
+ return;
1570
+ }
1571
+ body = { stop: true };
1572
+ this.log.info(`Cover ${coverId} ("${name}"): stop`);
1573
+ break;
1574
+ default:
1575
+ this.log.warn(`Cover ${coverId} ("${name}"): unknown state "${stateName}" — no API call made`);
1576
+ return;
1577
+ }
1578
+
1579
+ this.log.debug(`Cover ${coverId} ("${name}"): PUT /lights/${coverId}/state ${JSON.stringify(body)}`);
1580
+ await this._api.setLightState(coverId, body);
1581
+ }
1582
+
1583
+ /**
1584
+ * Send a command to a thermostat. deCONZ exposes the heating setpoint via
1585
+ * config, not state — see DeconzApi.setSensorConfig().
1586
+ *
1587
+ * @param {string} thermostatId - deCONZ sensor id
1588
+ * @param {string} stateName - State name e.g. "setpoint"
1589
+ * @param {unknown} val - New value
1590
+ */
1591
+ async _handleThermostatCommand(thermostatId, stateName, val) {
1592
+ const name = this._thermostatMap[thermostatId] || `id=${thermostatId}`;
1593
+
1594
+ if (stateName !== 'setpoint') {
1595
+ this.log.warn(`Thermostat ${thermostatId} ("${name}"): unknown state "${stateName}" — no API call made`);
1596
+ return;
1597
+ }
1598
+ const heatsetpoint = Math.round(Math.max(5, Math.min(32, Number(val))) * 100);
1599
+ this.log.info(`Thermostat ${thermostatId} ("${name}"): setpoint → ${val}°C (heatsetpoint=${heatsetpoint})`);
1600
+ await this._api.setSensorConfig(thermostatId, { heatsetpoint });
1601
+ }
1602
+
1603
+ /**
1604
+ * Send an action command to a group.
1605
+ *
1606
+ * @param {string} groupId - deCONZ group id
1607
+ * @param {string} stateName - State name e.g. "on", "brightness"
1608
+ * @param {unknown} val - New value
1609
+ */
1610
+ async _handleGroupCommand(groupId, stateName, val) {
1611
+ const transitionTime = this.config.transitionTime ?? 4;
1612
+ const name = this._groupMap[groupId]?.name || `id=${groupId}`;
1613
+ let body = {};
1614
+
1615
+ switch (stateName) {
1616
+ case 'on':
1617
+ body = { on: Boolean(val) };
1618
+ this.log.info(`Group ${groupId} ("${name}"): switched ${val ? 'ON' : 'OFF'}`);
1619
+ break;
1620
+ case 'brightness':
1621
+ body = { on: true, bri: percentToBri(Number(val)), transitiontime: transitionTime };
1622
+ this.log.info(
1623
+ `Group ${groupId} ("${name}"): brightness → ${val}% ` +
1624
+ `(bri=${percentToBri(Number(val))}, transition=${transitionTime}×100ms)`,
1625
+ );
1626
+ break;
1627
+ case 'colorTemp':
1628
+ body = { ct: kelvinToMired(Number(val)), transitiontime: transitionTime };
1629
+ this.log.info(
1630
+ `Group ${groupId} ("${name}"): color temperature → ${val}K ` +
1631
+ `(${kelvinToMired(Number(val))} mired)`,
1632
+ );
1633
+ break;
1634
+ case 'hex': {
1635
+ const [x, y] = hexToXy(String(val));
1636
+ body = { xy: [x, y], transitiontime: transitionTime };
1637
+ this.log.info(
1638
+ `Group ${groupId} ("${name}"): color → ${val} ` + `(xy=[${x.toFixed(4)}, ${y.toFixed(4)}])`,
1639
+ );
1640
+ break;
1641
+ }
1642
+ case 'effect':
1643
+ body = { effect: String(val) };
1644
+ this.log.info(`Group ${groupId} ("${name}"): effect → "${val}"`);
1645
+ break;
1646
+ case 'activateScene': {
1647
+ const sceneMap = this._sceneMap[groupId] || {};
1648
+ const sceneId = sceneMap[String(val)];
1649
+ this.log.debug(
1650
+ `Group ${groupId} ("${name}"): activateScene "${val}" — ` +
1651
+ `sceneId=${sceneId !== undefined ? sceneId : 'NOT FOUND'}, ` +
1652
+ `available: [${Object.keys(sceneMap).join(', ')}]`,
1653
+ );
1654
+ if (sceneId === undefined) {
1655
+ this.log.warn(
1656
+ `Group ${groupId} ("${name}"): scene "${val}" not found — ` +
1657
+ `available scenes: [${Object.keys(sceneMap).join(', ')}]`,
1658
+ );
1659
+ return;
1660
+ }
1661
+ await this._api.recallScene(groupId, sceneId);
1662
+ this.log.info(`Group ${groupId} ("${name}"): recalled scene "${val}" (id=${sceneId})`);
1663
+ return;
1664
+ }
1665
+ default:
1666
+ this.log.warn(`Group ${groupId} ("${name}"): unknown action "${stateName}" — no API call made`);
1667
+ return;
1668
+ }
1669
+
1670
+ this.log.debug(`Group ${groupId} ("${name}"): PUT /groups/${groupId}/action ${JSON.stringify(body)}`);
1671
+ await this._api.setGroupAction(groupId, body);
1672
+ }
1673
+
1674
+ /**
1675
+ * Recall a scene by setting its boolean state to true.
1676
+ *
1677
+ * @param {string} groupId - deCONZ group id
1678
+ * @param {string} safeSceneName - URL-safe scene name (key in state tree)
1679
+ * @param {unknown} val - Must be true to trigger
1680
+ * @param {string} fullId - Full ioBroker state id (used to read native.sceneName)
1681
+ */
1682
+ async _handleSceneCommand(groupId, safeSceneName, val, fullId) {
1683
+ if (!val) {
1684
+ this.log.debug(`Scene state ${fullId} set to false — no action`);
1685
+ return;
1686
+ }
1687
+ const obj = await this.getObjectAsync(fullId);
1688
+ const sceneName = obj?.native?.sceneName || safeSceneName;
1689
+ const sceneMap = this._sceneMap[groupId] || {};
1690
+ const sceneId = sceneMap[sceneName];
1691
+ const groupName = this._groupMap[groupId]?.name || `id=${groupId}`;
1692
+
1693
+ this.log.debug(
1694
+ `Scene trigger: group ${groupId} ("${groupName}"), ` +
1695
+ `sceneName="${sceneName}", sceneId=${sceneId !== undefined ? sceneId : 'NOT FOUND'}, ` +
1696
+ `all scenes: [${Object.keys(sceneMap).join(', ')}]`,
1697
+ );
1698
+
1699
+ if (sceneId === undefined) {
1700
+ this.log.warn(
1701
+ `Scene "${sceneName}" not found in group ${groupId} ("${groupName}") — ` +
1702
+ `available: [${Object.keys(sceneMap).join(', ')}]`,
1703
+ );
1704
+ return;
1705
+ }
1706
+ await this._recallScene(groupId, sceneId, sceneName);
1707
+ }
1708
+
1709
+ /**
1710
+ * Recall a scene on deCONZ and sync the boolean scene states of the group
1711
+ * so exactly the recalled scene's state reads true.
1712
+ *
1713
+ * @param {string} groupId - deCONZ group id
1714
+ * @param {number} sceneId - deCONZ scene id
1715
+ * @param {string} sceneName - Scene name (key in this._sceneMap[groupId])
1716
+ */
1717
+ async _recallScene(groupId, sceneId, sceneName) {
1718
+ await this._api.recallScene(groupId, sceneId);
1719
+ const groupName = this._groupMap[groupId]?.name || `id=${groupId}`;
1720
+ this.log.info(`Group ${groupId} ("${groupName}"): recalled scene "${sceneName}" (id=${sceneId})`);
1721
+ const sceneMap = this._sceneMap[groupId] || {};
1722
+ for (const name of Object.keys(sceneMap)) {
1723
+ const safe = name.replace(/[^a-zA-Z0-9_]/g, '_');
1724
+ await this._set(`groups.${groupId}.scenes.${safe}`, name === sceneName);
1725
+ }
1726
+ }
1727
+
1728
+ // ─────────────────────────────────────────────────────────────────────────
1729
+ // Color wheel auto-apply
1730
+ // ─────────────────────────────────────────────────────────────────────────
1731
+
1732
+ /**
1733
+ * Called by RemoteHandler on color wheel events.
1734
+ * When autoApplyColorWheel is enabled, applies the chosen color to the
1735
+ * active zone's light group.
1736
+ *
1737
+ * @param {string} sensorId - deCONZ sensor id
1738
+ * @param {number} x - CIE x chromaticity
1739
+ * @param {number} y - CIE y chromaticity
1740
+ * @param {string} _hex - Pre-computed hex color (unused here)
1741
+ * @param {number} _angle - Wheel angle (unused here)
1742
+ */
1743
+ async _onColorWheelEvent(sensorId, x, y, _hex, _angle) {
1744
+ if (!this.config.autoApplyColorWheel) {
1745
+ this.log.debug(`Color wheel event from sensor ${sensorId} — autoApplyColorWheel is disabled, skipping`);
1746
+ return;
1747
+ }
1748
+
1749
+ const zoneState = await this.getStateAsync(`remotes.${sensorId}.button.activeZone`);
1750
+ const zone = zoneState?.val ?? 0;
1751
+ const ZONE_GROUPS = { 1: '16388', 2: '16389', 3: '16390' };
1752
+
1753
+ if (zone === 0) {
1754
+ this.log.debug(
1755
+ `Color wheel auto-apply: sensor=${sensorId}, zone=0 (all lights), ` +
1756
+ `xy=[${x.toFixed(4)}, ${y.toFixed(4)}], hex=${_hex}, ` +
1757
+ `applying to ${Object.keys(this._lightMap).length} light(s)`,
1758
+ );
1759
+ for (const lightId of Object.keys(this._lightMap)) {
1760
+ this.log.debug(` → light ${lightId} ("${this._lightMap[lightId]}")`);
1761
+ await this._api
1762
+ .setLightState(lightId, {
1763
+ xy: [x, y],
1764
+ on: true,
1765
+ transitiontime: this.config.transitionTime ?? 4,
1766
+ })
1767
+ .catch(err => {
1768
+ this.log.warn(`Color wheel: failed to apply to light ${lightId}: ${err.message}`);
1769
+ });
1770
+ }
1771
+ } else {
1772
+ const groupNum = ZONE_GROUPS[zone];
1773
+ this.log.debug(
1774
+ `Color wheel auto-apply: sensor=${sensorId}, zone=${zone} → groupId=${groupNum}, ` +
1775
+ `xy=[${x.toFixed(4)}, ${y.toFixed(4)}], hex=${_hex}`,
1776
+ );
1777
+ let applied = false;
1778
+ for (const [groupId] of Object.entries(this._groupMap)) {
1779
+ if (groupNum && groupId === groupNum) {
1780
+ const groupName = this._groupMap[groupId]?.name || groupId;
1781
+ this.log.debug(` → group ${groupId} ("${groupName}")`);
1782
+ await this._api
1783
+ .setGroupAction(groupId, {
1784
+ xy: [x, y],
1785
+ on: true,
1786
+ transitiontime: this.config.transitionTime ?? 4,
1787
+ })
1788
+ .catch(err => {
1789
+ this.log.warn(`Color wheel: failed to apply to group ${groupId}: ${err.message}`);
1790
+ });
1791
+ applied = true;
1792
+ break;
1793
+ }
1794
+ }
1795
+ if (!applied) {
1796
+ this.log.debug(
1797
+ `Color wheel: zone ${zone} maps to groupId=${groupNum} but that group is not in groupMap`,
1798
+ );
1799
+ }
1800
+ }
1801
+ }
1802
+
1803
+ // ─────────────────────────────────────────────────────────────────────────
1804
+ // Fallback polling (setTimeout chain)
1805
+ // ─────────────────────────────────────────────────────────────────────────
1806
+
1807
+ /**
1808
+ * Schedule the next fallback poll using a setTimeout chain.
1809
+ * Never uses setInterval to avoid request pile-up.
1810
+ */
1811
+ _schedulePoll() {
1812
+ if (this._stopped) {
1813
+ this.log.debug('_schedulePoll called after stop — not scheduling');
1814
+ return;
1815
+ }
1816
+ const interval = (this.config.pollingInterval || 60) * 1000;
1817
+ this.log.debug(`Fallback poll scheduled in ${interval / 1000}s`);
1818
+ this._pollTimer = this.setTimeout(async () => {
1819
+ this._pollTimer = null;
1820
+ if (!this._stopped) {
1821
+ await this._pollAll();
1822
+ this._schedulePoll();
1823
+ }
1824
+ }, interval);
1825
+ }
1826
+
1827
+ /**
1828
+ * Poll all lights and groups from deCONZ REST API.
1829
+ */
1830
+ async _pollAll() {
1831
+ const lightCount = Object.keys(this._lightMap).length;
1832
+ const plugCount = Object.keys(this._plugMap).length;
1833
+ const coverCount = Object.keys(this._coverMap).length;
1834
+ const groupCount = Object.keys(this._groupMap).length;
1835
+ const sensorCount = Object.keys(this._sensorMap).length;
1836
+ const thermostatCount = Object.keys(this._thermostatMap).length;
1837
+ this.log.debug(
1838
+ `Fallback poll starting — ${lightCount} light(s), ${plugCount} plug(s), ${coverCount} cover(s), ` +
1839
+ `${groupCount} group(s), ${sensorCount} sensor(s), ${thermostatCount} thermostat(s)`,
1840
+ );
1841
+ try {
1842
+ const lights = await this._api.getLights();
1843
+ let lightUpdated = 0;
1844
+ let plugUpdated = 0;
1845
+ let coverUpdated = 0;
1846
+ for (const [id, light] of Object.entries(lights)) {
1847
+ if (this._lightMap[id]) {
1848
+ await this._updateLightStates(id, light);
1849
+ lightUpdated++;
1850
+ } else if (this._plugMap[id]) {
1851
+ await this._updatePlugStates(id, light);
1852
+ plugUpdated++;
1853
+ } else if (this._coverMap[id]) {
1854
+ await this._updateCoverStates(id, light);
1855
+ coverUpdated++;
1856
+ } else {
1857
+ this.log.debug(
1858
+ `Poll: light ${id} not in lightMap/plugMap/coverMap — skipping (run discovery first)`,
1859
+ );
1860
+ }
1861
+ }
1862
+ const groups = await this._api.getGroups();
1863
+ let groupUpdated = 0;
1864
+ for (const [id, group] of Object.entries(groups)) {
1865
+ if (this._groupMap[id]) {
1866
+ await this._updateGroupStates(id, group);
1867
+ groupUpdated++;
1868
+ } else {
1869
+ this.log.debug(`Poll: group ${id} not in groupMap — skipping (run discovery first)`);
1870
+ }
1871
+ }
1872
+ const sensors = await this._api.getSensors();
1873
+ let sensorUpdated = 0;
1874
+ let thermostatUpdated = 0;
1875
+ for (const [id, sensor] of Object.entries(sensors)) {
1876
+ if (this._sensorMap[id]) {
1877
+ await this._updateSensorStates(id, sensor);
1878
+ sensorUpdated++;
1879
+ } else if (this._thermostatMap[id]) {
1880
+ await this._updateThermostatStates(id, sensor);
1881
+ thermostatUpdated++;
1882
+ }
1883
+ }
1884
+ this.log.debug(
1885
+ `Fallback poll complete — updated ${lightUpdated} light(s), ${plugUpdated} plug(s), ` +
1886
+ `${coverUpdated} cover(s), ${groupUpdated} group(s), ${sensorUpdated} sensor(s), ` +
1887
+ `${thermostatUpdated} thermostat(s)`,
1888
+ );
1889
+ } catch (err) {
1890
+ this.log.warn(
1891
+ `Fallback poll failed: ${err.message} — ` + `will retry in ${this.config.pollingInterval || 60}s`,
1892
+ );
1893
+ }
1894
+ }
1895
+
1896
+ // ─────────────────────────────────────────────────────────────────────────
1897
+ // Utility
1898
+ // ─────────────────────────────────────────────────────────────────────────
1899
+
1900
+ /**
1901
+ * Set a state value with ack:true, swallowing errors gracefully.
1902
+ *
1903
+ * @param {string} id - Full ioBroker state id
1904
+ * @param {unknown} val - Value to write
1905
+ */
1906
+ async _set(id, val) {
1907
+ try {
1908
+ await this.setStateAsync(id, { val: val ?? null, ack: true });
1909
+ } catch (err) {
1910
+ this.log.warn(`setStateAsync ${id} failed: ${err.message}`);
1911
+ }
1912
+ }
1913
+ }
1914
+
1915
+ if (require.main !== module) {
1916
+ // Export the constructor in compact mode
1917
+ /**
1918
+ * @param {Partial<utils.AdapterOptions>} [options] - Adapter options
1919
+ */
1920
+ module.exports = options => new Tint(options);
1921
+ } else {
1922
+ // otherwise start the instance directly
1923
+ new Tint();
1924
+ }