smart-home-engine 0.13.0 → 0.15.18
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-B5b-7d7y.css +1 -0
- package/dist/web/assets/index-DUrZbUbt.js +220 -0
- package/dist/web/assets/{monaco-langs-DZ6hB11b.js → monaco-langs-Decdf6BV.js} +1 -1
- package/dist/web/assets/{tsMode-DcNPXUSe.js → tsMode-CQHBXrix.js} +1 -1
- package/dist/web/index.html +6 -5
- package/package.json +85 -87
- package/src/config.js +4 -0
- package/src/index.js +50 -3
- package/src/matter/controller.js +161 -23
- package/src/sandbox/matter-sandbox.js +20 -15
- package/src/sandbox/stdlib.js +17 -0
- package/src/web/ai-api.js +179 -222
- package/src/web/ai-context.js +118 -0
- package/src/web/ai-tools.js +348 -0
- package/src/web/log-ws.js +16 -1
- package/src/web/matter-api.js +7 -2
- package/src/web/prompts/db-doc.md +17 -0
- package/src/web/prompts/db-view.md +30 -0
- package/src/web/prompts/scripts-base.md +24 -0
- package/src/web/prompts/she-api-ref.md +73 -0
- package/src/web/scripts-api.js +4 -9
- package/src/web/server.js +8 -1
- package/dist/web/assets/index-CxNH_rV4.css +0 -1
- package/dist/web/assets/index-UyOLwDd5.js +0 -220
package/src/matter/controller.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* All nodeIds are exposed as decimal strings (BigInt serialization boundary).
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
const { Environment, ServerNode } = require('@matter/main');
|
|
12
|
+
const { Environment, ServerNode, ControllerBehavior } = require('@matter/main');
|
|
13
13
|
|
|
14
14
|
/** @type {import('@matter/main').ServerNode | null} */
|
|
15
15
|
let _server = null;
|
|
@@ -32,13 +32,37 @@ function _nodeIdStr(nodeId) {
|
|
|
32
32
|
return nodeId.toString();
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
function _findClientNode(
|
|
35
|
+
function _findClientNode(nodeIdOrName) {
|
|
36
|
+
nodeIdOrName = String(nodeIdOrName);
|
|
36
37
|
if (!_server) throw new Error('Matter controller not started');
|
|
38
|
+
// Try exact numeric nodeId match first
|
|
37
39
|
for (const node of _server.peers) {
|
|
38
40
|
const addr = node.peerAddress;
|
|
39
|
-
if (addr && _nodeIdStr(addr.nodeId) ===
|
|
41
|
+
if (addr && _nodeIdStr(addr.nodeId) === nodeIdOrName) return node;
|
|
40
42
|
}
|
|
41
|
-
|
|
43
|
+
// Fall back to name match (basicInformation.nodeLabel / productName)
|
|
44
|
+
for (const node of _server.peers) {
|
|
45
|
+
if (!node.peerAddress) continue;
|
|
46
|
+
if (_getDeviceName(node) === nodeIdOrName) return node;
|
|
47
|
+
}
|
|
48
|
+
throw new Error(`Matter node not found: ${nodeIdOrName}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Resolve an endpoint by numeric id or by name.
|
|
53
|
+
* @param {object} node ClientNode
|
|
54
|
+
* @param {number|string} endpointIdOrName
|
|
55
|
+
* @returns endpoint object
|
|
56
|
+
*/
|
|
57
|
+
function _resolveEndpoint(node, endpointIdOrName) {
|
|
58
|
+
const asNum = Number(endpointIdOrName);
|
|
59
|
+
if (Number.isFinite(asNum)) return node.endpoints.for(asNum);
|
|
60
|
+
// Name-based lookup
|
|
61
|
+
const name = String(endpointIdOrName);
|
|
62
|
+
for (const ep of node.endpoints) {
|
|
63
|
+
if (_getEndpointName(ep) === name) return ep;
|
|
64
|
+
}
|
|
65
|
+
throw new Error(`Endpoint not found: ${endpointIdOrName}`);
|
|
42
66
|
}
|
|
43
67
|
|
|
44
68
|
/** Resolve a cluster name (camelCase) from a cluster ID number or string. */
|
|
@@ -69,7 +93,13 @@ async function init(storagePath, log, broadcastFn) {
|
|
|
69
93
|
|
|
70
94
|
// Disable matter.js built-in CLI arg and env-var parsing so it doesn't
|
|
71
95
|
// interfere with the daemon's own yargs config.
|
|
72
|
-
|
|
96
|
+
// Wrapped in try/catch because the internal variable layout changed in some
|
|
97
|
+
// @matter/main versions and throws when 'environment' is not a map segment.
|
|
98
|
+
try {
|
|
99
|
+
Environment.default.vars.set('environment.disableInteraction', true);
|
|
100
|
+
} catch {
|
|
101
|
+
// Non-fatal: the daemon has no interactive terminal anyway.
|
|
102
|
+
}
|
|
73
103
|
|
|
74
104
|
_server = await ServerNode.create({
|
|
75
105
|
id: 'she-matter-controller',
|
|
@@ -92,9 +122,67 @@ async function close() {
|
|
|
92
122
|
|
|
93
123
|
// ── Device management ─────────────────────────────────────────────────────────
|
|
94
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Extract a human-readable name for a node from its root endpoint's basicInformation cluster.
|
|
127
|
+
* Returns null when the cluster is unavailable or the node is offline.
|
|
128
|
+
* @param {object} node ClientNode
|
|
129
|
+
* @returns {string|null}
|
|
130
|
+
*/
|
|
131
|
+
function _getDeviceName(node) {
|
|
132
|
+
try {
|
|
133
|
+
for (const ep of node.endpoints) {
|
|
134
|
+
if ((ep.number ?? 0) !== 0) continue;
|
|
135
|
+
const bi = ep.state?.basicInformation;
|
|
136
|
+
if (bi?.nodeLabel) return bi.nodeLabel;
|
|
137
|
+
if (bi?.productName) return bi.productName;
|
|
138
|
+
}
|
|
139
|
+
} catch { /* node may be offline */ }
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Extract a human-readable name for a single endpoint.
|
|
145
|
+
* Prefers bridgedDeviceBasicInformation.nodeLabel for bridged devices,
|
|
146
|
+
* falls back to basicInformation for the root endpoint.
|
|
147
|
+
* @param {object} endpoint
|
|
148
|
+
* @returns {string|null}
|
|
149
|
+
*/
|
|
150
|
+
function _getEndpointName(endpoint) {
|
|
151
|
+
try {
|
|
152
|
+
const state = endpoint.state;
|
|
153
|
+
if (!state) return null;
|
|
154
|
+
const bridgedBi = state.bridgedDeviceBasicInformation;
|
|
155
|
+
if (bridgedBi?.nodeLabel) return bridgedBi.nodeLabel;
|
|
156
|
+
const bi = state.basicInformation;
|
|
157
|
+
if (bi?.nodeLabel) return bi.nodeLabel;
|
|
158
|
+
if (bi?.productName) return bi.productName;
|
|
159
|
+
} catch { /* best-effort */ }
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Extract vendor + product subtitle for the root endpoint (endpoint 0).
|
|
165
|
+
* Returns null when unavailable.
|
|
166
|
+
* @param {object} node
|
|
167
|
+
* @returns {string|null}
|
|
168
|
+
*/
|
|
169
|
+
function _getDeviceSubtitle(node) {
|
|
170
|
+
try {
|
|
171
|
+
for (const ep of node.endpoints) {
|
|
172
|
+
if ((ep.number ?? 0) !== 0) continue;
|
|
173
|
+
const bi = ep.state?.basicInformation;
|
|
174
|
+
const parts = [];
|
|
175
|
+
if (bi?.vendorName) parts.push(bi.vendorName);
|
|
176
|
+
if (bi?.productName && bi.productName !== _getDeviceName(node)) parts.push(bi.productName);
|
|
177
|
+
return parts.length ? parts.join(' · ') : null;
|
|
178
|
+
}
|
|
179
|
+
} catch { /* offline */ }
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
95
183
|
/**
|
|
96
184
|
* List all paired nodes.
|
|
97
|
-
* @returns {{ nodeId: string, online: boolean }[]}
|
|
185
|
+
* @returns {{ nodeId: string, online: boolean, name: string|null }[]}
|
|
98
186
|
*/
|
|
99
187
|
function listPaired() {
|
|
100
188
|
if (!_server) return [];
|
|
@@ -105,6 +193,7 @@ function listPaired() {
|
|
|
105
193
|
result.push({
|
|
106
194
|
nodeId: _nodeIdStr(addr.nodeId),
|
|
107
195
|
online: node.lifecycle?.isOnline ?? false,
|
|
196
|
+
name: _getDeviceName(node),
|
|
108
197
|
});
|
|
109
198
|
}
|
|
110
199
|
return result;
|
|
@@ -113,12 +202,41 @@ function listPaired() {
|
|
|
113
202
|
/**
|
|
114
203
|
* Commission a new device.
|
|
115
204
|
*
|
|
116
|
-
* @param {{ passcode: number, discriminator?: number } | { pairingCode: string }} options
|
|
205
|
+
* @param {{ passcode: number, discriminator?: number, discoveryAddress?: string } | { pairingCode: string, discoveryAddress?: string }} options
|
|
206
|
+
* discoveryAddress: optional "ip" or "ip:port" to bypass mDNS discovery and connect directly.
|
|
117
207
|
* @returns {Promise<string>} nodeId of the newly commissioned device
|
|
118
208
|
*/
|
|
119
209
|
async function commission(options) {
|
|
120
210
|
if (!_server) throw new Error('Matter controller not started');
|
|
121
|
-
const
|
|
211
|
+
const { discoveryAddress, ...commissionOpts } = options;
|
|
212
|
+
let clientNode;
|
|
213
|
+
if (discoveryAddress) {
|
|
214
|
+
const colonIdx = discoveryAddress.lastIndexOf(':');
|
|
215
|
+
let ip, port;
|
|
216
|
+
if (colonIdx > 0 && !discoveryAddress.startsWith('[') && colonIdx !== discoveryAddress.indexOf(':')) {
|
|
217
|
+
// IPv6 without brackets — treat whole string as IP, use default port
|
|
218
|
+
ip = discoveryAddress;
|
|
219
|
+
port = 5540;
|
|
220
|
+
} else if (colonIdx > 0) {
|
|
221
|
+
const maybePort = parseInt(discoveryAddress.slice(colonIdx + 1), 10);
|
|
222
|
+
if (Number.isFinite(maybePort)) {
|
|
223
|
+
ip = discoveryAddress.slice(0, colonIdx).replace(/^\[|\]$/g, '');
|
|
224
|
+
port = maybePort;
|
|
225
|
+
} else {
|
|
226
|
+
ip = discoveryAddress;
|
|
227
|
+
port = 5540;
|
|
228
|
+
}
|
|
229
|
+
} else {
|
|
230
|
+
ip = discoveryAddress;
|
|
231
|
+
port = 5540;
|
|
232
|
+
}
|
|
233
|
+
_log.info(`matter: using direct discovery address ${ip}:${port} (bypassing mDNS)`);
|
|
234
|
+
_server.behaviors.require(ControllerBehavior);
|
|
235
|
+
clientNode = await _server.peers.forDescriptor({ addresses: [{ type: 'udp', ip, port }] });
|
|
236
|
+
await clientNode.commission(commissionOpts);
|
|
237
|
+
} else {
|
|
238
|
+
clientNode = await _server.peers.commission(commissionOpts);
|
|
239
|
+
}
|
|
122
240
|
const addr = clientNode.peerAddress;
|
|
123
241
|
if (!addr) throw new Error('Commission succeeded but node has no peerAddress');
|
|
124
242
|
const nodeId = _nodeIdStr(addr.nodeId);
|
|
@@ -149,14 +267,14 @@ async function unpair(nodeId) {
|
|
|
149
267
|
* Each endpoint entry carries the list of available cluster names.
|
|
150
268
|
*
|
|
151
269
|
* @param {string} nodeId
|
|
152
|
-
* @returns {{ endpointId: number, clusters: string[] }[]}
|
|
270
|
+
* @returns {{ endpointId: number, clusters: string[], name: string|null }[]}
|
|
153
271
|
*/
|
|
154
272
|
function getEndpoints(nodeId) {
|
|
155
273
|
const node = _findClientNode(nodeId);
|
|
156
274
|
const result = [];
|
|
157
275
|
for (const endpoint of node.endpoints) {
|
|
158
276
|
const clusters = endpoint.state ? Object.keys(endpoint.state) : [];
|
|
159
|
-
result.push({ endpointId: endpoint.number ?? 0, clusters });
|
|
277
|
+
result.push({ endpointId: endpoint.number ?? 0, clusters, name: _getEndpointName(endpoint) });
|
|
160
278
|
}
|
|
161
279
|
return result;
|
|
162
280
|
}
|
|
@@ -174,7 +292,7 @@ function getEndpoints(nodeId) {
|
|
|
174
292
|
*/
|
|
175
293
|
async function getAttribute(nodeId, endpointId, clusterName, attrName) {
|
|
176
294
|
const node = _findClientNode(nodeId);
|
|
177
|
-
const endpoint = node
|
|
295
|
+
const endpoint = _resolveEndpoint(node, endpointId);
|
|
178
296
|
const clusterState = endpoint.state?.[_clusterName(clusterName)];
|
|
179
297
|
if (!clusterState) throw new Error(`Cluster "${clusterName}" not found on endpoint ${endpointId}`);
|
|
180
298
|
return clusterState[attrName];
|
|
@@ -194,17 +312,22 @@ async function getAttribute(nodeId, endpointId, clusterName, attrName) {
|
|
|
194
312
|
*/
|
|
195
313
|
async function sendCommand(nodeId, endpointId, clusterName, commandName, args) {
|
|
196
314
|
const node = _findClientNode(nodeId);
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
315
|
+
// Use the target endpoint's own act() instead of navigating via agent.parts,
|
|
316
|
+
// because Parts in @matter/node is a set-like iterable, not a Map (.get doesn't exist).
|
|
317
|
+
const endpoint = _resolveEndpoint(node, endpointId);
|
|
318
|
+
return endpoint.act(
|
|
319
|
+
`she.matter.send(${nodeId}, ${endpointId}, ${clusterName}.${commandName})`,
|
|
320
|
+
async (agent) => {
|
|
321
|
+
const clusterAgent = agent[_clusterName(clusterName)];
|
|
322
|
+
if (!clusterAgent) throw new Error(`Cluster "${clusterName}" not found on endpoint ${endpointId}`);
|
|
323
|
+
const cmd = clusterAgent[commandName];
|
|
324
|
+
if (typeof cmd !== 'function') throw new Error(`Command "${commandName}" not found in cluster "${clusterName}"`);
|
|
325
|
+
// Only pass args when the caller actually provided non-empty args.
|
|
326
|
+
// Void commands (e.g. onOff.off) fail TLV validation if passed an empty object.
|
|
327
|
+
const hasArgs = args !== undefined && args !== null && Object.keys(args).length > 0;
|
|
328
|
+
return hasArgs ? cmd.call(clusterAgent, args) : cmd.call(clusterAgent);
|
|
329
|
+
}
|
|
330
|
+
);
|
|
208
331
|
}
|
|
209
332
|
|
|
210
333
|
// ── Node lifecycle events (online / offline) ────────────────────────────────
|
|
@@ -241,7 +364,7 @@ function _subscribeNodeLifecycle(node, nodeId) {
|
|
|
241
364
|
*/
|
|
242
365
|
function subscribeAttribute(scriptFile, nodeId, endpointId, clusterName, attrName, callback) {
|
|
243
366
|
const node = _findClientNode(nodeId);
|
|
244
|
-
const endpoint = node
|
|
367
|
+
const endpoint = _resolveEndpoint(node, endpointId);
|
|
245
368
|
const events = endpoint.events?.[_clusterName(clusterName)];
|
|
246
369
|
if (!events) throw new Error(`Cluster "${clusterName}" not found on endpoint ${endpointId}`);
|
|
247
370
|
const changeEvent = events[`${attrName}$Changed`];
|
|
@@ -292,6 +415,20 @@ function cleanup(scriptFile) {
|
|
|
292
415
|
_listeners.delete(scriptFile);
|
|
293
416
|
}
|
|
294
417
|
|
|
418
|
+
/**
|
|
419
|
+
* Get vendor · product subtitle for a node by nodeId string.
|
|
420
|
+
* @param {string} nodeId
|
|
421
|
+
* @returns {string|null}
|
|
422
|
+
*/
|
|
423
|
+
function getDeviceSubtitle(nodeId) {
|
|
424
|
+
try {
|
|
425
|
+
const node = _findClientNode(nodeId);
|
|
426
|
+
return _getDeviceSubtitle(node);
|
|
427
|
+
} catch {
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
295
432
|
module.exports = {
|
|
296
433
|
init,
|
|
297
434
|
close,
|
|
@@ -299,6 +436,7 @@ module.exports = {
|
|
|
299
436
|
commission,
|
|
300
437
|
unpair,
|
|
301
438
|
getEndpoints,
|
|
439
|
+
getDeviceSubtitle,
|
|
302
440
|
getAttribute,
|
|
303
441
|
sendCommand,
|
|
304
442
|
subscribeAttribute,
|
|
@@ -15,6 +15,11 @@
|
|
|
15
15
|
* she.matter.send(nodeId, endpointId, clusterName, command, args?)
|
|
16
16
|
* → Promise<result>
|
|
17
17
|
*
|
|
18
|
+
* nodeId — decimal NodeId string (e.g. '4') OR device name (e.g. 'Matterbridge')
|
|
19
|
+
* endpointId — numeric endpoint id (e.g. 43) OR endpoint name (e.g. 'Licht Werkstatt')
|
|
20
|
+
* Name matching uses basicInformation.nodeLabel / productName for devices and
|
|
21
|
+
* bridgedDeviceBasicInformation.nodeLabel / basicInformation.nodeLabel for endpoints.
|
|
22
|
+
*
|
|
18
23
|
* All subscriptions registered by a script are automatically cancelled on
|
|
19
24
|
* hot-reload (cleanup() is called from unloadScript() in index.js).
|
|
20
25
|
*/
|
|
@@ -26,11 +31,11 @@ module.exports = function (she, { scriptDomain, scriptName }) {
|
|
|
26
31
|
/**
|
|
27
32
|
* Subscribe to an attribute change on a paired Matter device.
|
|
28
33
|
*
|
|
29
|
-
* @param {string}
|
|
30
|
-
* @param {number}
|
|
31
|
-
* @param {string}
|
|
32
|
-
* @param {string}
|
|
33
|
-
* @param {Function}
|
|
34
|
+
* @param {string|number} nodeId Decimal NodeId string/number OR device name
|
|
35
|
+
* @param {number|string} endpointId Numeric endpoint id OR endpoint name
|
|
36
|
+
* @param {string} clusterName camelCase cluster name, e.g. "onOff"
|
|
37
|
+
* @param {string} attrName camelCase attribute name, e.g. "onOff"
|
|
38
|
+
* @param {Function} callback (value, oldValue) => void
|
|
34
39
|
* @returns {number} listenerId
|
|
35
40
|
*/
|
|
36
41
|
sub(nodeId, endpointId, clusterName, attrName, callback) {
|
|
@@ -53,10 +58,10 @@ module.exports = function (she, { scriptDomain, scriptName }) {
|
|
|
53
58
|
/**
|
|
54
59
|
* Read a single attribute value from a paired Matter device.
|
|
55
60
|
*
|
|
56
|
-
* @param {string} nodeId
|
|
57
|
-
* @param {number} endpointId
|
|
58
|
-
* @param {string}
|
|
59
|
-
* @param {string}
|
|
61
|
+
* @param {string|number} nodeId Decimal NodeId string/number OR device name
|
|
62
|
+
* @param {number|string} endpointId Numeric endpoint id OR endpoint name
|
|
63
|
+
* @param {string} clusterName camelCase cluster name
|
|
64
|
+
* @param {string} attrName camelCase attribute name
|
|
60
65
|
* @returns {Promise<unknown>}
|
|
61
66
|
*/
|
|
62
67
|
get(nodeId, endpointId, clusterName, attrName) {
|
|
@@ -66,15 +71,15 @@ module.exports = function (she, { scriptDomain, scriptName }) {
|
|
|
66
71
|
/**
|
|
67
72
|
* Invoke a cluster command on a paired Matter device.
|
|
68
73
|
*
|
|
69
|
-
* @param {string}
|
|
70
|
-
* @param {number}
|
|
71
|
-
* @param {string}
|
|
72
|
-
* @param {string}
|
|
73
|
-
* @param {object}
|
|
74
|
+
* @param {string|number} nodeId Decimal NodeId string/number OR device name
|
|
75
|
+
* @param {number|string} endpointId Numeric endpoint id OR endpoint name
|
|
76
|
+
* @param {string} clusterName camelCase cluster name
|
|
77
|
+
* @param {string} command camelCase command name
|
|
78
|
+
* @param {object} [args] Command arguments (omit for void commands)
|
|
74
79
|
* @returns {Promise<unknown>}
|
|
75
80
|
*/
|
|
76
81
|
send(nodeId, endpointId, clusterName, command, args) {
|
|
77
|
-
return controller.sendCommand(nodeId, endpointId, clusterName, command, args
|
|
82
|
+
return controller.sendCommand(nodeId, endpointId, clusterName, command, args);
|
|
78
83
|
},
|
|
79
84
|
};
|
|
80
85
|
};
|
package/src/sandbox/stdlib.js
CHANGED
|
@@ -129,4 +129,21 @@ module.exports = function (she) {
|
|
|
129
129
|
/** Register a callback for MQTT connection lifecycle events ('connect' or 'disconnect'). */
|
|
130
130
|
on: (event, cb) => she._registerMqttEvent(event, cb),
|
|
131
131
|
};
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Fetch a URL and return a Promise that resolves to the response body.
|
|
135
|
+
* Resolves to parsed JSON when the Content-Type is application/json, plain text otherwise.
|
|
136
|
+
* Rejects on non-2xx responses.
|
|
137
|
+
* @method fetch
|
|
138
|
+
* @param {string} url
|
|
139
|
+
* @param {RequestInit} [options]
|
|
140
|
+
* @returns {Promise<string|object>}
|
|
141
|
+
*/
|
|
142
|
+
she.fetch = function Sandbox_fetch(url, options) {
|
|
143
|
+
return fetch(url, options).then((r) => {
|
|
144
|
+
if (!r.ok) throw new Error(`HTTP ${r.status} ${r.statusText}`);
|
|
145
|
+
const ct = r.headers.get('content-type') || '';
|
|
146
|
+
return ct.includes('json') ? r.json() : r.text();
|
|
147
|
+
});
|
|
148
|
+
};
|
|
132
149
|
};
|