smart-home-engine 0.16.1 → 0.18.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smart-home-engine",
3
- "version": "0.16.1",
3
+ "version": "0.18.0",
4
4
  "description": "Node.js based script runner for use in MQTT based Smart Home environments",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
package/src/index.js CHANGED
@@ -189,8 +189,8 @@ function sunScheduleEvent(obj, shift) {
189
189
  // call the callback immediately!
190
190
  obj.domain.bind(obj.callback)();
191
191
  } else {
192
- // Schedule the event!
193
- scheduler.scheduleJob(event, obj.domain.bind(obj.callback));
192
+ // Schedule the event and track the job so it can be cancelled on script unload
193
+ obj._job = scheduler.scheduleJob(event, obj.domain.bind(obj.callback));
194
194
  }
195
195
  }
196
196
  }
@@ -659,7 +659,10 @@ function runScript(script, name, origin) {
659
659
  if (options.random) {
660
660
  _myJobs.push(
661
661
  scheduler.scheduleJob(pattern, () => {
662
- setTimeout(scriptDomain.bind(callback), (parseFloat(options.random) || 0) * 1000 * Math.random());
662
+ // Track the random-delay timer so it is cancelled on unload
663
+ // if the job fires in the same tick as the script is reloaded.
664
+ const id = setTimeout(scriptDomain.bind(callback), (parseFloat(options.random) || 0) * 1000 * Math.random());
665
+ _myTimers.add(id);
663
666
  }),
664
667
  );
665
668
  } else {
@@ -935,8 +938,19 @@ function runScript(script, name, origin) {
935
938
  };
936
939
 
937
940
  const scriptName = path.basename(name, path.extname(name));
941
+ // she.setTimeout / she.clearTimeout — tracked versions for use by stdlib and
942
+ // sandbox modules that don't have direct access to the Sandbox context.
943
+ she.setTimeout = (fn, delay, ...args) => {
944
+ const id = setTimeout(fn, delay, ...args);
945
+ _myTimers.add(id);
946
+ return id;
947
+ };
948
+ she.clearTimeout = (id) => {
949
+ _myTimers.delete(id);
950
+ clearTimeout(id);
951
+ };
938
952
  sandboxModules.forEach((md) => {
939
- md(she, { scriptDomain, scriptName });
953
+ md(she, { scriptDomain, scriptName, scriptFile: name });
940
954
  });
941
955
 
942
956
  log.debug(name, 'contextifying sandbox');
@@ -1010,6 +1024,10 @@ function unloadScript(file) {
1010
1024
  if (mqttEventCallbacks[i]._script === file) mqttEventCallbacks.splice(i, 1);
1011
1025
  }
1012
1026
 
1027
+ // Remove HTTP routes registered by this script via she.api
1028
+ const scriptName = path.basename(file, path.extname(file));
1029
+ require('./web/server').unregisterRoutesByScript(scriptName);
1030
+
1013
1031
  // Cancel all node-schedule jobs for this script
1014
1032
  const jobs = scriptJobs.get(file);
1015
1033
  if (jobs) {
@@ -1017,9 +1035,12 @@ function unloadScript(file) {
1017
1035
  scriptJobs.delete(file);
1018
1036
  }
1019
1037
 
1020
- // Remove sun events belonging to this script
1038
+ // Remove sun events belonging to this script and cancel any pending scheduled job
1021
1039
  for (let i = sunEvents.length - 1; i >= 0; i--) {
1022
- if (sunEvents[i]._script === file) sunEvents.splice(i, 1);
1040
+ if (sunEvents[i]._script === file) {
1041
+ if (sunEvents[i]._job) sunEvents[i]._job.cancel();
1042
+ sunEvents.splice(i, 1);
1043
+ }
1023
1044
  }
1024
1045
 
1025
1046
  // Clear all tracked timers for this script
@@ -26,7 +26,10 @@
26
26
 
27
27
  const controller = require('../matter/controller');
28
28
 
29
- module.exports = function (she, { scriptDomain, scriptName }) {
29
+ module.exports = function (she, { scriptDomain, scriptName, scriptFile }) {
30
+ // Use full file path as the tracking key so cleanup() called from
31
+ // unloadScript() (which passes the full path) matches what was registered.
32
+ const trackingKey = scriptFile || scriptName;
30
33
  she.matter = {
31
34
  /**
32
35
  * Subscribe to an attribute change on a paired Matter device.
@@ -40,7 +43,7 @@ module.exports = function (she, { scriptDomain, scriptName }) {
40
43
  */
41
44
  sub(nodeId, endpointId, clusterName, attrName, callback) {
42
45
  try {
43
- return controller.subscribeAttribute(scriptName, nodeId, endpointId, clusterName, attrName, callback);
46
+ return controller.subscribeAttribute(trackingKey, nodeId, endpointId, clusterName, attrName, callback);
44
47
  } catch (err) {
45
48
  scriptDomain.emit('error', err);
46
49
  }
@@ -52,7 +55,7 @@ module.exports = function (she, { scriptDomain, scriptName }) {
52
55
  * @param {number} listenerId Returned by she.matter.sub()
53
56
  */
54
57
  unsub(listenerId) {
55
- controller.unsubscribe(scriptName, listenerId);
58
+ controller.unsubscribe(trackingKey, listenerId);
56
59
  },
57
60
 
58
61
  /**
@@ -17,8 +17,11 @@
17
17
 
18
18
  const { getCore, addListener, removeListenersByScript } = require('../web/shedb');
19
19
 
20
- module.exports = function (she, { scriptDomain, scriptName }) {
20
+ module.exports = function (she, { scriptDomain, scriptName, scriptFile }) {
21
21
  const core = getCore();
22
+ // Use the full file path as the tracking key so cleanup() in index.js
23
+ // (which passes the full path) matches what was registered here.
24
+ const trackingKey = scriptFile || scriptName;
22
25
 
23
26
  // sheDB may not be initialised (--db-path not given or startup still in progress).
24
27
  // We expose stubs that are no-ops / return undefined so user scripts don't crash.
@@ -62,7 +65,7 @@ module.exports = function (she, { scriptDomain, scriptName }) {
62
65
  if (!core) return;
63
66
  // Wrap in script domain so errors don't crash the process
64
67
  const wrapped = scriptDomain.bind(callback);
65
- addListener(pattern, wrapped, scriptName);
68
+ addListener(pattern, wrapped, trackingKey);
66
69
  },
67
70
 
68
71
  /**
@@ -88,11 +88,11 @@ module.exports = function (she) {
88
88
  she.timer = function (src, target, time) {
89
89
  she.mqttsub(src, { retain: false }, (topic, val) => {
90
90
  if (val) {
91
- clearTimeout(timeouts[target]);
91
+ she.clearTimeout(timeouts[target]);
92
92
  if (!she.getValue(target)) {
93
93
  she.setValue(target, 1);
94
94
  }
95
- timeouts[target] = setTimeout(() => {
95
+ timeouts[target] = she.setTimeout(() => {
96
96
  if (she.getValue(target)) {
97
97
  she.setValue(target, 0);
98
98
  }
@@ -100,7 +100,7 @@ module.exports = function (she) {
100
100
  }
101
101
  });
102
102
 
103
- timeouts[target] = setTimeout(() => {
103
+ timeouts[target] = she.setTimeout(() => {
104
104
  if (she.getValue(target)) {
105
105
  she.setValue(target, 0);
106
106
  }
@@ -7,14 +7,16 @@ A view has three optional parts:
7
7
  Examples: `devices/+/state` matches `devices/lamp1/state`. `sensors/#` matches all IDs starting with `sensors/`.
8
8
  ⚠️ `*` is NOT a valid MQTT wildcard — never use it. Use `#` for "match everything".
9
9
 
10
- 2. **Map** — a JavaScript function body. `this` is the current document. Call `emit(value)` to include a value in the result array. No `return`.
10
+ 2. **Map** — a JavaScript function body. `this` is the current document. Call `emit(this)` to include a document in the result array. No `return`.
11
11
 
12
12
  3. **Reduce** — a JavaScript function body that receives `result` (the array from map) and must `return` a transformed value.
13
13
 
14
+ Don't suggest scripts utilizing the she.db.* api, you should only propose view parts.
15
+
14
16
  When proposing view parts, use these exact formats (include only the parts that change):
15
17
 
16
18
  ```filter
17
- devices/#
19
+ #
18
20
  ```
19
21
 
20
22
  ```javascript
package/src/web/server.js CHANGED
@@ -82,8 +82,15 @@ app.use((req, res, next) => {
82
82
  // Central route registry — key: 'METHOD /api/scriptname/path'
83
83
  const registry = new Map();
84
84
 
85
+ // Per-script Express sub-routers for she.api routes.
86
+ // Each script gets one Router mounted at /api/<scriptName>; the layer reference
87
+ // is kept so it can be spliced out of the app's middleware stack on unload.
88
+ const scriptRouters = new Map(); // scriptName → { router, layer }
89
+
85
90
  /**
86
91
  * Register an HTTP route. Throws if the same method+path pair is already registered.
92
+ * Routes under /api/<scriptName>/... are grouped into a per-script sub-router so
93
+ * they can all be removed at once when the script is unloaded.
87
94
  * @param {'get'|'post'|'put'|'delete'} method
88
95
  * @param {string} fullPath - absolute Express path, e.g. '/api/myscript/foo'
89
96
  * @param {Function} handler - Express route handler (req, res)
@@ -94,7 +101,51 @@ function registerRoute(method, fullPath, handler) {
94
101
  throw new Error(`Route already registered: ${key}`);
95
102
  }
96
103
  registry.set(key, true);
97
- app[method](fullPath, handler);
104
+
105
+ // Route belongs to a user script — use a per-script sub-router.
106
+ const m = fullPath.match(/^\/api\/([^/]+)(\/.*)?$/);
107
+ if (m) {
108
+ const scriptName = m[1];
109
+ const routePath = m[2] || '/';
110
+ let entry = scriptRouters.get(scriptName);
111
+ if (!entry) {
112
+ const router = express.Router();
113
+ app.use('/api/' + scriptName, router);
114
+ // Capture the layer Express just pushed onto its stack.
115
+ // Express 5 exposes the router via the public `app.router` getter.
116
+ const stack = app.router.stack;
117
+ const layer = stack[stack.length - 1];
118
+ entry = { router, layer };
119
+ scriptRouters.set(scriptName, entry);
120
+ }
121
+ entry.router[method](routePath, handler);
122
+ } else {
123
+ // Fallback for any non-/api/ paths (shouldn't occur in normal usage).
124
+ app[method](fullPath, handler);
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Remove all HTTP routes registered by a script and allow re-registration.
130
+ * Called by unloadScript() in index.js on hot-reload.
131
+ * @param {string} scriptName - basename without extension, e.g. 'myscript'
132
+ */
133
+ function unregisterRoutesByScript(scriptName) {
134
+ const entry = scriptRouters.get(scriptName);
135
+ if (entry) {
136
+ const stack = app.router?.stack;
137
+ if (stack) {
138
+ const idx = stack.indexOf(entry.layer);
139
+ if (idx !== -1) stack.splice(idx, 1);
140
+ }
141
+ scriptRouters.delete(scriptName);
142
+ }
143
+ // Clear registry entries so the routes can be re-registered on reload.
144
+ for (const key of [...registry.keys()]) {
145
+ if (key.includes('/api/' + scriptName + '/') || key.endsWith('/api/' + scriptName)) {
146
+ registry.delete(key);
147
+ }
148
+ }
98
149
  }
99
150
 
100
151
  let httpServer = null;
@@ -145,4 +196,4 @@ function stopServer() {
145
196
  });
146
197
  }
147
198
 
148
- module.exports = { app, registerRoute, setStatsProvider, startServer, stopServer };
199
+ module.exports = { app, registerRoute, unregisterRoutesByScript, setStatsProvider, startServer, stopServer };