smart-home-engine 1.15.1 → 1.17.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.
@@ -1,4 +1,4 @@
1
- import{m as O}from"./monaco-langs-Dt8a9eeo.js";import{t as I}from"./index-Vw4M_nL7.js";/*!-----------------------------------------------------------------------------
1
+ import{m as O}from"./monaco-langs-Dt8a9eeo.js";import{t as I}from"./index-BWK6XOv0.js";/*!-----------------------------------------------------------------------------
2
2
  * Copyright (c) Microsoft Corporation. All rights reserved.
3
3
  * Version: 0.52.2(404545bded1df6ffa41ea0af4e8ddb219018c6c1)
4
4
  * Released under the MIT license
@@ -172,10 +172,10 @@
172
172
  }
173
173
  })();
174
174
  </script>
175
- <script type="module" crossorigin src="/assets/index-Vw4M_nL7.js"></script>
175
+ <script type="module" crossorigin src="/assets/index-BWK6XOv0.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-LTpStzqg.css">
178
+ <link rel="stylesheet" crossorigin href="/assets/index-C-4azUYf.css">
179
179
  </head>
180
180
  <body>
181
181
  <div id="app"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smart-home-engine",
3
- "version": "1.15.1",
3
+ "version": "1.17.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": {
@@ -13,9 +13,12 @@
13
13
  * she.db.prop(id, method, prop, val)→ void (method: 'set'|'create'|'del')
14
14
  * she.db.sub(pattern, callback) → void (callback(id, doc))
15
15
  * she.db.query(filter, mapFn, reduceFn) → Array (ad-hoc synchronous query)
16
+ * she.db.getView(id) → Array|undefined (current view result)
17
+ * she.db.subView(pattern, callback) → void (callback(id, result))
18
+ * she.db.setView(id, definition) → void (create/update named view)
16
19
  */
17
20
 
18
- const { getCore, addListener, removeListenersByScript } = require('../web/shedb');
21
+ const { getCore, addListener, removeListenersByScript, addViewListener, removeViewListenersByScript } = require('../web/shedb');
19
22
 
20
23
  module.exports = function (she, { scriptDomain, scriptName, scriptFile }) {
21
24
  const core = getCore();
@@ -80,6 +83,57 @@ module.exports = function (she, { scriptDomain, scriptName, scriptFile }) {
80
83
  if (!core) return [];
81
84
  return core.adhocQuery(filter, mapFn, reduceFn);
82
85
  },
86
+
87
+ /**
88
+ * Return the current computed result array for a named view, or undefined if
89
+ * the view does not exist, has not yet completed, or produced an error.
90
+ *
91
+ * @param {string} id - view name
92
+ * @returns {Array|undefined}
93
+ */
94
+ getView(id) {
95
+ if (!core) return undefined;
96
+ const view = core.views[id];
97
+ if (!view || view.error) return undefined;
98
+ return view.result;
99
+ },
100
+
101
+ /**
102
+ * Subscribe to view result changes matching an MQTT wildcard pattern.
103
+ * callback(id, result) fires whenever a matching view's result changes;
104
+ * result is undefined if the view errored.
105
+ * Subscriptions are automatically removed when the script is hot-reloaded.
106
+ *
107
+ * @param {string} pattern - MQTT wildcard, e.g. 'stats/#'
108
+ * @param {Function} callback - called as callback(id, result)
109
+ */
110
+ subView(pattern, callback) {
111
+ if (!core) return;
112
+ const wrapped = scriptDomain.bind(callback);
113
+ addViewListener(pattern, wrapped, trackingKey);
114
+ },
115
+
116
+ /**
117
+ * Create or update a named persistent view.
118
+ * Definition: { map, filter?, reduce?, publish?, retain?, description? }
119
+ * `publish` maps to `mqttpub` internally.
120
+ *
121
+ * @param {string} id
122
+ * @param {{ map: string, filter?: string, reduce?: string, publish?: boolean, retain?: boolean, description?: string }} definition
123
+ */
124
+ setView(id, definition) {
125
+ if (!core) return;
126
+ if (!definition || typeof definition !== 'object') return;
127
+ const { map, filter, reduce, publish, retain, description } = definition;
128
+ if (typeof map !== 'string' || !map.trim()) return;
129
+ const payload = { map };
130
+ if (filter) payload.filter = filter;
131
+ if (reduce) payload.reduce = reduce;
132
+ if (publish) payload.mqttpub = true;
133
+ if (retain) payload.retain = true;
134
+ if (description) payload.description = String(description).slice(0, 500);
135
+ core.query(id, payload);
136
+ },
83
137
  };
84
138
  };
85
139
 
@@ -89,4 +143,5 @@ module.exports = function (she, { scriptDomain, scriptName, scriptFile }) {
89
143
  */
90
144
  module.exports.cleanup = function (scriptFile) {
91
145
  removeListenersByScript(scriptFile);
146
+ removeViewListenersByScript(scriptFile);
92
147
  };
@@ -950,6 +950,150 @@ router.get('/logs', (req, res) => {
950
950
  res.json(limit >= all.length ? all : all.slice(-limit));
951
951
  });
952
952
 
953
+ // ── Password file management ──────────────────────────────────────────────────
954
+
955
+ /** POSIX shell-safe single-quote escaping for SSH commands */
956
+ function shellArg(s) {
957
+ return "'" + String(s).replace(/'/g, "'\\''") + "'";
958
+ }
959
+
960
+ /**
961
+ * GET /she/broker/passwd?file=<path>
962
+ * List usernames from a mosquitto password file.
963
+ */
964
+ router.get('/passwd', async (req, res) => {
965
+ try {
966
+ const bc = getBrokerConfig(req);
967
+ const file = String(req.query.file || '').trim();
968
+ if (!file || !file.startsWith('/')) return res.status(400).json({ error: 'absolute file path required' });
969
+
970
+ let text = '';
971
+ if (bc.ssh && bc.ssh.host) {
972
+ const result = await sshDeploy.runCommand(bc.ssh, `sudo cat -- ${shellArg(file)} 2>/dev/null || true`);
973
+ text = result.stdout;
974
+ } else {
975
+ try {
976
+ text = fs.readFileSync(file, 'utf8');
977
+ } catch (e) {
978
+ if (e.code !== 'ENOENT') throw e;
979
+ }
980
+ }
981
+ const users = text
982
+ .split('\n')
983
+ .filter((l) => l.includes(':'))
984
+ .map((l) => l.split(':')[0])
985
+ .filter(Boolean);
986
+ res.json({ users });
987
+ } catch (err) {
988
+ handleError(res, err);
989
+ }
990
+ });
991
+
992
+ /**
993
+ * POST /she/broker/passwd
994
+ * Add or update a user in a mosquitto password file.
995
+ * Body: { file, username, password }
996
+ */
997
+ router.post('/passwd', async (req, res) => {
998
+ try {
999
+ const bc = getBrokerConfig(req);
1000
+ const { file, username, password } = req.body;
1001
+ if (!file || typeof file !== 'string' || !file.startsWith('/')) return res.status(400).json({ error: 'absolute file path required' });
1002
+ if (!username || typeof username !== 'string') return res.status(400).json({ error: 'username required' });
1003
+ if (!password || typeof password !== 'string') return res.status(400).json({ error: 'password required' });
1004
+ if (/[:\n\r]/.test(username)) return res.status(400).json({ error: 'invalid username' });
1005
+
1006
+ if (bc.ssh && bc.ssh.host) {
1007
+ const cmd = `sudo mosquitto_passwd -b ${shellArg(file)} ${shellArg(username)} ${shellArg(password)}`;
1008
+ await sshDeploy.runCommand(bc.ssh, cmd);
1009
+ } else {
1010
+ await execFileAsync('mosquitto_passwd', ['-b', file, username, password]);
1011
+ }
1012
+ res.json({ ok: true });
1013
+ } catch (err) {
1014
+ handleError(res, err);
1015
+ }
1016
+ });
1017
+
1018
+ /**
1019
+ * DELETE /she/broker/passwd/:user
1020
+ * Delete a user from a mosquitto password file.
1021
+ * Body: { file }
1022
+ */
1023
+ router.delete('/passwd/:user', async (req, res) => {
1024
+ try {
1025
+ const bc = getBrokerConfig(req);
1026
+ const { file } = req.body;
1027
+ const username = req.params.user;
1028
+ if (!file || typeof file !== 'string' || !file.startsWith('/')) return res.status(400).json({ error: 'absolute file path required' });
1029
+
1030
+ if (bc.ssh && bc.ssh.host) {
1031
+ const cmd = `sudo mosquitto_passwd -D ${shellArg(file)} ${shellArg(username)}`;
1032
+ await sshDeploy.runCommand(bc.ssh, cmd);
1033
+ } else {
1034
+ await execFileAsync('mosquitto_passwd', ['-D', file, username]);
1035
+ }
1036
+ res.json({ ok: true });
1037
+ } catch (err) {
1038
+ handleError(res, err);
1039
+ }
1040
+ });
1041
+
1042
+ // ── ACL file management ───────────────────────────────────────────────────────
1043
+
1044
+ /**
1045
+ * GET /she/broker/acl?file=<path>
1046
+ * Read a mosquitto ACL file (static file, not dynsec).
1047
+ */
1048
+ router.get('/acl', async (req, res) => {
1049
+ try {
1050
+ const bc = getBrokerConfig(req);
1051
+ const file = String(req.query.file || '').trim();
1052
+ if (!file || !file.startsWith('/')) return res.status(400).json({ error: 'absolute file path required' });
1053
+
1054
+ let content = '';
1055
+ if (bc.ssh && bc.ssh.host) {
1056
+ try {
1057
+ content = await sshDeploy.readRemoteFile(bc.ssh, file);
1058
+ } catch (e) {
1059
+ if (!String(e.message).toLowerCase().includes('no such file')) throw e;
1060
+ }
1061
+ } else {
1062
+ try {
1063
+ content = fs.readFileSync(file, 'utf8');
1064
+ } catch (e) {
1065
+ if (e.code !== 'ENOENT') throw e;
1066
+ }
1067
+ }
1068
+ res.json({ content });
1069
+ } catch (err) {
1070
+ handleError(res, err);
1071
+ }
1072
+ });
1073
+
1074
+ /**
1075
+ * PUT /she/broker/acl
1076
+ * Write a mosquitto ACL file.
1077
+ * Body: { file, content }
1078
+ */
1079
+ router.put('/acl', async (req, res) => {
1080
+ try {
1081
+ const bc = getBrokerConfig(req);
1082
+ const { file, content } = req.body;
1083
+ if (!file || typeof file !== 'string' || !file.startsWith('/')) return res.status(400).json({ error: 'absolute file path required' });
1084
+ if (typeof content !== 'string') return res.status(400).json({ error: 'content must be a string' });
1085
+
1086
+ if (bc.ssh && bc.ssh.host) {
1087
+ await sshDeploy.uploadContent(bc.ssh, content, file);
1088
+ } else {
1089
+ fs.writeFileSync(file, content, 'utf8');
1090
+ }
1091
+ res.json({ ok: true });
1092
+ } catch (err) {
1093
+ handleError(res, err);
1094
+ }
1095
+ });
1096
+
953
1097
  module.exports = { router, setLogger, setStore };
954
1098
 
955
1099
  // ── dynsec: ACL topic inspection ──────────────────────────────────────────────
package/src/web/shedb.js CHANGED
@@ -23,6 +23,9 @@ let _broadcast = () => {};
23
23
  /** Registry for she.db.sub() sandbox subscriptions */
24
24
  const _listeners = []; // { pattern: string, callback: Function, _script: string }
25
25
 
26
+ /** Registry for she.db.subView() sandbox subscriptions */
27
+ const _viewListeners = []; // { pattern: string, callback: Function, _script: string }
28
+
26
29
  /**
27
30
  * Initialise sheDB.
28
31
  *
@@ -80,6 +83,18 @@ function init({ dbPath, dbPublish, dbRetain, dbPrefix, mqttName, mqtt, log, broa
80
83
  if (query && query.mqttpub && _mqtt && view && !view.error) {
81
84
  _mqtt.publish(_dbPrefix + 'view/' + id, JSON.stringify(view.result ?? []), { retain: query.retain === true });
82
85
  }
86
+
87
+ // Fire sandbox she.db.subView() listeners
88
+ const result = view && !view.error ? view.result : undefined;
89
+ for (const sub of _viewListeners) {
90
+ if (mqttWildcard(id, sub.pattern) !== null) {
91
+ try {
92
+ sub.callback(id, result);
93
+ } catch {
94
+ /* errors are caught by the script domain wrapper */
95
+ }
96
+ }
97
+ }
83
98
  });
84
99
 
85
100
  // When a view definition is saved, immediately publish the existing cached
@@ -165,16 +180,28 @@ function addListener(pattern, callback, scriptName) {
165
180
  _listeners.push({ pattern, callback, _script: scriptName });
166
181
  }
167
182
 
168
- /** Remove all listeners registered by a script (called on hot-reload / unload). */
183
+ /** Remove all she.db.sub() listeners registered by a script (called on hot-reload / unload). */
169
184
  function removeListenersByScript(scriptName) {
170
185
  for (let i = _listeners.length - 1; i >= 0; i--) {
171
186
  if (_listeners[i]._script === scriptName) _listeners.splice(i, 1);
172
187
  }
173
188
  }
174
189
 
190
+ /** Register a she.db.subView() listener. */
191
+ function addViewListener(pattern, callback, scriptName) {
192
+ _viewListeners.push({ pattern, callback, _script: scriptName });
193
+ }
194
+
195
+ /** Remove all she.db.subView() listeners registered by a script (called on hot-reload / unload). */
196
+ function removeViewListenersByScript(scriptName) {
197
+ for (let i = _viewListeners.length - 1; i >= 0; i--) {
198
+ if (_viewListeners[i]._script === scriptName) _viewListeners.splice(i, 1);
199
+ }
200
+ }
201
+
175
202
  /** Return the live core instance (used by shedb-api.js). */
176
203
  function getCore() {
177
204
  return _core;
178
205
  }
179
206
 
180
- module.exports = { init, handleMqttMessage, addListener, removeListenersByScript, getCore };
207
+ module.exports = { init, handleMqttMessage, addListener, removeListenersByScript, addViewListener, removeViewListenersByScript, getCore };