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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +76 -0
  3. package/dist/web/assets/codicon-DCmgc-ay.ttf +0 -0
  4. package/dist/web/assets/index-Bdf2J0nm.js +140 -0
  5. package/dist/web/assets/index-DkhtWYJx.css +1 -0
  6. package/dist/web/assets/monaco-langs-DZ6hB11b.js +1423 -0
  7. package/dist/web/assets/monaco-langs-DyX1CsEw.css +1 -0
  8. package/dist/web/assets/tsMode-THvwQw-l.js +16 -0
  9. package/dist/web/index.html +164 -0
  10. package/dist/web/monacoeditorwork/editor.worker.bundle.js +13519 -0
  11. package/dist/web/monacoeditorwork/ts.worker.bundle.js +256353 -0
  12. package/package.json +84 -10
  13. package/src/config.js +53 -0
  14. package/src/elastic.js +19 -0
  15. package/src/index.js +1184 -0
  16. package/src/influx.js +25 -0
  17. package/src/lib/mqtt-wildcards.js +34 -0
  18. package/src/lib/parse-payload.js +29 -0
  19. package/src/lib/redis.js +74 -0
  20. package/src/lib/shedb-core.js +447 -0
  21. package/src/lib/shedb-worker.js +126 -0
  22. package/src/lib/state-store.js +97 -0
  23. package/src/lib/storage.js +74 -0
  24. package/src/matter/controller.js +307 -0
  25. package/src/sandbox/api.js +57 -0
  26. package/src/sandbox/elastic-sandbox.js +88 -0
  27. package/src/sandbox/influx-sandbox.js +107 -0
  28. package/src/sandbox/matter-sandbox.js +92 -0
  29. package/src/sandbox/shedb-sandbox.js +89 -0
  30. package/src/sandbox/stdlib.js +132 -0
  31. package/src/scripts/hello.js +3 -0
  32. package/src/web/ai-api.js +443 -0
  33. package/src/web/config-api.js +34 -0
  34. package/src/web/deps-api.js +138 -0
  35. package/src/web/git-api.js +188 -0
  36. package/src/web/log-ws.js +71 -0
  37. package/src/web/matter-api.js +102 -0
  38. package/src/web/mqtt-api.js +65 -0
  39. package/src/web/scripts-api.js +192 -0
  40. package/src/web/server.js +130 -0
  41. package/src/web/shedb-api.js +140 -0
  42. package/src/web/shedb.js +168 -0
  43. 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
+ };