smart-home-engine 1.20.1 → 1.20.3
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/dist/web/assets/{index-CjRDTePT.css → index-B4M4MeEk.css} +1 -1
- package/dist/web/assets/{index-DxR31sxM.js → index-BbXC7eVn.js} +62 -62
- package/dist/web/assets/{tsMode-BNcOSQVq.js → tsMode-DBmTyMNv.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/src/index.js +22 -0
- package/src/lib/redis.js +7 -0
- package/src/lib/state-store.js +16 -0
- package/src/matter/controller.js +145 -5
- package/src/web/matter-api.js +19 -0
- package/src/web/mqtt-api.js +8 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{m as O}from"./monaco-langs-Dt8a9eeo.js";import{t as I}from"./index-
|
|
1
|
+
import{m as O}from"./monaco-langs-Dt8a9eeo.js";import{t as I}from"./index-BbXC7eVn.js";/*!-----------------------------------------------------------------------------
|
|
2
2
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
3
3
|
* Version: 0.52.2(404545bded1df6ffa41ea0af4e8ddb219018c6c1)
|
|
4
4
|
* Released under the MIT license
|
package/dist/web/index.html
CHANGED
|
@@ -172,10 +172,10 @@
|
|
|
172
172
|
}
|
|
173
173
|
})();
|
|
174
174
|
</script>
|
|
175
|
-
<script type="module" crossorigin src="/assets/index-
|
|
175
|
+
<script type="module" crossorigin src="/assets/index-BbXC7eVn.js"></script>
|
|
176
176
|
<link rel="modulepreload" crossorigin href="/assets/monaco-langs-Dt8a9eeo.js">
|
|
177
177
|
<link rel="stylesheet" crossorigin href="/assets/monaco-langs-DyX1CsEw.css">
|
|
178
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
178
|
+
<link rel="stylesheet" crossorigin href="/assets/index-B4M4MeEk.css">
|
|
179
179
|
</head>
|
|
180
180
|
<body>
|
|
181
181
|
<div id="app"></div>
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -498,6 +498,28 @@ if (config.url) {
|
|
|
498
498
|
|
|
499
499
|
if (shedb.handleMqttMessage(topic, payload)) return;
|
|
500
500
|
|
|
501
|
+
// An empty payload deletes the topic from the state store (mqtt-smarthome
|
|
502
|
+
// convention; also how a retained message is cleared per MQTT spec). The
|
|
503
|
+
// retain flag cannot be used to detect this: MQTT 3.1.1 brokers clear it
|
|
504
|
+
// when forwarding to established subscriptions, so a retained-clear
|
|
505
|
+
// arrives here as a plain empty message. Previously an empty payload was
|
|
506
|
+
// stored as { val: NaN }, so the MQTT tab kept showing cleared topics.
|
|
507
|
+
// Subscription callbacks are still dispatched (empty publishes are also
|
|
508
|
+
// used as plain triggers) with val '' — only the stored state is dropped.
|
|
509
|
+
if (payload.length === 0) {
|
|
510
|
+
const emptyOld = store.getObject('mqtt::' + topic) || {};
|
|
511
|
+
if (store.delete('mqtt::' + topic)) {
|
|
512
|
+
log.debug('empty payload, removed topic from state store:', topic);
|
|
513
|
+
}
|
|
514
|
+
const delArr = topic.split('/');
|
|
515
|
+
if (delArr[0] === config.variablePrefix && delArr[1] === 'status') {
|
|
516
|
+
store.delete('var::' + delArr.slice(2).join('/'));
|
|
517
|
+
}
|
|
518
|
+
const ts = new Date().getTime();
|
|
519
|
+
stateChange(topic, { val: '', ts, lc: ts }, emptyOld, msg);
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
|
|
501
523
|
const state = require('./lib/parse-payload')(payload);
|
|
502
524
|
|
|
503
525
|
const topicArr = topic.split('/');
|
package/src/lib/redis.js
CHANGED
|
@@ -63,6 +63,13 @@ async function init({ url, store, log }) {
|
|
|
63
63
|
});
|
|
64
64
|
});
|
|
65
65
|
|
|
66
|
+
// Write-through: StateStore deletions (e.g. cleared retained messages) → hdel
|
|
67
|
+
store.on('delete', (key) => {
|
|
68
|
+
client.hdel('she:state', key).catch((err) => {
|
|
69
|
+
log.error('redis: hdel failed:', err.message);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
66
73
|
log.info('redis: connected', url);
|
|
67
74
|
}
|
|
68
75
|
|
package/src/lib/state-store.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Events:
|
|
12
12
|
* 'change' (key, val, obj, prevObj)
|
|
13
|
+
* 'delete' (key, prevObj)
|
|
13
14
|
*/
|
|
14
15
|
|
|
15
16
|
const { EventEmitter } = require('events');
|
|
@@ -47,6 +48,21 @@ class StateStore extends EventEmitter {
|
|
|
47
48
|
this.emit('change', key, obj.val, obj, prev);
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Remove a key from the store (e.g. when a retained MQTT message is
|
|
53
|
+
* deleted via an empty retained publish). Emits 'delete' (key, prevObj)
|
|
54
|
+
* when the key existed; a no-op otherwise.
|
|
55
|
+
* @param {string} key
|
|
56
|
+
* @returns {boolean} true when the key existed and was removed
|
|
57
|
+
*/
|
|
58
|
+
delete(key) {
|
|
59
|
+
const prev = this._map.get(key);
|
|
60
|
+
if (prev === undefined) return false;
|
|
61
|
+
this._map.delete(key);
|
|
62
|
+
this.emit('delete', key, prev);
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
|
|
50
66
|
/**
|
|
51
67
|
* @param {string} key
|
|
52
68
|
* @returns {*} the val of the stored state, or undefined
|
package/src/matter/controller.js
CHANGED
|
@@ -9,7 +9,11 @@
|
|
|
9
9
|
* All nodeIds are exposed as decimal strings (BigInt serialization boundary).
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
const { Environment, ServerNode, ControllerBehavior } = require('@matter/main');
|
|
12
|
+
const { Environment, ServerNode, ControllerBehavior, Logger, LogLevel, LogFormat } = require('@matter/main');
|
|
13
|
+
|
|
14
|
+
// Upper bound for a commissioning attempt — matter.js has internal retries that
|
|
15
|
+
// can keep an HTTP request (and the pairing wizard) hanging for a very long time.
|
|
16
|
+
const COMMISSION_TIMEOUT_MS = 120000;
|
|
13
17
|
|
|
14
18
|
/** @type {import('@matter/main').ServerNode | null} */
|
|
15
19
|
let _server = null;
|
|
@@ -105,6 +109,17 @@ async function init(storagePath, log, broadcastFn) {
|
|
|
105
109
|
if (_server) throw new Error('Matter controller already started');
|
|
106
110
|
_log = log;
|
|
107
111
|
|
|
112
|
+
// Route matter.js's own diagnostics into the daemon logger so commissioning
|
|
113
|
+
// problems (discovery, PASE failures, retries) are visible in the Logs tab —
|
|
114
|
+
// by default matter.js writes straight to stdout, bypassing she's logging.
|
|
115
|
+
Logger.format = LogFormat.PLAIN;
|
|
116
|
+
Logger.level = LogLevel.INFO;
|
|
117
|
+
Logger.destinations.default.write = (text, message) => {
|
|
118
|
+
if (message.level >= LogLevel.ERROR) _log.error('matter.js:', text);
|
|
119
|
+
else if (message.level === LogLevel.WARN) _log.warn('matter.js:', text);
|
|
120
|
+
else _log.debug('matter.js:', text);
|
|
121
|
+
};
|
|
122
|
+
|
|
108
123
|
// Configure storage before anything else touches StorageService
|
|
109
124
|
Environment.default.vars.set('storage.path', storagePath);
|
|
110
125
|
|
|
@@ -240,6 +255,44 @@ function listPaired() {
|
|
|
240
255
|
*/
|
|
241
256
|
async function commission(options) {
|
|
242
257
|
if (!_server) throw new Error('Matter controller not started');
|
|
258
|
+
|
|
259
|
+
// Accept QR-code payloads ("MT:...") — matter.js's pairingCode option only
|
|
260
|
+
// decodes 11/21-digit manual pairing codes, so decode QR payloads here.
|
|
261
|
+
if (typeof options.pairingCode === 'string' && options.pairingCode.trim().toUpperCase().startsWith('MT:')) {
|
|
262
|
+
const { QrPairingCodeCodec } = require('@matter/types');
|
|
263
|
+
const [decoded] = QrPairingCodeCodec.decode(options.pairingCode.trim());
|
|
264
|
+
const { pairingCode: _pc, ...rest } = options;
|
|
265
|
+
options = { ...rest, passcode: decoded.passcode, discriminator: decoded.discriminator };
|
|
266
|
+
_log.debug('matter: decoded QR pairing code, discriminator', decoded.discriminator);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const label = options.pairingCode !== undefined ? 'pairing code' : `passcode${options.discriminator !== undefined ? ' + discriminator ' + options.discriminator : ''}`;
|
|
270
|
+
_log.info(`matter: commissioning start (${label}${options.discoveryAddress ? ', direct address ' + options.discoveryAddress : ''})`);
|
|
271
|
+
let timer;
|
|
272
|
+
try {
|
|
273
|
+
const nodeId = await Promise.race([
|
|
274
|
+
_commissionInner(options),
|
|
275
|
+
new Promise((_resolve, reject) => {
|
|
276
|
+
timer = setTimeout(
|
|
277
|
+
() =>
|
|
278
|
+
reject(
|
|
279
|
+
new Error(`commissioning timed out after ${COMMISSION_TIMEOUT_MS / 1000} s — check that the pairing window is still open and the device is reachable`),
|
|
280
|
+
),
|
|
281
|
+
COMMISSION_TIMEOUT_MS,
|
|
282
|
+
);
|
|
283
|
+
}),
|
|
284
|
+
]);
|
|
285
|
+
_log.info('matter: commissioning succeeded, nodeId', nodeId);
|
|
286
|
+
return nodeId;
|
|
287
|
+
} catch (err) {
|
|
288
|
+
_log.error('matter: commissioning failed:', err.message);
|
|
289
|
+
throw err;
|
|
290
|
+
} finally {
|
|
291
|
+
clearTimeout(timer);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function _commissionInner(options) {
|
|
243
296
|
const { discoveryAddress, ...commissionOpts } = options;
|
|
244
297
|
let clientNode;
|
|
245
298
|
if (discoveryAddress) {
|
|
@@ -273,6 +326,11 @@ async function commission(options) {
|
|
|
273
326
|
if (!addr) throw new Error('Commission succeeded but node has no peerAddress');
|
|
274
327
|
const nodeId = _nodeIdStr(addr.nodeId);
|
|
275
328
|
_subscribeNodeLifecycle(clientNode, nodeId);
|
|
329
|
+
// A freshly commissioned node is already online, so the lifecycle 'online'
|
|
330
|
+
// event (the other place this is wired) will not fire again — attach the
|
|
331
|
+
// attribute watchers now or the node stays without live updates until the
|
|
332
|
+
// next daemon restart.
|
|
333
|
+
_broadcastNodeAttributes(clientNode, nodeId);
|
|
276
334
|
_broadcast?.({ type: 'matter:deviceList', devices: listPaired() });
|
|
277
335
|
return nodeId;
|
|
278
336
|
}
|
|
@@ -341,11 +399,77 @@ async function getAttribute(nodeId, endpointId, clusterName, attrName) {
|
|
|
341
399
|
return clusterState[attrName];
|
|
342
400
|
}
|
|
343
401
|
|
|
402
|
+
/**
|
|
403
|
+
* Write a single attribute value (a remote write for client nodes — the
|
|
404
|
+
* attribute must be writable per the device's Matter data model).
|
|
405
|
+
*
|
|
406
|
+
* @param {string} nodeId
|
|
407
|
+
* @param {number} endpointId
|
|
408
|
+
* @param {string} clusterName camelCase cluster name, e.g. "basicInformation"
|
|
409
|
+
* @param {string} attrName camelCase attribute name, e.g. "nodeLabel"
|
|
410
|
+
* @param {*} value
|
|
411
|
+
* @returns {Promise<void>}
|
|
412
|
+
*/
|
|
413
|
+
async function setAttribute(nodeId, endpointId, clusterName, attrName, value) {
|
|
414
|
+
const node = _findClientNode(nodeId);
|
|
415
|
+
const endpoint = _resolveEndpoint(node, endpointId);
|
|
416
|
+
await endpoint.set({ [_clusterName(clusterName)]: { [attrName]: value } });
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Rename a device by writing basicInformation.nodeLabel on the root endpoint —
|
|
421
|
+
* the Matter-standard writable user label (max 32 chars), persisted on the
|
|
422
|
+
* device itself. she prefers nodeLabel over productName wherever names are
|
|
423
|
+
* shown or matched, so the new name is immediately usable in scripts.
|
|
424
|
+
*
|
|
425
|
+
* @param {string} nodeId
|
|
426
|
+
* @param {string} name
|
|
427
|
+
* @returns {Promise<void>}
|
|
428
|
+
*/
|
|
429
|
+
async function rename(nodeId, name) {
|
|
430
|
+
await setAttribute(nodeId, 0, 'basicInformation', 'nodeLabel', String(name));
|
|
431
|
+
_log?.info(`matter: renamed node ${nodeId} to "${name}"`);
|
|
432
|
+
_broadcast?.({ type: 'matter:deviceList', devices: listPaired() });
|
|
433
|
+
}
|
|
434
|
+
|
|
344
435
|
// ── Commands ──────────────────────────────────────────────────────────────────
|
|
345
436
|
|
|
437
|
+
/**
|
|
438
|
+
* Best-effort: list the mandatory field names (camelCase) of a command's
|
|
439
|
+
* request schema. Handles both shapes matter.js uses: a TLV ObjectSchema
|
|
440
|
+
* (fieldDefinitions, camelCase keys) and a CommandModel (children with
|
|
441
|
+
* PascalCase names and conformance flags). Returns [] when unresolvable.
|
|
442
|
+
* @param {object} cluster the behavior's cluster definition
|
|
443
|
+
* @param {string} commandName
|
|
444
|
+
* @returns {string[]}
|
|
445
|
+
*/
|
|
446
|
+
function _mandatoryCommandFields(cluster, commandName) {
|
|
447
|
+
try {
|
|
448
|
+
const cmd = cluster?.commands?.[commandName];
|
|
449
|
+
const schema = cmd?.schema ?? cmd?.requestSchema;
|
|
450
|
+
if (schema?.fieldDefinitions) {
|
|
451
|
+
return Object.entries(schema.fieldDefinitions)
|
|
452
|
+
.filter(([, def]) => !def?.optional)
|
|
453
|
+
.map(([name]) => name);
|
|
454
|
+
}
|
|
455
|
+
if (schema?.children) {
|
|
456
|
+
return [...schema.children].filter((c) => c.effectiveConformance?.isMandatory !== false).map((c) => c.name.charAt(0).toLowerCase() + c.name.slice(1));
|
|
457
|
+
}
|
|
458
|
+
} catch {
|
|
459
|
+
/* best-effort — fall through */
|
|
460
|
+
}
|
|
461
|
+
return [];
|
|
462
|
+
}
|
|
463
|
+
|
|
346
464
|
/**
|
|
347
465
|
* Invoke a cluster command.
|
|
348
466
|
*
|
|
467
|
+
* Mandatory boilerplate fields that scripts rarely care about are filled with
|
|
468
|
+
* neutral defaults when omitted: optionsMask/optionsOverride → {} (bitmap 0,
|
|
469
|
+
* "obey the device's options") and transitionTime → 0 (instant). So
|
|
470
|
+
* she.matter.send('x', 1, 'levelControl', 'moveToLevel', { level: 128 }) works
|
|
471
|
+
* without spelling out the full Matter command schema.
|
|
472
|
+
*
|
|
349
473
|
* @param {string} nodeId
|
|
350
474
|
* @param {number} endpointId
|
|
351
475
|
* @param {string} clusterName camelCase cluster name, e.g. "onOff"
|
|
@@ -363,10 +487,17 @@ async function sendCommand(nodeId, endpointId, clusterName, commandName, args) {
|
|
|
363
487
|
if (!clusterAgent) throw new Error(`Cluster "${clusterName}" not found on endpoint ${endpointId}`);
|
|
364
488
|
const cmd = clusterAgent[commandName];
|
|
365
489
|
if (typeof cmd !== 'function') throw new Error(`Command "${commandName}" not found in cluster "${clusterName}"`);
|
|
366
|
-
//
|
|
490
|
+
// Fill neutral defaults for mandatory boilerplate fields the caller omitted.
|
|
491
|
+
const merged = args !== undefined && args !== null && typeof args === 'object' ? { ...args } : {};
|
|
492
|
+
for (const field of _mandatoryCommandFields(clusterAgent.cluster, commandName)) {
|
|
493
|
+
if (merged[field] !== undefined) continue;
|
|
494
|
+
if (field === 'optionsMask' || field === 'optionsOverride') merged[field] = {};
|
|
495
|
+
else if (field === 'transitionTime') merged[field] = 0;
|
|
496
|
+
}
|
|
497
|
+
// Only pass args when there is actually a payload.
|
|
367
498
|
// Void commands (e.g. onOff.off) fail TLV validation if passed an empty object.
|
|
368
|
-
const hasArgs =
|
|
369
|
-
return hasArgs ? cmd.call(clusterAgent,
|
|
499
|
+
const hasArgs = Object.keys(merged).length > 0;
|
|
500
|
+
return hasArgs ? cmd.call(clusterAgent, merged) : cmd.call(clusterAgent);
|
|
370
501
|
});
|
|
371
502
|
}
|
|
372
503
|
|
|
@@ -414,7 +545,14 @@ function _broadcastNodeAttributes(node, nodeId) {
|
|
|
414
545
|
}
|
|
415
546
|
}
|
|
416
547
|
}
|
|
417
|
-
|
|
548
|
+
if (count === 0) {
|
|
549
|
+
// Endpoint structure not populated yet (e.g. right after commissioning)
|
|
550
|
+
// — drop the guard so the next lifecycle 'online' event retries.
|
|
551
|
+
_attrBroadcastNodes.delete(nodeId);
|
|
552
|
+
_log?.debug(`matter: no watchable attributes on node ${nodeId} yet, will retry on next online event`);
|
|
553
|
+
} else {
|
|
554
|
+
_log?.info(`matter: watching ${count} attribute(s) on node ${nodeId}`);
|
|
555
|
+
}
|
|
418
556
|
} catch (err) {
|
|
419
557
|
_attrBroadcastNodes.delete(nodeId); // allow retry
|
|
420
558
|
_log?.warn(`matter: attribute broadcast setup failed for ${nodeId}: ${err.message}`);
|
|
@@ -533,6 +671,8 @@ module.exports = {
|
|
|
533
671
|
getEndpoints,
|
|
534
672
|
getDeviceSubtitle,
|
|
535
673
|
getAttribute,
|
|
674
|
+
setAttribute,
|
|
675
|
+
rename,
|
|
536
676
|
sendCommand,
|
|
537
677
|
subscribeAttribute,
|
|
538
678
|
unsubscribe,
|
package/src/web/matter-api.js
CHANGED
|
@@ -58,6 +58,25 @@ router.post('/commission', async (req, res) => {
|
|
|
58
58
|
}
|
|
59
59
|
});
|
|
60
60
|
|
|
61
|
+
// POST /she/matter/devices/:nodeId/rename — { name } → writes basicInformation.nodeLabel
|
|
62
|
+
router.post('/devices/:nodeId/rename', async (req, res) => {
|
|
63
|
+
if (!isReady()) return notReady(res);
|
|
64
|
+
const name = req.body?.name;
|
|
65
|
+
if (typeof name !== 'string' || !name.trim()) {
|
|
66
|
+
return res.status(400).json({ error: 'body must contain a non-empty name' });
|
|
67
|
+
}
|
|
68
|
+
if (name.trim().length > 32) {
|
|
69
|
+
return res.status(400).json({ error: 'name must be at most 32 characters (Matter nodeLabel limit)' });
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
await getController().rename(req.params.nodeId, name.trim());
|
|
73
|
+
res.json({ ok: true });
|
|
74
|
+
} catch (err) {
|
|
75
|
+
if (err.message.includes('not found')) return res.status(404).json({ error: err.message });
|
|
76
|
+
res.status(500).json({ error: err.message });
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
61
80
|
// GET /she/matter/devices/:nodeId — node detail
|
|
62
81
|
router.get('/devices/:nodeId', (req, res) => {
|
|
63
82
|
if (!isReady()) return notReady(res);
|
package/src/web/mqtt-api.js
CHANGED
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
* (or null when no broker is configured).
|
|
13
13
|
*
|
|
14
14
|
* State changes for mqtt:: keys are automatically broadcast to WebSocket clients
|
|
15
|
-
* as { type: 'mqtt', topic, val, ts }
|
|
15
|
+
* as { type: 'mqtt', topic, val, ts }; deletions (retained message cleared via
|
|
16
|
+
* an empty retained publish) as { type: 'mqtt', topic, deleted: true, ts }.
|
|
16
17
|
*/
|
|
17
18
|
|
|
18
19
|
const express = require('express');
|
|
@@ -43,6 +44,12 @@ function init(store, getMqttClient) {
|
|
|
43
44
|
broadcastBrokerLog(topic.slice('$SYS/broker/log/'.length), String(obj.val), obj.ts);
|
|
44
45
|
}
|
|
45
46
|
});
|
|
47
|
+
|
|
48
|
+
// Forward deletions (retained message cleared) so clients drop the topic
|
|
49
|
+
store.on('delete', (key) => {
|
|
50
|
+
if (!key.startsWith('mqtt::')) return;
|
|
51
|
+
broadcast({ type: 'mqtt', topic: key.slice(6), deleted: true, ts: Date.now() });
|
|
52
|
+
});
|
|
46
53
|
}
|
|
47
54
|
|
|
48
55
|
// GET /she/mqtt/state — sorted snapshot of all retained MQTT topic values
|