iobroker.tint 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +201 -0
  3. package/admin/build/assets/__virtual_mf___mfe_internal__tintComponents__loadShare__react__loadShare__.js_commonjs-proxy-Cl6Kn7gP.js +9 -0
  4. package/admin/build/assets/_virtual_mf-localSharedImportMap___mfe_internal__tintComponents-B1A16Tgp.js +1 -0
  5. package/admin/build/assets/_virtual_mf___mfe_internal__tintComponents__loadShare___mf_0_emotion_mf_1_react__loadShare__.js-C8Vyx7Bj.js +8 -0
  6. package/admin/build/assets/_virtual_mf___mfe_internal__tintComponents__loadShare___mf_0_emotion_mf_1_styled__loadShare__.js-ByxO1Xun.js +1 -0
  7. package/admin/build/assets/_virtual_mf___mfe_internal__tintComponents__loadShare___mf_0_mui_mf_1_material__loadShare__.js-Dg1UrPxy.js +248 -0
  8. package/admin/build/assets/_virtual_mf___mfe_internal__tintComponents__loadShare__react__loadShare__.js-CAwea2Mm.js +1 -0
  9. package/admin/build/assets/_virtual_mf___mfe_internal__tintComponents__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__.js-B7t36uFG.js +9 -0
  10. package/admin/build/assets/_virtual_mf___mfe_internal__tintComponents__loadShare__react_mf_2_dom__loadShare__.js-rV2HHyiS.js +24 -0
  11. package/admin/build/assets/bootstrap-DdKMNh18.js +1 -0
  12. package/admin/build/assets/hostInit-C5jswnkw.js +1 -0
  13. package/admin/build/assets/index-C-tjmgJM.js +1 -0
  14. package/admin/build/assets/preload-helper-BlTxHScW.js +1 -0
  15. package/admin/build/assets/virtualExposes-Bu4cv-kd.js +1 -0
  16. package/admin/build/customComponents.js +7 -0
  17. package/admin/build/customComponents.ssr.js +48 -0
  18. package/admin/i18n/de.json +35 -0
  19. package/admin/i18n/en.json +35 -0
  20. package/admin/i18n/es.json +35 -0
  21. package/admin/i18n/fr.json +35 -0
  22. package/admin/i18n/it.json +35 -0
  23. package/admin/i18n/nl.json +35 -0
  24. package/admin/i18n/pl.json +35 -0
  25. package/admin/i18n/pt.json +35 -0
  26. package/admin/i18n/ru.json +35 -0
  27. package/admin/i18n/uk.json +35 -0
  28. package/admin/i18n/zh-cn.json +35 -0
  29. package/admin/jsonConfig.json +329 -0
  30. package/admin/tint.png +0 -0
  31. package/io-package.json +227 -0
  32. package/lib/adapter-config.d.ts +20 -0
  33. package/lib/admin-projections.js +61 -0
  34. package/lib/color-utils.js +230 -0
  35. package/lib/deconz-api.js +379 -0
  36. package/lib/deconz-ws.js +151 -0
  37. package/lib/device-category.js +42 -0
  38. package/lib/objects.js +1002 -0
  39. package/lib/remote-handler.js +218 -0
  40. package/main.js +1924 -0
  41. package/package.json +84 -0
@@ -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;
@@ -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 };