smart-home-engine 0.0.1 → 0.10.4
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 +76 -0
- package/dist/web/assets/codicon-DCmgc-ay.ttf +0 -0
- package/dist/web/assets/index-Bdf2J0nm.js +140 -0
- package/dist/web/assets/index-DkhtWYJx.css +1 -0
- package/dist/web/assets/monaco-langs-DZ6hB11b.js +1423 -0
- package/dist/web/assets/monaco-langs-DyX1CsEw.css +1 -0
- package/dist/web/assets/tsMode-THvwQw-l.js +16 -0
- package/dist/web/index.html +164 -0
- package/dist/web/monacoeditorwork/editor.worker.bundle.js +13519 -0
- package/dist/web/monacoeditorwork/ts.worker.bundle.js +256353 -0
- package/package.json +84 -10
- package/src/config.js +53 -0
- package/src/elastic.js +19 -0
- package/src/index.js +1184 -0
- package/src/influx.js +25 -0
- package/src/lib/mqtt-wildcards.js +34 -0
- package/src/lib/parse-payload.js +29 -0
- package/src/lib/redis.js +74 -0
- package/src/lib/shedb-core.js +447 -0
- package/src/lib/shedb-worker.js +126 -0
- package/src/lib/state-store.js +97 -0
- package/src/lib/storage.js +74 -0
- package/src/matter/controller.js +307 -0
- package/src/sandbox/api.js +57 -0
- package/src/sandbox/elastic-sandbox.js +88 -0
- package/src/sandbox/influx-sandbox.js +107 -0
- package/src/sandbox/matter-sandbox.js +92 -0
- package/src/sandbox/shedb-sandbox.js +89 -0
- package/src/sandbox/stdlib.js +132 -0
- package/src/scripts/hello.js +3 -0
- package/src/web/ai-api.js +443 -0
- package/src/web/config-api.js +34 -0
- package/src/web/deps-api.js +138 -0
- package/src/web/git-api.js +188 -0
- package/src/web/log-ws.js +71 -0
- package/src/web/matter-api.js +102 -0
- package/src/web/mqtt-api.js +65 -0
- package/src/web/scripts-api.js +192 -0
- package/src/web/server.js +130 -0
- package/src/web/shedb-api.js +140 -0
- package/src/web/shedb.js +168 -0
- package/index.js +0 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SheDB view worker — runs in a worker_threads Worker.
|
|
5
|
+
* Executes map/reduce view scripts for all named queries against
|
|
6
|
+
* a snapshot of the document store received from the main thread.
|
|
7
|
+
*
|
|
8
|
+
* Message protocol:
|
|
9
|
+
* Main → Worker:
|
|
10
|
+
* { type: 'db', docs: {...} } full docs snapshot
|
|
11
|
+
* { type: 'query', id, payload: {filter?,map,reduce?} }
|
|
12
|
+
* { type: 'delQuery', id }
|
|
13
|
+
*
|
|
14
|
+
* Worker → Main:
|
|
15
|
+
* { type: 'view', id, result: [...] } successful result
|
|
16
|
+
* { type: 'view', id, error: string } runtime/compile error
|
|
17
|
+
* { type: 'view', id, deleted: true } query was removed
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const { parentPort, workerData } = require('worker_threads');
|
|
21
|
+
const vm = require('vm');
|
|
22
|
+
const mqttWildcard = require('./mqtt-wildcards');
|
|
23
|
+
|
|
24
|
+
const TIMEOUT = (workerData && workerData.scriptTimeout) || 5000;
|
|
25
|
+
|
|
26
|
+
let docs = {};
|
|
27
|
+
const queries = {};
|
|
28
|
+
let queue = [];
|
|
29
|
+
let running = false;
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Helpers (inlined — worker has no access to main-thread closures)
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
function getProp(obj, propPath) {
|
|
36
|
+
if (obj == null || !propPath) return undefined;
|
|
37
|
+
return propPath.split('.').reduce((cur, k) => (cur != null ? cur[k] : undefined), obj);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// View queue
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
function enqueue(id) {
|
|
45
|
+
if (!queue.includes(id)) queue.push(id);
|
|
46
|
+
if (!running) scheduleNext();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function scheduleNext() {
|
|
50
|
+
if (queue.length === 0) {
|
|
51
|
+
running = false;
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
running = true;
|
|
55
|
+
const id = queue.shift();
|
|
56
|
+
setImmediate(() => buildAndRun(id));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function buildAndRun(id) {
|
|
60
|
+
const q = queries[id];
|
|
61
|
+
if (!q) {
|
|
62
|
+
scheduleNext();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const { filter, map, reduce } = q;
|
|
67
|
+
|
|
68
|
+
// Build script source — same structure as the original in-process approach
|
|
69
|
+
let src = `api.map = function() {\n${map}\n};\napi._result = [];\n`;
|
|
70
|
+
if (filter) {
|
|
71
|
+
src += `api.forEachDocument(docId => { if (api.mqttWildcard(docId, ${JSON.stringify(filter)})) api.map.apply(api.getDocument(docId)); });\n`;
|
|
72
|
+
} else {
|
|
73
|
+
src += `api.forEachDocument(docId => { api.map.apply(api.getDocument(docId)); });\n`;
|
|
74
|
+
}
|
|
75
|
+
if (reduce) {
|
|
76
|
+
src += `api.reduce = function(result) {\n${reduce}\n};\napi._result = api.reduce(api._result);\n`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Sandbox — uses the local docs snapshot
|
|
80
|
+
const sandbox = {
|
|
81
|
+
api: {
|
|
82
|
+
forEachDocument: (cb) => Object.keys(docs).forEach(cb),
|
|
83
|
+
getDocument: (docId) => docs[docId],
|
|
84
|
+
getProp,
|
|
85
|
+
mqttWildcard,
|
|
86
|
+
_result: [],
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
sandbox.emit = (item) => sandbox.api._result.push(item);
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const script = new vm.Script(src, { filename: 'shedb-view-' + id });
|
|
93
|
+
const ctx = vm.createContext(sandbox);
|
|
94
|
+
script.runInContext(ctx, { timeout: TIMEOUT });
|
|
95
|
+
parentPort.postMessage({ type: 'view', id, result: Array.from(ctx.api._result) });
|
|
96
|
+
} catch (err) {
|
|
97
|
+
parentPort.postMessage({ type: 'view', id, error: 'runtime: ' + err.message });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
scheduleNext();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Message handler
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
parentPort.on('message', (msg) => {
|
|
108
|
+
switch (msg.type) {
|
|
109
|
+
case 'db':
|
|
110
|
+
docs = msg.docs;
|
|
111
|
+
// Re-run all registered views with the new docs snapshot
|
|
112
|
+
for (const id of Object.keys(queries)) enqueue(id);
|
|
113
|
+
break;
|
|
114
|
+
|
|
115
|
+
case 'query':
|
|
116
|
+
queries[msg.id] = msg.payload;
|
|
117
|
+
enqueue(msg.id);
|
|
118
|
+
break;
|
|
119
|
+
|
|
120
|
+
case 'delQuery':
|
|
121
|
+
delete queries[msg.id];
|
|
122
|
+
queue = queue.filter((id) => id !== msg.id);
|
|
123
|
+
parentPort.postMessage({ type: 'view', id: msg.id, deleted: true });
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Unified in-memory state store for all interfaces (MQTT, Matter, variables).
|
|
5
|
+
*
|
|
6
|
+
* Keys are namespaced with a double-colon separator:
|
|
7
|
+
* mqtt::home/sensor/temp
|
|
8
|
+
* matter::1/1/LevelControl/currentLevel
|
|
9
|
+
* var::myVariable
|
|
10
|
+
*
|
|
11
|
+
* Events:
|
|
12
|
+
* 'change' (key, val, obj, prevObj)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const { EventEmitter } = require('events');
|
|
16
|
+
|
|
17
|
+
class StateStore extends EventEmitter {
|
|
18
|
+
constructor() {
|
|
19
|
+
super();
|
|
20
|
+
this._map = new Map();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Store a value, auto-computing { val, ts, lc } timestamps.
|
|
25
|
+
* Emits 'change' on every call.
|
|
26
|
+
* @param {string} key Namespaced key, e.g. 'mqtt::home/sensor/temp'
|
|
27
|
+
* @param {*} val
|
|
28
|
+
*/
|
|
29
|
+
set(key, val) {
|
|
30
|
+
const now = Date.now();
|
|
31
|
+
const prev = this._map.get(key);
|
|
32
|
+
const lc = !prev || prev.val !== val ? now : prev.lc;
|
|
33
|
+
const obj = { val, ts: now, lc };
|
|
34
|
+
this._map.set(key, obj);
|
|
35
|
+
this.emit('change', key, val, obj, prev);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Store a pre-constructed state object { val, ts, lc }.
|
|
40
|
+
* Use when the caller has already built the state (e.g. MQTT parse-payload).
|
|
41
|
+
* @param {string} key
|
|
42
|
+
* @param {{ val:*, ts:number, lc:number }} obj
|
|
43
|
+
*/
|
|
44
|
+
setObject(key, obj) {
|
|
45
|
+
const prev = this._map.get(key);
|
|
46
|
+
this._map.set(key, obj);
|
|
47
|
+
this.emit('change', key, obj.val, obj, prev);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {string} key
|
|
52
|
+
* @returns {*} the val of the stored state, or undefined
|
|
53
|
+
*/
|
|
54
|
+
get(key) {
|
|
55
|
+
const obj = this._map.get(key);
|
|
56
|
+
return obj !== undefined ? obj.val : undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {string} key
|
|
61
|
+
* @returns {{ val:*, ts:number, lc:number } | undefined}
|
|
62
|
+
*/
|
|
63
|
+
getObject(key) {
|
|
64
|
+
return this._map.get(key);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** @returns {boolean} */
|
|
68
|
+
has(key) {
|
|
69
|
+
return this._map.has(key);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Return all keys, optionally filtered to those starting with nsPrefix.
|
|
74
|
+
* @param {string} [nsPrefix]
|
|
75
|
+
* @returns {string[]}
|
|
76
|
+
*/
|
|
77
|
+
keys(nsPrefix) {
|
|
78
|
+
const all = Array.from(this._map.keys());
|
|
79
|
+
return nsPrefix ? all.filter((k) => k.startsWith(nsPrefix)) : all;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Iterate [rawTopic, obj] pairs for all mqtt:: keys, stripping the prefix.
|
|
84
|
+
* Used for wildcard retain-replay in subscribe().
|
|
85
|
+
* @yields {[string, {val:*, ts:number, lc:number}]}
|
|
86
|
+
*/
|
|
87
|
+
*mqttEntries() {
|
|
88
|
+
const prefix = 'mqtt::';
|
|
89
|
+
for (const [key, obj] of this._map) {
|
|
90
|
+
if (key.startsWith(prefix)) {
|
|
91
|
+
yield [key.slice(prefix.length), obj];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = StateStore;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
const STORAGE_ROOT = path.join(os.homedir(), '.she');
|
|
8
|
+
const CONFIG_ROOT = path.join(STORAGE_ROOT, 'config');
|
|
9
|
+
const SCRIPTS_ROOT = path.join(STORAGE_ROOT, 'scripts');
|
|
10
|
+
const DB_ROOT = path.join(STORAGE_ROOT, 'db');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Return the absolute path for a named sub-directory of ~/.she/.
|
|
14
|
+
* The directory is NOT created here — call ensureStorageDir() for that.
|
|
15
|
+
*/
|
|
16
|
+
function getStoragePath(name) {
|
|
17
|
+
return path.join(STORAGE_ROOT, name);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Return the path to the shared config file: ~/.she/config/config.json.
|
|
22
|
+
*/
|
|
23
|
+
function getConfigPath() {
|
|
24
|
+
return path.join(CONFIG_ROOT, 'config.json');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Create ~/.she/<name>/ if it does not already exist.
|
|
29
|
+
* Returns the resolved path.
|
|
30
|
+
*/
|
|
31
|
+
function ensureStorageDir(name) {
|
|
32
|
+
const dir = path.join(STORAGE_ROOT, name);
|
|
33
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
34
|
+
return dir;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Create ~/.she/ and its standard subdirectories if they don't already exist.
|
|
39
|
+
* Called once at daemon startup before anything else runs.
|
|
40
|
+
*/
|
|
41
|
+
function ensureRoot() {
|
|
42
|
+
fs.mkdirSync(STORAGE_ROOT, { recursive: true });
|
|
43
|
+
fs.mkdirSync(CONFIG_ROOT, { recursive: true });
|
|
44
|
+
fs.mkdirSync(SCRIPTS_ROOT, { recursive: true });
|
|
45
|
+
fs.mkdirSync(DB_ROOT, { recursive: true });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Ensure ~/.she/package.json exists so npm can install packages there.
|
|
50
|
+
* Creates a minimal private package.json if missing.
|
|
51
|
+
*/
|
|
52
|
+
function ensureUserPackageJson() {
|
|
53
|
+
const pkgPath = path.join(STORAGE_ROOT, 'package.json');
|
|
54
|
+
if (!fs.existsSync(pkgPath)) {
|
|
55
|
+
fs.writeFileSync(
|
|
56
|
+
pkgPath,
|
|
57
|
+
JSON.stringify(
|
|
58
|
+
{
|
|
59
|
+
name: 'she-user-scripts',
|
|
60
|
+
version: '1.0.0',
|
|
61
|
+
private: true,
|
|
62
|
+
description: 'User-installed npm packages for she scripts',
|
|
63
|
+
dependencies: {},
|
|
64
|
+
},
|
|
65
|
+
null,
|
|
66
|
+
2,
|
|
67
|
+
) + '\n',
|
|
68
|
+
'utf8',
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
return pkgPath;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = { STORAGE_ROOT, CONFIG_ROOT, SCRIPTS_ROOT, DB_ROOT, getStoragePath, getConfigPath, ensureStorageDir, ensureRoot, ensureUserPackageJson };
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Matter controller — wraps @matter/main ServerNode + Peers API.
|
|
5
|
+
*
|
|
6
|
+
* Call init() once at daemon startup (only when --matter-storage is set).
|
|
7
|
+
* Call close() on daemon shutdown.
|
|
8
|
+
*
|
|
9
|
+
* All nodeIds are exposed as decimal strings (BigInt serialization boundary).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const { Environment, ServerNode } = require('@matter/main');
|
|
13
|
+
|
|
14
|
+
/** @type {import('@matter/main').ServerNode | null} */
|
|
15
|
+
let _server = null;
|
|
16
|
+
let _log = null;
|
|
17
|
+
/** @type {((msg: object) => void) | null} */
|
|
18
|
+
let _broadcast = null;
|
|
19
|
+
|
|
20
|
+
// ── Attribute / event listeners registered by sandbox scripts ───────────────
|
|
21
|
+
// Map<scriptFile, Map<listenerId, { cancel: () => void }>>
|
|
22
|
+
const _listeners = new Map();
|
|
23
|
+
let _nextListenerId = 1;
|
|
24
|
+
|
|
25
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
function _bigintNodeId(nodeId) {
|
|
28
|
+
return BigInt(nodeId);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function _nodeIdStr(nodeId) {
|
|
32
|
+
return nodeId.toString();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function _findClientNode(nodeIdStr) {
|
|
36
|
+
if (!_server) throw new Error('Matter controller not started');
|
|
37
|
+
for (const node of _server.peers) {
|
|
38
|
+
const addr = node.peerAddress;
|
|
39
|
+
if (addr && _nodeIdStr(addr.nodeId) === nodeIdStr) return node;
|
|
40
|
+
}
|
|
41
|
+
throw new Error(`Matter node not found: ${nodeIdStr}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Resolve a cluster name (camelCase) from a cluster ID number or string. */
|
|
45
|
+
function _clusterName(clusterId) {
|
|
46
|
+
// matter.js cluster state keys are camelCase cluster names.
|
|
47
|
+
// For generic access we accept either the camelCase name directly or skip resolution.
|
|
48
|
+
// The caller must pass the camelCase cluster name (e.g. "onOff", "levelControl").
|
|
49
|
+
return clusterId;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Initialise the Matter controller.
|
|
56
|
+
* Must be called before any other method.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} storagePath Absolute path to the matter storage directory (~/.she/matter)
|
|
59
|
+
* @param {{ info: Function, warn: Function, error: Function }} log Daemon logger
|
|
60
|
+
* @param {((msg: object) => void) | null} [broadcastFn] WebSocket broadcast function from log-ws.js
|
|
61
|
+
*/
|
|
62
|
+
async function init(storagePath, log, broadcastFn) {
|
|
63
|
+
_broadcast = broadcastFn ?? null;
|
|
64
|
+
if (_server) throw new Error('Matter controller already started');
|
|
65
|
+
_log = log;
|
|
66
|
+
|
|
67
|
+
// Configure storage before anything else touches StorageService
|
|
68
|
+
Environment.default.vars.set('storage.path', storagePath);
|
|
69
|
+
|
|
70
|
+
// Disable matter.js built-in CLI arg and env-var parsing so it doesn't
|
|
71
|
+
// interfere with the daemon's own yargs config.
|
|
72
|
+
Environment.default.vars.set('environment.disableInteraction', true);
|
|
73
|
+
|
|
74
|
+
_server = await ServerNode.create({
|
|
75
|
+
id: 'she-matter-controller',
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
await _server.start();
|
|
79
|
+
_log.info('matter controller started, storage:', storagePath);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function close() {
|
|
83
|
+
if (!_server) return;
|
|
84
|
+
try {
|
|
85
|
+
await _server.close();
|
|
86
|
+
} catch (err) {
|
|
87
|
+
_log?.error('matter controller close error:', err.message);
|
|
88
|
+
} finally {
|
|
89
|
+
_server = null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── Device management ─────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* List all paired nodes.
|
|
97
|
+
* @returns {{ nodeId: string, online: boolean }[]}
|
|
98
|
+
*/
|
|
99
|
+
function listPaired() {
|
|
100
|
+
if (!_server) return [];
|
|
101
|
+
const result = [];
|
|
102
|
+
for (const node of _server.peers) {
|
|
103
|
+
const addr = node.peerAddress;
|
|
104
|
+
if (!addr) continue; // not commissioned yet (in discovery)
|
|
105
|
+
result.push({
|
|
106
|
+
nodeId: _nodeIdStr(addr.nodeId),
|
|
107
|
+
online: node.lifecycle?.isOnline ?? false,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Commission a new device.
|
|
115
|
+
*
|
|
116
|
+
* @param {{ passcode: number, discriminator?: number } | { pairingCode: string }} options
|
|
117
|
+
* @returns {Promise<string>} nodeId of the newly commissioned device
|
|
118
|
+
*/
|
|
119
|
+
async function commission(options) {
|
|
120
|
+
if (!_server) throw new Error('Matter controller not started');
|
|
121
|
+
const clientNode = await _server.peers.commission(options);
|
|
122
|
+
const addr = clientNode.peerAddress;
|
|
123
|
+
if (!addr) throw new Error('Commission succeeded but node has no peerAddress');
|
|
124
|
+
const nodeId = _nodeIdStr(addr.nodeId);
|
|
125
|
+
_subscribeNodeLifecycle(clientNode, nodeId);
|
|
126
|
+
_broadcast?.({ type: 'matter:deviceList', devices: listPaired() });
|
|
127
|
+
return nodeId;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Decommission and locally delete a paired node.
|
|
132
|
+
* Tries graceful decommission first; falls back to force-delete.
|
|
133
|
+
*
|
|
134
|
+
* @param {string} nodeId
|
|
135
|
+
*/
|
|
136
|
+
async function unpair(nodeId) {
|
|
137
|
+
const node = _findClientNode(nodeId);
|
|
138
|
+
try {
|
|
139
|
+
await node.decommission();
|
|
140
|
+
} catch (err) {
|
|
141
|
+
_log?.warn(`matter: decommission of ${nodeId} failed (${err.message}), force-deleting`);
|
|
142
|
+
await node.delete();
|
|
143
|
+
}
|
|
144
|
+
_broadcast?.({ type: 'matter:deviceList', devices: listPaired() });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Return the endpoint structure of a paired node.
|
|
149
|
+
* Each endpoint entry carries the list of available cluster names.
|
|
150
|
+
*
|
|
151
|
+
* @param {string} nodeId
|
|
152
|
+
* @returns {{ endpointId: number, clusters: string[] }[]}
|
|
153
|
+
*/
|
|
154
|
+
function getEndpoints(nodeId) {
|
|
155
|
+
const node = _findClientNode(nodeId);
|
|
156
|
+
const result = [];
|
|
157
|
+
for (const endpoint of node.endpoints) {
|
|
158
|
+
const clusters = endpoint.state ? Object.keys(endpoint.state) : [];
|
|
159
|
+
result.push({ endpointId: endpoint.number ?? 0, clusters });
|
|
160
|
+
}
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── Attribute access ──────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Read a single attribute value.
|
|
168
|
+
*
|
|
169
|
+
* @param {string} nodeId
|
|
170
|
+
* @param {number} endpointId
|
|
171
|
+
* @param {string} clusterName camelCase cluster name, e.g. "onOff"
|
|
172
|
+
* @param {string} attrName camelCase attribute name, e.g. "onOff"
|
|
173
|
+
* @returns {Promise<unknown>}
|
|
174
|
+
*/
|
|
175
|
+
async function getAttribute(nodeId, endpointId, clusterName, attrName) {
|
|
176
|
+
const node = _findClientNode(nodeId);
|
|
177
|
+
const endpoint = node.endpoints.for(endpointId);
|
|
178
|
+
const clusterState = endpoint.state?.[_clusterName(clusterName)];
|
|
179
|
+
if (!clusterState) throw new Error(`Cluster "${clusterName}" not found on endpoint ${endpointId}`);
|
|
180
|
+
return clusterState[attrName];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── Commands ──────────────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Invoke a cluster command.
|
|
187
|
+
*
|
|
188
|
+
* @param {string} nodeId
|
|
189
|
+
* @param {number} endpointId
|
|
190
|
+
* @param {string} clusterName camelCase cluster name, e.g. "onOff"
|
|
191
|
+
* @param {string} commandName camelCase command name, e.g. "on"
|
|
192
|
+
* @param {object} [args={}]
|
|
193
|
+
* @returns {Promise<unknown>}
|
|
194
|
+
*/
|
|
195
|
+
async function sendCommand(nodeId, endpointId, clusterName, commandName, args) {
|
|
196
|
+
const node = _findClientNode(nodeId);
|
|
197
|
+
return node.act(`she.matter.send(${nodeId}, ${endpointId}, ${clusterName}.${commandName})`, async (agent) => {
|
|
198
|
+
const rootParts = agent.parts;
|
|
199
|
+
// Navigate to the target endpoint
|
|
200
|
+
const ep = rootParts ? rootParts.get(endpointId) : null;
|
|
201
|
+
if (!ep) throw new Error(`Endpoint ${endpointId} not found on node ${nodeId}`);
|
|
202
|
+
const clusterAgent = ep[_clusterName(clusterName)];
|
|
203
|
+
if (!clusterAgent) throw new Error(`Cluster "${clusterName}" not found`);
|
|
204
|
+
const cmd = clusterAgent[commandName];
|
|
205
|
+
if (typeof cmd !== 'function') throw new Error(`Command "${commandName}" not found in cluster "${clusterName}"`);
|
|
206
|
+
return cmd.call(clusterAgent, args ?? {});
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── Node lifecycle events (online / offline) ────────────────────────────────
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Subscribe to online/offline lifecycle changes for a node and broadcast them.
|
|
214
|
+
* @param {object} node ClientNode
|
|
215
|
+
* @param {string} nodeId decimal string
|
|
216
|
+
*/
|
|
217
|
+
function _subscribeNodeLifecycle(node, nodeId) {
|
|
218
|
+
const lc = node.lifecycle;
|
|
219
|
+
if (!lc) return;
|
|
220
|
+
lc.online?.on?.(() => {
|
|
221
|
+
_broadcast?.({ type: 'matter:deviceStatus', nodeId, online: true });
|
|
222
|
+
});
|
|
223
|
+
lc.offline?.on?.(() => {
|
|
224
|
+
_broadcast?.({ type: 'matter:deviceStatus', nodeId, online: false });
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── Subscriptions (for sandbox) ───────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Subscribe to attribute changes on a specific cluster attribute.
|
|
232
|
+
* Returns a listenerId that can be passed to unsubscribe().
|
|
233
|
+
*
|
|
234
|
+
* @param {string} scriptFile For cleanup tracking
|
|
235
|
+
* @param {string} nodeId
|
|
236
|
+
* @param {number} endpointId
|
|
237
|
+
* @param {string} clusterName camelCase cluster name
|
|
238
|
+
* @param {string} attrName camelCase attribute name
|
|
239
|
+
* @param {Function} callback (value, oldValue) => void
|
|
240
|
+
* @returns {number} listenerId
|
|
241
|
+
*/
|
|
242
|
+
function subscribeAttribute(scriptFile, nodeId, endpointId, clusterName, attrName, callback) {
|
|
243
|
+
const node = _findClientNode(nodeId);
|
|
244
|
+
const endpoint = node.endpoints.for(endpointId);
|
|
245
|
+
const events = endpoint.events?.[_clusterName(clusterName)];
|
|
246
|
+
if (!events) throw new Error(`Cluster "${clusterName}" not found on endpoint ${endpointId}`);
|
|
247
|
+
const changeEvent = events[`${attrName}$Changed`];
|
|
248
|
+
if (!changeEvent) throw new Error(`Attribute "${attrName}" on cluster "${clusterName}" has no change event`);
|
|
249
|
+
|
|
250
|
+
const listenerId = _nextListenerId++;
|
|
251
|
+
const cancel = changeEvent.on((value, oldValue) => {
|
|
252
|
+
try {
|
|
253
|
+
callback(value, oldValue);
|
|
254
|
+
} catch (err) {
|
|
255
|
+
_log?.error(`matter subscriber error in ${scriptFile}:`, err.message);
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
if (!_listeners.has(scriptFile)) _listeners.set(scriptFile, new Map());
|
|
260
|
+
_listeners.get(scriptFile).set(listenerId, { cancel });
|
|
261
|
+
return listenerId;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Remove a specific subscription by listenerId.
|
|
266
|
+
*/
|
|
267
|
+
function unsubscribe(scriptFile, listenerId) {
|
|
268
|
+
const scriptListeners = _listeners.get(scriptFile);
|
|
269
|
+
if (!scriptListeners) return;
|
|
270
|
+
const entry = scriptListeners.get(listenerId);
|
|
271
|
+
if (entry) {
|
|
272
|
+
entry.cancel();
|
|
273
|
+
scriptListeners.delete(listenerId);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Remove all subscriptions registered by a script (called on hot-reload).
|
|
279
|
+
*
|
|
280
|
+
* @param {string} scriptFile
|
|
281
|
+
*/
|
|
282
|
+
function cleanup(scriptFile) {
|
|
283
|
+
const scriptListeners = _listeners.get(scriptFile);
|
|
284
|
+
if (!scriptListeners) return;
|
|
285
|
+
for (const { cancel } of scriptListeners.values()) {
|
|
286
|
+
try {
|
|
287
|
+
cancel();
|
|
288
|
+
} catch {
|
|
289
|
+
// ignore
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
_listeners.delete(scriptFile);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
module.exports = {
|
|
296
|
+
init,
|
|
297
|
+
close,
|
|
298
|
+
listPaired,
|
|
299
|
+
commission,
|
|
300
|
+
unpair,
|
|
301
|
+
getEndpoints,
|
|
302
|
+
getAttribute,
|
|
303
|
+
sendCommand,
|
|
304
|
+
subscribeAttribute,
|
|
305
|
+
unsubscribe,
|
|
306
|
+
cleanup,
|
|
307
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { registerRoute } = require('../web/server');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Sandbox module — adds she.api.{get,post,put,delete} to every script.
|
|
7
|
+
*
|
|
8
|
+
* Routes are registered under /api/<scriptName><routePath>, e.g.
|
|
9
|
+
* she.api.get('/hello', () => ({ ok: true }))
|
|
10
|
+
* → GET /api/myscript/hello
|
|
11
|
+
*
|
|
12
|
+
* Throws if the same method+path is registered more than once.
|
|
13
|
+
*
|
|
14
|
+
* @param {object} she - per-script sandbox she object
|
|
15
|
+
* @param {object} ctx
|
|
16
|
+
* @param {string} ctx.scriptName - basename of the script file without extension
|
|
17
|
+
*/
|
|
18
|
+
module.exports = function (she, { scriptName }) {
|
|
19
|
+
/**
|
|
20
|
+
* Build the Express route handler that calls the user-supplied function.
|
|
21
|
+
* - GET / DELETE: handler(req) → value | Promise
|
|
22
|
+
* - POST / PUT: handler(req, body) → value | Promise
|
|
23
|
+
* req = { params, query, headers }
|
|
24
|
+
*/
|
|
25
|
+
function makeExpressHandler(userHandler, hasBody) {
|
|
26
|
+
return function (req, res) {
|
|
27
|
+
let result;
|
|
28
|
+
try {
|
|
29
|
+
result = hasBody
|
|
30
|
+
? userHandler({ params: req.params, query: req.query, headers: req.headers }, req.body)
|
|
31
|
+
: userHandler({ params: req.params, query: req.query, headers: req.headers });
|
|
32
|
+
} catch (err) {
|
|
33
|
+
res.status(500).json({ error: err.message });
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
Promise.resolve(result)
|
|
37
|
+
.then((val) => res.json(val !== undefined ? val : null))
|
|
38
|
+
.catch((err) => res.status(500).json({ error: err.message }));
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function apiMethod(method, hasBody) {
|
|
43
|
+
return function (routePath, handler) {
|
|
44
|
+
if (typeof routePath !== 'string') throw new TypeError('path must be a string');
|
|
45
|
+
if (typeof handler !== 'function') throw new TypeError('handler must be a function');
|
|
46
|
+
const fullPath = '/api/' + scriptName + routePath;
|
|
47
|
+
registerRoute(method, fullPath, makeExpressHandler(handler, hasBody));
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
she.api = {
|
|
52
|
+
get: apiMethod('get', false),
|
|
53
|
+
post: apiMethod('post', true),
|
|
54
|
+
put: apiMethod('put', true),
|
|
55
|
+
delete: apiMethod('delete', false),
|
|
56
|
+
};
|
|
57
|
+
};
|