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.
- package/LICENSE +21 -0
- package/README.md +201 -0
- package/admin/build/assets/__virtual_mf___mfe_internal__tintComponents__loadShare__react__loadShare__.js_commonjs-proxy-Cl6Kn7gP.js +9 -0
- package/admin/build/assets/_virtual_mf-localSharedImportMap___mfe_internal__tintComponents-B1A16Tgp.js +1 -0
- package/admin/build/assets/_virtual_mf___mfe_internal__tintComponents__loadShare___mf_0_emotion_mf_1_react__loadShare__.js-C8Vyx7Bj.js +8 -0
- package/admin/build/assets/_virtual_mf___mfe_internal__tintComponents__loadShare___mf_0_emotion_mf_1_styled__loadShare__.js-ByxO1Xun.js +1 -0
- package/admin/build/assets/_virtual_mf___mfe_internal__tintComponents__loadShare___mf_0_mui_mf_1_material__loadShare__.js-Dg1UrPxy.js +248 -0
- package/admin/build/assets/_virtual_mf___mfe_internal__tintComponents__loadShare__react__loadShare__.js-CAwea2Mm.js +1 -0
- package/admin/build/assets/_virtual_mf___mfe_internal__tintComponents__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__.js-B7t36uFG.js +9 -0
- package/admin/build/assets/_virtual_mf___mfe_internal__tintComponents__loadShare__react_mf_2_dom__loadShare__.js-rV2HHyiS.js +24 -0
- package/admin/build/assets/bootstrap-DdKMNh18.js +1 -0
- package/admin/build/assets/hostInit-C5jswnkw.js +1 -0
- package/admin/build/assets/index-C-tjmgJM.js +1 -0
- package/admin/build/assets/preload-helper-BlTxHScW.js +1 -0
- package/admin/build/assets/virtualExposes-Bu4cv-kd.js +1 -0
- package/admin/build/customComponents.js +7 -0
- package/admin/build/customComponents.ssr.js +48 -0
- package/admin/i18n/de.json +35 -0
- package/admin/i18n/en.json +35 -0
- package/admin/i18n/es.json +35 -0
- package/admin/i18n/fr.json +35 -0
- package/admin/i18n/it.json +35 -0
- package/admin/i18n/nl.json +35 -0
- package/admin/i18n/pl.json +35 -0
- package/admin/i18n/pt.json +35 -0
- package/admin/i18n/ru.json +35 -0
- package/admin/i18n/uk.json +35 -0
- package/admin/i18n/zh-cn.json +35 -0
- package/admin/jsonConfig.json +329 -0
- package/admin/tint.png +0 -0
- package/io-package.json +227 -0
- package/lib/adapter-config.d.ts +20 -0
- package/lib/admin-projections.js +61 -0
- package/lib/color-utils.js +230 -0
- package/lib/deconz-api.js +379 -0
- package/lib/deconz-ws.js +151 -0
- package/lib/device-category.js +42 -0
- package/lib/objects.js +1002 -0
- package/lib/remote-handler.js +218 -0
- package/main.js +1924 -0
- 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
|
+
}
|