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
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const axios = require('axios');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* deCONZ REST API wrapper.
|
|
7
|
+
* Handles all HTTP communication with the deCONZ gateway.
|
|
8
|
+
*/
|
|
9
|
+
class DeconzApi {
|
|
10
|
+
/**
|
|
11
|
+
* @param {object} options - Connection options
|
|
12
|
+
* @param {string} options.ip - IP address of the deCONZ host
|
|
13
|
+
* @param {number} options.port - REST API port (default 80)
|
|
14
|
+
* @param {string} options.apiKey - deCONZ API key
|
|
15
|
+
* @param {object} options.log - ioBroker logger instance
|
|
16
|
+
*/
|
|
17
|
+
constructor(options) {
|
|
18
|
+
this.ip = options.ip;
|
|
19
|
+
this.port = options.port;
|
|
20
|
+
this.apiKey = options.apiKey;
|
|
21
|
+
this.log = options.log;
|
|
22
|
+
|
|
23
|
+
this._client = axios.create({
|
|
24
|
+
baseURL: `http://${this.ip}:${this.port}/api/${this.apiKey}`,
|
|
25
|
+
timeout: 10000,
|
|
26
|
+
headers: { 'Content-Type': 'application/json' },
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
this.log.debug(
|
|
30
|
+
`DeconzApi initialised — endpoint: http://${this.ip}:${this.port}/api/<key>, timeout: 10 000 ms`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ─── Generic request helpers ─────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Perform a GET request against the deCONZ REST API.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} path - API path relative to /api/{key}
|
|
40
|
+
* @returns {Promise<object>} Parsed response body
|
|
41
|
+
*/
|
|
42
|
+
async _get(path) {
|
|
43
|
+
this.log.debug(`→ GET ${path}`);
|
|
44
|
+
try {
|
|
45
|
+
const res = await this._client.get(path);
|
|
46
|
+
this.log.debug(`← GET ${path} [${res.status}] ${JSON.stringify(res.data).length} B`);
|
|
47
|
+
return res.data;
|
|
48
|
+
} catch (err) {
|
|
49
|
+
this.log.error(`GET ${path} failed: ${err.message}`);
|
|
50
|
+
throw err;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Perform a PUT request against the deCONZ REST API.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} path - API path relative to /api/{key}
|
|
58
|
+
* @param {object} body - Request body
|
|
59
|
+
* @returns {Promise<object>} Parsed response body
|
|
60
|
+
*/
|
|
61
|
+
async _put(path, body) {
|
|
62
|
+
this.log.debug(`→ PUT ${path} body=${JSON.stringify(body)}`);
|
|
63
|
+
try {
|
|
64
|
+
const res = await this._client.put(path, body);
|
|
65
|
+
this.log.debug(`← PUT ${path} [${res.status}] ${JSON.stringify(res.data)}`);
|
|
66
|
+
return res.data;
|
|
67
|
+
} catch (err) {
|
|
68
|
+
this.log.error(`PUT ${path} failed: ${err.message}`);
|
|
69
|
+
throw err;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Perform a POST request against the deCONZ REST API.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} path - API path relative to /api/{key}
|
|
77
|
+
* @param {object} body - Request body
|
|
78
|
+
* @returns {Promise<object>} Parsed response body
|
|
79
|
+
*/
|
|
80
|
+
async _post(path, body) {
|
|
81
|
+
this.log.debug(`→ POST ${path} body=${JSON.stringify(body)}`);
|
|
82
|
+
try {
|
|
83
|
+
const res = await this._client.post(path, body);
|
|
84
|
+
this.log.debug(`← POST ${path} [${res.status}] ${JSON.stringify(res.data)}`);
|
|
85
|
+
return res.data;
|
|
86
|
+
} catch (err) {
|
|
87
|
+
this.log.error(`POST ${path} failed: ${err.message}`);
|
|
88
|
+
throw err;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Perform a DELETE request against the deCONZ REST API.
|
|
94
|
+
*
|
|
95
|
+
* @param {string} path - API path relative to /api/{key}
|
|
96
|
+
* @returns {Promise<object>} Parsed response body
|
|
97
|
+
*/
|
|
98
|
+
async _delete(path) {
|
|
99
|
+
this.log.debug(`→ DELETE ${path}`);
|
|
100
|
+
try {
|
|
101
|
+
const res = await this._client.delete(path);
|
|
102
|
+
this.log.debug(`← DELETE ${path} [${res.status}] ${JSON.stringify(res.data)}`);
|
|
103
|
+
return res.data;
|
|
104
|
+
} catch (err) {
|
|
105
|
+
this.log.error(`DELETE ${path} failed: ${err.message}`);
|
|
106
|
+
throw err;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ─── Connection test ──────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Fetch the gateway configuration (GET /config).
|
|
114
|
+
*
|
|
115
|
+
* @returns {Promise<object|null>} Gateway config, or null on error
|
|
116
|
+
*/
|
|
117
|
+
async getConfig() {
|
|
118
|
+
this.log.debug('Fetching deCONZ gateway config (GET /config)');
|
|
119
|
+
try {
|
|
120
|
+
return await this._get('/config');
|
|
121
|
+
} catch {
|
|
122
|
+
// error already logged by _get()
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Verify API key is valid by fetching the gateway config.
|
|
129
|
+
*
|
|
130
|
+
* @returns {Promise<boolean>} True if connection succeeded
|
|
131
|
+
*/
|
|
132
|
+
async testConnection() {
|
|
133
|
+
const data = await this.getConfig();
|
|
134
|
+
if (data && data.name) {
|
|
135
|
+
this.log.info(
|
|
136
|
+
`Gateway info — name: "${data.name}", firmware: ${data.swversion || 'n/a'}, ` +
|
|
137
|
+
`model: ${data.modelid || 'unknown'}, api: v${data.apiversion || 'n/a'}`,
|
|
138
|
+
);
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
this.log.error('Connection test failed — /config response is missing the "name" field');
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ─── Lights ───────────────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Get all lights.
|
|
149
|
+
*
|
|
150
|
+
* @returns {Promise<object>} Map of id → light object
|
|
151
|
+
*/
|
|
152
|
+
async getLights() {
|
|
153
|
+
this.log.debug('getLights()');
|
|
154
|
+
const lights = await this._get('/lights');
|
|
155
|
+
this.log.debug(`getLights() → ${Object.keys(lights || {}).length} light(s)`);
|
|
156
|
+
return lights;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Get single light by id.
|
|
161
|
+
*
|
|
162
|
+
* @param {string} lightId - deCONZ light id
|
|
163
|
+
* @returns {Promise<object>} Light object
|
|
164
|
+
*/
|
|
165
|
+
async getLight(lightId) {
|
|
166
|
+
this.log.debug(`getLight(${lightId})`);
|
|
167
|
+
return await this._get(`/lights/${lightId}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Set light state.
|
|
172
|
+
*
|
|
173
|
+
* @param {string} lightId - deCONZ light id
|
|
174
|
+
* @param {object} state - State to set e.g. { on: true, bri: 200, ct: 370 }
|
|
175
|
+
* @returns {Promise<Array>} deCONZ response array
|
|
176
|
+
*/
|
|
177
|
+
async setLightState(lightId, state) {
|
|
178
|
+
this.log.debug(`setLightState(${lightId}) body=${JSON.stringify(state)}`);
|
|
179
|
+
return await this._put(`/lights/${lightId}/state`, state);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ─── Groups ───────────────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Get all groups.
|
|
186
|
+
*
|
|
187
|
+
* @returns {Promise<object>} Map of id → group object
|
|
188
|
+
*/
|
|
189
|
+
async getGroups() {
|
|
190
|
+
this.log.debug('getGroups()');
|
|
191
|
+
const groups = await this._get('/groups');
|
|
192
|
+
this.log.debug(`getGroups() → ${Object.keys(groups || {}).length} group(s)`);
|
|
193
|
+
return groups;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Get single group including its scenes.
|
|
198
|
+
*
|
|
199
|
+
* @param {string} groupId - deCONZ group id
|
|
200
|
+
* @returns {Promise<object>} Group object with scenes array
|
|
201
|
+
*/
|
|
202
|
+
async getGroup(groupId) {
|
|
203
|
+
this.log.debug(`getGroup(${groupId})`);
|
|
204
|
+
return await this._get(`/groups/${groupId}`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Set group action — affects all member lights simultaneously.
|
|
209
|
+
*
|
|
210
|
+
* @param {string} groupId - deCONZ group id
|
|
211
|
+
* @param {object} action - Action to apply e.g. { on: true, bri: 200, ct: 370 }
|
|
212
|
+
* @returns {Promise<Array>} deCONZ response array
|
|
213
|
+
*/
|
|
214
|
+
async setGroupAction(groupId, action) {
|
|
215
|
+
this.log.debug(`setGroupAction(${groupId}) body=${JSON.stringify(action)}`);
|
|
216
|
+
return await this._put(`/groups/${groupId}/action`, action);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Recall a scene within a group.
|
|
221
|
+
*
|
|
222
|
+
* @param {string} groupId - deCONZ group id
|
|
223
|
+
* @param {string} sceneId - deCONZ scene id within the group
|
|
224
|
+
* @returns {Promise<Array>} deCONZ response array
|
|
225
|
+
*/
|
|
226
|
+
async recallScene(groupId, sceneId) {
|
|
227
|
+
this.log.debug(`recallScene(groupId=${groupId}, sceneId=${sceneId})`);
|
|
228
|
+
return await this._put(`/groups/${groupId}/action`, { scene: sceneId });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Create a new group.
|
|
233
|
+
*
|
|
234
|
+
* @param {string} name - Group name
|
|
235
|
+
* @param {string[]} lights - Array of deCONZ light ids
|
|
236
|
+
* @returns {Promise<object>} deCONZ response (contains id of new group)
|
|
237
|
+
*/
|
|
238
|
+
async createGroup(name, lights) {
|
|
239
|
+
this.log.info(`Creating group "${name}" with ${lights.length} member(s): [${lights.join(', ')}]`);
|
|
240
|
+
const res = await this._post('/groups', { name, lights });
|
|
241
|
+
const newId = Array.isArray(res) && res[0]?.success?.id;
|
|
242
|
+
if (newId) {
|
|
243
|
+
this.log.info(`Group created — id: ${newId}, name: "${name}"`);
|
|
244
|
+
} else {
|
|
245
|
+
this.log.warn(`Group creation for "${name}" returned unexpected response: ${JSON.stringify(res)}`);
|
|
246
|
+
}
|
|
247
|
+
return res;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Update an existing group's name and/or member lights.
|
|
252
|
+
*
|
|
253
|
+
* @param {string} groupId - deCONZ group id
|
|
254
|
+
* @param {string} name - New group name
|
|
255
|
+
* @param {string[]} lights - New list of deCONZ light ids
|
|
256
|
+
* @returns {Promise<Array>} deCONZ response array
|
|
257
|
+
*/
|
|
258
|
+
async updateGroup(groupId, name, lights) {
|
|
259
|
+
this.log.info(`Updating group ${groupId}: name="${name}", members=[${lights.join(', ')}]`);
|
|
260
|
+
const res = await this._put(`/groups/${groupId}`, { name, lights });
|
|
261
|
+
this.log.info(`Group ${groupId} updated successfully`);
|
|
262
|
+
return res;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Delete a group.
|
|
267
|
+
*
|
|
268
|
+
* @param {string} groupId - deCONZ group id
|
|
269
|
+
* @returns {Promise<Array>} deCONZ response array
|
|
270
|
+
*/
|
|
271
|
+
async deleteGroup(groupId) {
|
|
272
|
+
this.log.info(`Deleting group ${groupId}`);
|
|
273
|
+
const res = await this._delete(`/groups/${groupId}`);
|
|
274
|
+
this.log.info(`Group ${groupId} deleted`);
|
|
275
|
+
return res;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ─── Sensors (Remotes) ────────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Get all sensors (includes Tint remotes).
|
|
282
|
+
*
|
|
283
|
+
* @returns {Promise<object>} Map of id → sensor object
|
|
284
|
+
*/
|
|
285
|
+
async getSensors() {
|
|
286
|
+
this.log.debug('getSensors()');
|
|
287
|
+
const sensors = await this._get('/sensors');
|
|
288
|
+
this.log.debug(`getSensors() → ${Object.keys(sensors || {}).length} sensor(s)`);
|
|
289
|
+
return sensors;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Get single sensor by id.
|
|
294
|
+
*
|
|
295
|
+
* @param {string} sensorId - deCONZ sensor id
|
|
296
|
+
* @returns {Promise<object>} Sensor object
|
|
297
|
+
*/
|
|
298
|
+
async getSensor(sensorId) {
|
|
299
|
+
this.log.debug(`getSensor(${sensorId})`);
|
|
300
|
+
return await this._get(`/sensors/${sensorId}`);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Update a sensor's config. Used for thermostats: deCONZ exposes the
|
|
305
|
+
* heating setpoint via config.heatsetpoint, not via state — writing it
|
|
306
|
+
* through /sensors/{id}/state (like a light) would silently no-op.
|
|
307
|
+
*
|
|
308
|
+
* @param {string} sensorId - deCONZ sensor id
|
|
309
|
+
* @param {object} config - Config fields to update e.g. { heatsetpoint: 2150 }
|
|
310
|
+
* @returns {Promise<Array>} deCONZ response array
|
|
311
|
+
*/
|
|
312
|
+
async setSensorConfig(sensorId, config) {
|
|
313
|
+
this.log.debug(`setSensorConfig(${sensorId}) body=${JSON.stringify(config)}`);
|
|
314
|
+
return await this._put(`/sensors/${sensorId}/config`, config);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ─── Pairing ─────────────────────────────────────────────────────────────
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Request a new API key from deCONZ while the pairing window is open.
|
|
321
|
+
* The user must first open Phoscon → ☰ → Gateway → Authenticate app.
|
|
322
|
+
*
|
|
323
|
+
* @param {string} ip - deCONZ host IP
|
|
324
|
+
* @param {number} port - deCONZ REST port
|
|
325
|
+
* @param {object|null} [log] - Optional ioBroker logger for debug output
|
|
326
|
+
* @returns {Promise<string|null>} The new API key (username), or null if window not open yet
|
|
327
|
+
*/
|
|
328
|
+
static async pair(ip, port, log = null) {
|
|
329
|
+
if (log) {
|
|
330
|
+
log.debug(`Pairing: POST http://${ip}:${port}/api body={"devicetype":"ioBroker.tint"}`);
|
|
331
|
+
}
|
|
332
|
+
let body;
|
|
333
|
+
try {
|
|
334
|
+
const res = await axios.post(
|
|
335
|
+
`http://${ip}:${port}/api`,
|
|
336
|
+
{ devicetype: 'ioBroker.tint' },
|
|
337
|
+
{ timeout: 8000, headers: { 'Content-Type': 'application/json' } },
|
|
338
|
+
);
|
|
339
|
+
body = res.data;
|
|
340
|
+
if (log) {
|
|
341
|
+
log.debug(`Pairing: HTTP ${res.status} response — ${JSON.stringify(body)}`);
|
|
342
|
+
}
|
|
343
|
+
} catch (err) {
|
|
344
|
+
// deCONZ may return error details even on non-2xx status
|
|
345
|
+
if (err.response?.data) {
|
|
346
|
+
body = err.response.data;
|
|
347
|
+
if (log) {
|
|
348
|
+
log.debug(`Pairing: HTTP ${err.response.status} non-2xx — body: ${JSON.stringify(body)}`);
|
|
349
|
+
}
|
|
350
|
+
} else {
|
|
351
|
+
if (log) {
|
|
352
|
+
log.error(`Pairing: request failed — ${err.message}`);
|
|
353
|
+
}
|
|
354
|
+
throw new Error(err.message);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (Array.isArray(body) && body[0]?.success?.username) {
|
|
359
|
+
const username = body[0].success.username;
|
|
360
|
+
if (log) {
|
|
361
|
+
log.info(`Pairing successful — API key received (${username.length} chars)`);
|
|
362
|
+
}
|
|
363
|
+
return username;
|
|
364
|
+
}
|
|
365
|
+
if (Array.isArray(body) && body[0]?.error) {
|
|
366
|
+
const { type, description } = body[0].error;
|
|
367
|
+
if (type === 101) {
|
|
368
|
+
if (log) {
|
|
369
|
+
log.debug('Pairing: window not yet open (deCONZ error type 101) — will retry');
|
|
370
|
+
}
|
|
371
|
+
return null; // caller should retry
|
|
372
|
+
}
|
|
373
|
+
throw new Error(description || `deCONZ error type ${type}`);
|
|
374
|
+
}
|
|
375
|
+
throw new Error(`Unexpected response from deCONZ: ${JSON.stringify(body)}`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
module.exports = DeconzApi;
|
package/lib/deconz-ws.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const WebSocket = require('ws');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* deCONZ WebSocket event handler.
|
|
7
|
+
* Receives real-time push events for lights, groups and sensors.
|
|
8
|
+
*/
|
|
9
|
+
class DeconzWebSocket {
|
|
10
|
+
/**
|
|
11
|
+
* @param {object} options - Connection options
|
|
12
|
+
* @param {string} options.ip - IP address of the deCONZ host
|
|
13
|
+
* @param {number} options.wsPort - WebSocket port (default 443)
|
|
14
|
+
* @param {object} options.log - ioBroker logger instance
|
|
15
|
+
* @param {(event: object) => void} options.onEvent - Callback for incoming WebSocket events
|
|
16
|
+
* @param {() => void} options.onOpen - Callback when connection is established
|
|
17
|
+
* @param {() => void} options.onClose - Callback when connection is lost
|
|
18
|
+
*/
|
|
19
|
+
constructor(options) {
|
|
20
|
+
this.ip = options.ip;
|
|
21
|
+
this.wsPort = options.wsPort;
|
|
22
|
+
this.log = options.log;
|
|
23
|
+
this.onEvent = options.onEvent;
|
|
24
|
+
this.onOpen = options.onOpen;
|
|
25
|
+
this.onClose = options.onClose;
|
|
26
|
+
|
|
27
|
+
this._ws = null;
|
|
28
|
+
this._reconnectTimer = null;
|
|
29
|
+
this._stopped = false;
|
|
30
|
+
this._reconnectDelay = 5000;
|
|
31
|
+
|
|
32
|
+
this.log.debug(`DeconzWebSocket initialised — endpoint: ws://${this.ip}:${this.wsPort}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ─── Lifecycle ────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Open the WebSocket connection to deCONZ.
|
|
39
|
+
* Automatically schedules reconnect on failure or close.
|
|
40
|
+
*/
|
|
41
|
+
connect() {
|
|
42
|
+
if (this._stopped) {
|
|
43
|
+
this.log.debug('WebSocket connect() called after stop — ignoring');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const url = `ws://${this.ip}:${this.wsPort}`;
|
|
48
|
+
this.log.debug(`WebSocket: connecting to ${url}`);
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
this._ws = new WebSocket(url);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
this.log.error(`WebSocket: constructor failed for ${url} — ${err.message}`);
|
|
54
|
+
this._scheduleReconnect();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
this._ws.on('open', () => {
|
|
59
|
+
this.log.info(`WebSocket: connected to deCONZ at ws://${this.ip}:${this.wsPort}`);
|
|
60
|
+
this.log.debug('WebSocket: resetting reconnect back-off to 5 s');
|
|
61
|
+
this._reconnectDelay = 5000;
|
|
62
|
+
if (this.onOpen) {
|
|
63
|
+
this.onOpen();
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
this._ws.on('message', data => {
|
|
68
|
+
let event;
|
|
69
|
+
try {
|
|
70
|
+
event = JSON.parse(data.toString());
|
|
71
|
+
} catch (err) {
|
|
72
|
+
const raw = data.toString().slice(0, 200);
|
|
73
|
+
this.log.warn(`WebSocket: message parse error — ${err.message} raw: "${raw}"`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
this.log.debug(
|
|
78
|
+
`WebSocket ← e="${event.e}" r="${event.r}" id="${event.id}"${event.t ? ` t="${event.t}"` : ''}${
|
|
79
|
+
event.state ? ` state=${JSON.stringify(event.state)}` : ''
|
|
80
|
+
}${
|
|
81
|
+
event.action ? ` action=${JSON.stringify(event.action)}` : ''
|
|
82
|
+
}${event.attr ? ` attr=${JSON.stringify(event.attr)}` : ''}`,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
if (this.onEvent) {
|
|
86
|
+
this.onEvent(event);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
this._ws.on('error', err => {
|
|
91
|
+
this.log.error(`WebSocket: error — ${err.message}`);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
this._ws.on('close', (code, reason) => {
|
|
95
|
+
const reasonStr = reason ? reason.toString() : 'no reason given';
|
|
96
|
+
this.log.warn(`WebSocket: closed — code=${code}, reason="${reasonStr}"`);
|
|
97
|
+
if (this.onClose) {
|
|
98
|
+
this.onClose();
|
|
99
|
+
}
|
|
100
|
+
this._scheduleReconnect();
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Close the WebSocket connection and cancel any pending reconnect timer.
|
|
106
|
+
*/
|
|
107
|
+
close() {
|
|
108
|
+
this.log.info('WebSocket: adapter is stopping — closing connection and cancelling reconnects');
|
|
109
|
+
this._stopped = true;
|
|
110
|
+
if (this._reconnectTimer) {
|
|
111
|
+
this.log.debug('WebSocket: clearing pending reconnect timer');
|
|
112
|
+
clearTimeout(this._reconnectTimer);
|
|
113
|
+
this._reconnectTimer = null;
|
|
114
|
+
}
|
|
115
|
+
if (this._ws) {
|
|
116
|
+
this.log.debug('WebSocket: terminating socket');
|
|
117
|
+
try {
|
|
118
|
+
this._ws.terminate();
|
|
119
|
+
} catch {
|
|
120
|
+
/* ignore */
|
|
121
|
+
}
|
|
122
|
+
this._ws = null;
|
|
123
|
+
}
|
|
124
|
+
this.log.debug('WebSocket: closed cleanly');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ─── Reconnect with exponential back-off (max 60s) ───────────────────────
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Schedule a reconnect attempt with exponential back-off (5s → 10s → … → 60s).
|
|
131
|
+
*/
|
|
132
|
+
_scheduleReconnect() {
|
|
133
|
+
if (this._stopped) {
|
|
134
|
+
this.log.debug('WebSocket: _scheduleReconnect called after stop — ignoring');
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const delaySec = this._reconnectDelay / 1000;
|
|
138
|
+
this.log.info(`WebSocket: reconnecting in ${delaySec} s…`);
|
|
139
|
+
this._reconnectTimer = setTimeout(() => {
|
|
140
|
+
this._reconnectTimer = null;
|
|
141
|
+
this.log.debug('WebSocket: reconnect timer fired — calling connect()');
|
|
142
|
+
this.connect();
|
|
143
|
+
}, this._reconnectDelay);
|
|
144
|
+
// Exponential back-off: 5s → 10s → 20s → 40s → 60s
|
|
145
|
+
const nextDelay = Math.min(this._reconnectDelay * 2, 60000);
|
|
146
|
+
this.log.debug(`WebSocket: back-off updated ${this._reconnectDelay / 1000}s → ${nextDelay / 1000}s`);
|
|
147
|
+
this._reconnectDelay = nextDelay;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
module.exports = DeconzWebSocket;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pragmatic heuristic to tell smart plugs/switches apart from real lights,
|
|
5
|
+
* based on deCONZ's "type" field (e.g. "On/Off plug-in unit" vs.
|
|
6
|
+
* "Extended color light"). Mirrors admin/src/components/deviceCategory.js —
|
|
7
|
+
* not a full vendor/model database like Phoscon's, but good enough for the
|
|
8
|
+
* common case.
|
|
9
|
+
*
|
|
10
|
+
* @param {object} light - Raw deCONZ light object
|
|
11
|
+
* @returns {boolean} True if the device looks like a plug/switch, not a light
|
|
12
|
+
*/
|
|
13
|
+
function isPlug(light) {
|
|
14
|
+
return (light?.type || '').toLowerCase().includes('plug');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* deCONZ reports window covering motors (shutters/blinds) under the same
|
|
19
|
+
* /lights endpoint as real lights, with type "Window covering device".
|
|
20
|
+
*
|
|
21
|
+
* @param {object} light - Raw deCONZ light object
|
|
22
|
+
* @returns {boolean} True if the device is a window covering motor
|
|
23
|
+
*/
|
|
24
|
+
function isCover(light) {
|
|
25
|
+
return (light?.type || '').toLowerCase().includes('window covering');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Tint remote controls are reported via /sensors with a type containing
|
|
30
|
+
* "Switch" (deCONZ type "ZHASwitch"), same as generic Zigbee wall switches.
|
|
31
|
+
* Müller Licht ("MLI") is the manufacturer code used for Tint remotes —
|
|
32
|
+
* everything else with a "Switch" type is treated as a generic switch
|
|
33
|
+
* instead of being run through the Tint-specific RemoteHandler.
|
|
34
|
+
*
|
|
35
|
+
* @param {object} sensor - Raw deCONZ sensor object
|
|
36
|
+
* @returns {boolean} True if this looks like a Tint remote control
|
|
37
|
+
*/
|
|
38
|
+
function isTintRemote(sensor) {
|
|
39
|
+
return Boolean(sensor?.type && sensor.type.includes('Switch') && sensor.manufacturername === 'MLI');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = { isPlug, isCover, isTintRemote };
|