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
package/src/index.js
ADDED
|
@@ -0,0 +1,1184 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* eslint-disable func-names */
|
|
3
|
+
/* eslint-disable func-name-matching */
|
|
4
|
+
/* eslint-disable camelcase */
|
|
5
|
+
|
|
6
|
+
/* eslint prefer-rest-params: "warn" */
|
|
7
|
+
/* eslint prefer-destructuring: "warn" */
|
|
8
|
+
|
|
9
|
+
/* eslint n/no-deprecated-api: "warn" */
|
|
10
|
+
|
|
11
|
+
// Ensure ~/.she/ exists before anything else runs
|
|
12
|
+
require('./lib/storage').ensureRoot();
|
|
13
|
+
|
|
14
|
+
const PinoPretty = require('pino-pretty');
|
|
15
|
+
const _pino = require('pino')(
|
|
16
|
+
{ level: 'debug' },
|
|
17
|
+
PinoPretty({
|
|
18
|
+
colorize: false,
|
|
19
|
+
translateTime: 'SYS:yyyy-mm-dd HH:MM:ss.l',
|
|
20
|
+
ignore: 'pid,hostname',
|
|
21
|
+
sync: true,
|
|
22
|
+
}),
|
|
23
|
+
);
|
|
24
|
+
// Lazy import — log-ws exports a no-op broadcastLog when the HTTP server is not started.
|
|
25
|
+
const { broadcastLog, broadcast } = require('./web/log-ws');
|
|
26
|
+
const shedb = require('./web/shedb');
|
|
27
|
+
const log = {
|
|
28
|
+
debug: (...args) => {
|
|
29
|
+
_pino.debug(args.join(' '));
|
|
30
|
+
broadcastLog({ level: 'debug', msg: args.join(' '), ts: Date.now() });
|
|
31
|
+
},
|
|
32
|
+
info: (...args) => {
|
|
33
|
+
_pino.info(args.join(' '));
|
|
34
|
+
broadcastLog({ level: 'info', msg: args.join(' '), ts: Date.now() });
|
|
35
|
+
},
|
|
36
|
+
warn: (...args) => {
|
|
37
|
+
_pino.warn(args.join(' '));
|
|
38
|
+
broadcastLog({ level: 'warn', msg: args.join(' '), ts: Date.now() });
|
|
39
|
+
},
|
|
40
|
+
error: (...args) => {
|
|
41
|
+
_pino.error(args.join(' '));
|
|
42
|
+
broadcastLog({ level: 'error', msg: args.join(' '), ts: Date.now() });
|
|
43
|
+
},
|
|
44
|
+
setLevel: (level) => {
|
|
45
|
+
_pino.level = level;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
const config = require('./config.js');
|
|
49
|
+
const pkg = require('../package.json');
|
|
50
|
+
|
|
51
|
+
log.setLevel(['debug', 'info', 'warn', 'error'].indexOf(config.verbosity) === -1 ? 'info' : config.verbosity);
|
|
52
|
+
log.info('she ' + pkg.version + ' starting');
|
|
53
|
+
log.debug('loaded config: ', config);
|
|
54
|
+
|
|
55
|
+
if (typeof config.port !== 'undefined') {
|
|
56
|
+
require('./web/server')
|
|
57
|
+
.startServer(config.port, {
|
|
58
|
+
apiKey: config.apiKey,
|
|
59
|
+
configPath: config.config,
|
|
60
|
+
scriptDir: config.dir || null,
|
|
61
|
+
})
|
|
62
|
+
.then((actualPort) => log.info('http server listening on :' + actualPort))
|
|
63
|
+
.catch((err) => {
|
|
64
|
+
log.error('http server start failed:', err.message);
|
|
65
|
+
process.exit(1);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const chokidar = require('chokidar');
|
|
70
|
+
const modules = {
|
|
71
|
+
'fs': require('fs'),
|
|
72
|
+
'path': require('path'),
|
|
73
|
+
'vm': require('vm'),
|
|
74
|
+
/* eslint-disable no-restricted-modules */
|
|
75
|
+
'domain': require('domain'),
|
|
76
|
+
'mqtt': require('mqtt'),
|
|
77
|
+
'node-schedule': require('node-schedule'),
|
|
78
|
+
'suncalc': require('suncalc'),
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// Require function anchored at ~/.she/ so user-installed npm packages
|
|
82
|
+
// in ~/.she/node_modules/ are resolved by sandbox scripts.
|
|
83
|
+
const Module = require('module');
|
|
84
|
+
const { STORAGE_ROOT } = require('./lib/storage');
|
|
85
|
+
const _userRequire = Module.createRequire(modules.path.join(STORAGE_ROOT, '_anchor.js'));
|
|
86
|
+
|
|
87
|
+
const domain = modules.domain;
|
|
88
|
+
const vm = modules.vm;
|
|
89
|
+
const fs = modules.fs;
|
|
90
|
+
const path = modules.path;
|
|
91
|
+
const scheduler = modules['node-schedule'];
|
|
92
|
+
const suncalc = modules.suncalc;
|
|
93
|
+
|
|
94
|
+
const StateStore = require('./lib/state-store');
|
|
95
|
+
const sandboxModules = [];
|
|
96
|
+
const store = new StateStore();
|
|
97
|
+
const scripts = {};
|
|
98
|
+
const scriptOrigins = new Map(); // file → 'builtin' | 'user'
|
|
99
|
+
const subscriptions = [];
|
|
100
|
+
const mqttEventCallbacks = [];
|
|
101
|
+
const varSubscriptions = []; // store-based var:: subscriptions { key, handler, _script }
|
|
102
|
+
|
|
103
|
+
// Per-script resource tracking for hot-reload
|
|
104
|
+
const scriptJobs = new Map(); // scriptFile → node-schedule Job[]
|
|
105
|
+
const scriptTimers = new Map(); // scriptFile → Set<timer id>
|
|
106
|
+
|
|
107
|
+
const _global = {};
|
|
108
|
+
|
|
109
|
+
// Sun scheduling
|
|
110
|
+
|
|
111
|
+
const SUNCALC_EVENTS = new Set([
|
|
112
|
+
'sunrise',
|
|
113
|
+
'sunriseEnd',
|
|
114
|
+
'goldenHourEnd',
|
|
115
|
+
'solarNoon',
|
|
116
|
+
'goldenHour',
|
|
117
|
+
'sunsetStart',
|
|
118
|
+
'sunset',
|
|
119
|
+
'dusk',
|
|
120
|
+
'nauticalDusk',
|
|
121
|
+
'night',
|
|
122
|
+
'nadir',
|
|
123
|
+
'nightEnd',
|
|
124
|
+
'nauticalDawn',
|
|
125
|
+
'dawn',
|
|
126
|
+
]);
|
|
127
|
+
|
|
128
|
+
const sunEvents = [];
|
|
129
|
+
let sunTimes = [{}, /* today */ {}, /* tomorrow */ {}];
|
|
130
|
+
|
|
131
|
+
function calculateSunTimes() {
|
|
132
|
+
const now = new Date();
|
|
133
|
+
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 12, 0, 0, 0, 0);
|
|
134
|
+
const yesterday = new Date(today.getTime() - 86400000); // (24 * 60 * 60 * 1000));
|
|
135
|
+
const tomorrow = new Date(today.getTime() + 86400000); // (24 * 60 * 60 * 1000));
|
|
136
|
+
sunTimes = [
|
|
137
|
+
suncalc.getTimes(yesterday, config.latitude, config.longitude),
|
|
138
|
+
suncalc.getTimes(today, config.latitude, config.longitude),
|
|
139
|
+
suncalc.getTimes(tomorrow, config.latitude, config.longitude),
|
|
140
|
+
];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
calculateSunTimes();
|
|
144
|
+
|
|
145
|
+
scheduler.scheduleJob('0 0 * * *', () => {
|
|
146
|
+
// Re-calculate every day
|
|
147
|
+
calculateSunTimes();
|
|
148
|
+
// Schedule events for this day
|
|
149
|
+
sunEvents.forEach((event) => {
|
|
150
|
+
sunScheduleEvent(event);
|
|
151
|
+
});
|
|
152
|
+
log.info('re-scheduled', sunEvents.length, 'sun events');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
function sunScheduleEvent(obj, shift) {
|
|
156
|
+
// Shift = -1 -> yesterday
|
|
157
|
+
// shift = 0 -> today
|
|
158
|
+
// shift = 1 -> tomorrow
|
|
159
|
+
let event = sunTimes[1 + (shift || 0)][obj.pattern];
|
|
160
|
+
const now = new Date();
|
|
161
|
+
|
|
162
|
+
if (event.toString() !== 'Invalid Date') {
|
|
163
|
+
// Event will occur today
|
|
164
|
+
|
|
165
|
+
if (obj.options.shift) {
|
|
166
|
+
event = new Date(event.getTime() + (parseFloat(obj.options.shift) || 0) * 1000);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (event.getDate() !== now.getDate() && typeof shift === 'undefined') {
|
|
170
|
+
// Event shifted to previous or next day
|
|
171
|
+
sunScheduleEvent(obj, event < now ? 1 : -1);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (now.getTime() - event.getTime() < 1000) {
|
|
176
|
+
// Event is less than 1s in the past or occurs later this day
|
|
177
|
+
|
|
178
|
+
if (obj.options.random) {
|
|
179
|
+
event = new Date(event.getTime() + Math.floor((parseFloat(obj.options.random) || 0) * Math.random()) * 1000);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (event.getTime() - now.getTime() < 1000) {
|
|
183
|
+
// Event is less than 1s in the future or already in the past
|
|
184
|
+
// (options.random may have shifted us further to the past)
|
|
185
|
+
// call the callback immediately!
|
|
186
|
+
obj.domain.bind(obj.callback)();
|
|
187
|
+
} else {
|
|
188
|
+
// Schedule the event!
|
|
189
|
+
scheduler.scheduleJob(event, obj.domain.bind(obj.callback));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// MQTT — only connect when a broker URL is configured
|
|
196
|
+
let mqtt = null;
|
|
197
|
+
let connected = false;
|
|
198
|
+
|
|
199
|
+
// Wire up the MQTT API: pass the state store and a getter for the live MQTT client.
|
|
200
|
+
// The getter always returns the current value of `mqtt` (null until connected).
|
|
201
|
+
require('./web/mqtt-api').init(store, () => mqtt);
|
|
202
|
+
require('./web/ai-api').init(store);
|
|
203
|
+
|
|
204
|
+
if (!config.url) {
|
|
205
|
+
log.warn('no MQTT broker URL configured — set "url" in ' + path.join(require('os').homedir(), '.she', 'config.json'));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (config.url) {
|
|
209
|
+
mqtt = modules.mqtt.connect(config.url, { will: { topic: config.name + '/connected', payload: '0', retain: true } });
|
|
210
|
+
mqtt.publish(config.name + '/connected', '2', { retain: true });
|
|
211
|
+
|
|
212
|
+
mqtt.on('connect', () => {
|
|
213
|
+
connected = true;
|
|
214
|
+
log.info('mqtt connected ' + config.url);
|
|
215
|
+
log.debug('mqtt subscribe #');
|
|
216
|
+
mqtt.subscribe('#');
|
|
217
|
+
mqttEventCallbacks.filter((c) => c.event === 'connect').forEach((c) => c.callback());
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
mqtt.on('close', () => {
|
|
221
|
+
if (connected) {
|
|
222
|
+
connected = false;
|
|
223
|
+
log.info('mqtt closed ' + config.url);
|
|
224
|
+
mqttEventCallbacks.filter((c) => c.event === 'disconnect').forEach((c) => c.callback());
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
mqtt.on('error', () => {
|
|
229
|
+
log.error('mqtt error ' + config.url);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
mqtt.on('message', (topic, payload, msg) => {
|
|
233
|
+
if (shedb.handleMqttMessage(topic, payload)) return;
|
|
234
|
+
|
|
235
|
+
const state = require('./lib/parse-payload')(payload);
|
|
236
|
+
|
|
237
|
+
const topicArr = topic.split('/');
|
|
238
|
+
let oldState;
|
|
239
|
+
|
|
240
|
+
if (topicArr[0] === config.variablePrefix && topicArr[1] === 'set' && !config.disableVariables) {
|
|
241
|
+
topicArr[1] = 'status';
|
|
242
|
+
topic = topicArr.join('/');
|
|
243
|
+
const varName = topicArr.slice(2).join('/');
|
|
244
|
+
oldState = store.getObject('mqtt::' + topic) || {};
|
|
245
|
+
const ts = new Date().getTime();
|
|
246
|
+
|
|
247
|
+
state.ts = ts;
|
|
248
|
+
|
|
249
|
+
state.lc = state.val === oldState.val ? oldState.lc : ts;
|
|
250
|
+
store.setObject('mqtt::' + topic, state);
|
|
251
|
+
store.setObject('var::' + varName, state);
|
|
252
|
+
mqtt.publish(topic, JSON.stringify(state), { retain: true });
|
|
253
|
+
} else {
|
|
254
|
+
if (!state) {
|
|
255
|
+
log.error('invalid state', topic, payload);
|
|
256
|
+
process.exit();
|
|
257
|
+
}
|
|
258
|
+
if (!state.ts) {
|
|
259
|
+
state.ts = new Date().getTime();
|
|
260
|
+
}
|
|
261
|
+
oldState = store.getObject('mqtt::' + topic) || {};
|
|
262
|
+
if (oldState.val !== state.val) {
|
|
263
|
+
state.lc = state.ts;
|
|
264
|
+
}
|
|
265
|
+
store.setObject('mqtt::' + topic, state);
|
|
266
|
+
stateChange(topic, state, oldState, msg);
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// sheDB — only init when --db-path is given
|
|
272
|
+
if (config.dbPath) {
|
|
273
|
+
shedb.init({ dbPath: config.dbPath, dbPublish: config.dbPublish || false, dbRetain: config.dbRetain || false, dbPrefix: config.dbPrefix || 'she/db/', mqttName: config.name, mqtt, log, broadcast });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Redis write-through cache — only init when config.redis.url is given
|
|
277
|
+
if (config.redis && config.redis.url) {
|
|
278
|
+
require('./lib/redis')
|
|
279
|
+
.init({ url: config.redis.url, store, log })
|
|
280
|
+
.catch((err) => log.error('redis init failed:', err.message));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// InfluxDB — only init when --influx is set
|
|
284
|
+
if (config.influx) {
|
|
285
|
+
require('./influx').init(config.influx);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Elasticsearch — only init when --elastic is set
|
|
289
|
+
if (config.elastic) {
|
|
290
|
+
require('./elastic').init(config.elastic);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Matter controller — only init when --matter-storage is set
|
|
294
|
+
if (config.matterStorage) {
|
|
295
|
+
const { ensureStorageDir } = require('./lib/storage');
|
|
296
|
+
const matterController = require('./matter/controller');
|
|
297
|
+
const matterStoragePath = typeof config.matterStorage === 'string' ? config.matterStorage : ensureStorageDir('matter');
|
|
298
|
+
matterController.init(matterStoragePath, log, broadcast).catch((err) => {
|
|
299
|
+
log.error('matter controller init failed:', err.message);
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Start scripts immediately — MQTT retained state will populate the store asynchronously
|
|
304
|
+
start();
|
|
305
|
+
|
|
306
|
+
function stateChange(topic, state, oldState, msg) {
|
|
307
|
+
subscriptions.forEach((subs) => {
|
|
308
|
+
const options = subs.options || {};
|
|
309
|
+
let delay;
|
|
310
|
+
|
|
311
|
+
const match = mqttWildcards(topic, subs.topic);
|
|
312
|
+
|
|
313
|
+
if (match && typeof options.condition === 'function') {
|
|
314
|
+
if (!options.condition(topic.replace(/^([^/]+)\/status\/(.+)/, '$1//$2'), state.val, state, oldState, msg)) {
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (match && typeof subs.callback === 'function') {
|
|
320
|
+
if (msg.retain && !options.retain) {
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (options.change && state.val === oldState.val) {
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
delay = 0;
|
|
328
|
+
if (options.shift) {
|
|
329
|
+
delay += (parseFloat(options.shift) || 0) * 1000;
|
|
330
|
+
}
|
|
331
|
+
if (options.random) {
|
|
332
|
+
delay += (parseFloat(options.random) || 0) * Math.random() * 1000;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
delay = Math.floor(delay);
|
|
336
|
+
|
|
337
|
+
setTimeout(() => {
|
|
338
|
+
/**
|
|
339
|
+
* @callback subscribeCallback
|
|
340
|
+
* @param {string} topic - the topic that triggered this callback. +/status/# will be replaced by +//#
|
|
341
|
+
* @param {mixed} val - the val property of the new state
|
|
342
|
+
* @param {object} obj - new state - the whole state object (e.g. {"val": true, "ts": 12346345, "lc": 12346345} )
|
|
343
|
+
* @param {object} objPrev - previous state - the whole state object
|
|
344
|
+
* @param {object} msg - the mqtt message as received from MQTT.js
|
|
345
|
+
*/
|
|
346
|
+
subs.callback(topic.replace(/^([^/]+)\/status\/(.+)/, '$1//$2'), state.val, state, oldState, msg);
|
|
347
|
+
}, delay);
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const mqttWildcards = require('./lib/mqtt-wildcards');
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Write a variable to the var:: store namespace, sync mqtt:: for backwards
|
|
356
|
+
* compat, fire mqttsub callbacks, and (mqtt backend) publish retained.
|
|
357
|
+
* @param {string} name - bare variable name (e.g. 'testvar1')
|
|
358
|
+
* @param {*} val
|
|
359
|
+
*/
|
|
360
|
+
function setVariable(name, val) {
|
|
361
|
+
const storeKey = 'var::' + name;
|
|
362
|
+
const mqttTopic = config.variablePrefix + '/status/' + name;
|
|
363
|
+
const oldState = store.getObject(storeKey) || {};
|
|
364
|
+
const ts = new Date().getTime();
|
|
365
|
+
|
|
366
|
+
let newState;
|
|
367
|
+
if (typeof val === 'object' && val !== null && 'val' in val) {
|
|
368
|
+
newState = { val: val.val, ts: val.ts || ts };
|
|
369
|
+
newState.lc = newState.val !== oldState.val ? newState.ts : oldState.lc || newState.ts;
|
|
370
|
+
} else {
|
|
371
|
+
newState = { val, ts };
|
|
372
|
+
newState.lc = val !== oldState.val ? ts : oldState.lc || ts;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const changed = newState.val !== oldState.val;
|
|
376
|
+
store.setObject(storeKey, newState); // primary: fires 'change' → she.on() callbacks
|
|
377
|
+
store.setObject('mqtt::' + mqttTopic, newState); // compat: so mqttsub('var//name') still works
|
|
378
|
+
stateChange(mqttTopic, newState, oldState, {}); // fires mqttsub callbacks
|
|
379
|
+
|
|
380
|
+
const backend = (config.variables && config.variables.backend) || 'mqtt';
|
|
381
|
+
if (backend === 'mqtt' && mqtt && connected && changed) {
|
|
382
|
+
mqtt.publish(mqttTopic, JSON.stringify(newState), { retain: true });
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function createScript(source, name) {
|
|
387
|
+
log.debug(name, 'compiling');
|
|
388
|
+
try {
|
|
389
|
+
return new vm.Script(source, { filename: name });
|
|
390
|
+
} catch (err) {
|
|
391
|
+
log.error(name, err.name + ':', err.message);
|
|
392
|
+
return false;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function runScript(script, name, origin) {
|
|
397
|
+
const scriptDir = path.dirname(path.resolve(name));
|
|
398
|
+
const logLabel = (origin || 'user') + '::' + path.basename(name) + ':';
|
|
399
|
+
|
|
400
|
+
// Initialise per-script resource tracking
|
|
401
|
+
if (!scriptJobs.has(name)) scriptJobs.set(name, []);
|
|
402
|
+
if (!scriptTimers.has(name)) scriptTimers.set(name, new Set());
|
|
403
|
+
const _myJobs = scriptJobs.get(name);
|
|
404
|
+
const _myTimers = scriptTimers.get(name);
|
|
405
|
+
|
|
406
|
+
log.debug(logLabel, 'creating domain');
|
|
407
|
+
const scriptDomain = domain.create();
|
|
408
|
+
|
|
409
|
+
log.debug(logLabel, 'creating sandbox');
|
|
410
|
+
|
|
411
|
+
const she = {
|
|
412
|
+
global: _global,
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Log a debug message
|
|
416
|
+
* @method debug
|
|
417
|
+
* @param {...*}
|
|
418
|
+
*/
|
|
419
|
+
debug() {
|
|
420
|
+
const args = Array.prototype.slice.call(arguments);
|
|
421
|
+
args.unshift(logLabel);
|
|
422
|
+
log.debug.apply(log, args);
|
|
423
|
+
},
|
|
424
|
+
/**
|
|
425
|
+
* Log an info message
|
|
426
|
+
* @method info
|
|
427
|
+
* @param {...*}
|
|
428
|
+
*/
|
|
429
|
+
info() {
|
|
430
|
+
const args = Array.prototype.slice.call(arguments);
|
|
431
|
+
args.unshift(logLabel);
|
|
432
|
+
log.info.apply(log, args);
|
|
433
|
+
},
|
|
434
|
+
/**
|
|
435
|
+
* Log a warning message
|
|
436
|
+
* @method warn
|
|
437
|
+
* @param {...*}
|
|
438
|
+
*/
|
|
439
|
+
warn() {
|
|
440
|
+
const args = Array.prototype.slice.call(arguments);
|
|
441
|
+
args.unshift(logLabel);
|
|
442
|
+
log.warn.apply(log, args);
|
|
443
|
+
},
|
|
444
|
+
/**
|
|
445
|
+
* Log an error message
|
|
446
|
+
* @method error
|
|
447
|
+
* @param {...*}
|
|
448
|
+
*/
|
|
449
|
+
error() {
|
|
450
|
+
const args = Array.prototype.slice.call(arguments);
|
|
451
|
+
args.unshift(logLabel);
|
|
452
|
+
log.error.apply(log, args);
|
|
453
|
+
},
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Subscribe to MQTT topic(s)
|
|
457
|
+
* @method mqttsub
|
|
458
|
+
* @param {(string|string[])} topic - topic or array of topics to subscribe
|
|
459
|
+
* @param {Object|string|function} [options] - Options object or as shorthand to options.condition a function or string
|
|
460
|
+
* @param {number} [options.shift] - delay execution in seconds. Has to be positive
|
|
461
|
+
* @param {number} [options.random] - random delay execution in seconds. Has to be positive
|
|
462
|
+
* @param {boolean} [options.change] - if set to true callback is only called if val changed
|
|
463
|
+
* @param {boolean} [options.retain] - if set to true callback is also called on retained messages
|
|
464
|
+
* @param {(string|function)} [options.condition] - conditional function or condition string
|
|
465
|
+
* @param {subscribeCallback} callback
|
|
466
|
+
*/
|
|
467
|
+
mqttsub: function Sandbox_mqttsub(topic, /* optional */ options, callback) {
|
|
468
|
+
if (typeof topic === 'undefined') {
|
|
469
|
+
throw new TypeError('argument topic missing');
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (arguments.length === 2) {
|
|
473
|
+
if (typeof arguments[1] !== 'function') {
|
|
474
|
+
throw new TypeError('callback is not a function');
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
callback = arguments[1];
|
|
478
|
+
options = {};
|
|
479
|
+
} else if (arguments.length === 3) {
|
|
480
|
+
if (typeof arguments[2] !== 'function') {
|
|
481
|
+
throw new TypeError('callback is not a function');
|
|
482
|
+
}
|
|
483
|
+
options = arguments[1] || {};
|
|
484
|
+
|
|
485
|
+
if (typeof options === 'string' || typeof options === 'function') {
|
|
486
|
+
options = { condition: options };
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
callback = arguments[2];
|
|
490
|
+
} else if (arguments.length > 3) {
|
|
491
|
+
throw new Error('wrong number of arguments');
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (typeof topic === 'string') {
|
|
495
|
+
topic = topic.replace(/^([^/]+)\/\//, '$1/status/');
|
|
496
|
+
|
|
497
|
+
if (typeof options.condition === 'string') {
|
|
498
|
+
if (options.condition.indexOf('\n') !== -1) {
|
|
499
|
+
throw new Error('options.condition string must be one-line javascript');
|
|
500
|
+
}
|
|
501
|
+
/* eslint-disable no-new-func */
|
|
502
|
+
options.condition = new Function('topic', 'val', 'obj', 'objPrev', 'msg', 'return ' + options.condition + ';');
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (typeof options.condition === 'function') {
|
|
506
|
+
options.condition = scriptDomain.bind(options.condition);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
subscriptions.push({ topic, options, callback: typeof callback === 'function' && scriptDomain.bind(callback), _script: name });
|
|
510
|
+
|
|
511
|
+
if (options.retain && store.has('mqtt::' + topic) && typeof callback === 'function') {
|
|
512
|
+
callback(topic.replace(/^([^/]+)\/status\/(.+)/, '$1//$2'), store.get('mqtt::' + topic), store.getObject('mqtt::' + topic));
|
|
513
|
+
} else if (options.retain && (/\/\+\//.test(topic) || /\+$/.test(topic) || /\+/.test(topic) || topic.endsWith('#')) && typeof callback === 'function') {
|
|
514
|
+
for (const [t, obj] of store.mqttEntries()) {
|
|
515
|
+
if (mqttWildcards(t, topic)) {
|
|
516
|
+
callback(t.replace(/^([^/]+)\/status\/(.+)/, '$1//$2'), obj.val, obj);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
} else if (typeof topic === 'object' && topic.length > 0) {
|
|
521
|
+
topic = Array.prototype.slice.call(topic);
|
|
522
|
+
topic.forEach((tp) => {
|
|
523
|
+
she.mqttsub(tp, options, callback);
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
},
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Schedule recurring and one-shot callbacks, including solar events.
|
|
530
|
+
* Pass a suncalc event name (e.g. 'sunrise', 'sunset') as pattern to schedule
|
|
531
|
+
* relative to a solar event; cron strings, Date objects, and node-schedule
|
|
532
|
+
* literals are also accepted.
|
|
533
|
+
* @method schedule
|
|
534
|
+
* @param {string|Date|Object|Array} pattern - Cron string, suncalc event name, Date, node-schedule literal, or an array of any mix.
|
|
535
|
+
* @param {Object} [options]
|
|
536
|
+
* @param {number} [options.random] - random delay in seconds
|
|
537
|
+
* @param {number} [options.shift] - offset in seconds for solar events (-86400…86400)
|
|
538
|
+
* @param {function} callback - is called with no arguments
|
|
539
|
+
*/
|
|
540
|
+
schedule: function Sandbox_schedule(pattern, /* optional */ options, callback) {
|
|
541
|
+
if (arguments.length === 2) {
|
|
542
|
+
if (typeof arguments[1] !== 'function') {
|
|
543
|
+
throw new TypeError('callback is not a function');
|
|
544
|
+
}
|
|
545
|
+
callback = arguments[1];
|
|
546
|
+
options = {};
|
|
547
|
+
} else if (arguments.length === 3) {
|
|
548
|
+
if (typeof arguments[2] !== 'function') {
|
|
549
|
+
throw new TypeError('callback is not a function');
|
|
550
|
+
}
|
|
551
|
+
options = arguments[1] || {};
|
|
552
|
+
callback = arguments[2];
|
|
553
|
+
} else {
|
|
554
|
+
throw new Error('wrong number of arguments');
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (typeof pattern === 'object' && pattern.length > 0) {
|
|
558
|
+
pattern = Array.prototype.slice.call(pattern);
|
|
559
|
+
pattern.forEach((pt) => {
|
|
560
|
+
she.schedule(pt, options, callback);
|
|
561
|
+
});
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// A string with no spaces is treated as a suncalc event name.
|
|
566
|
+
if (typeof pattern === 'string' && !pattern.includes(' ')) {
|
|
567
|
+
if (!SUNCALC_EVENTS.has(pattern)) {
|
|
568
|
+
throw new TypeError('unknown suncalc event ' + pattern);
|
|
569
|
+
}
|
|
570
|
+
if (typeof options.shift !== 'undefined' && (options.shift < -86400 || options.shift > 86400)) {
|
|
571
|
+
throw new Error('options.shift out of range');
|
|
572
|
+
}
|
|
573
|
+
const obj = {
|
|
574
|
+
pattern,
|
|
575
|
+
options,
|
|
576
|
+
callback,
|
|
577
|
+
context: she,
|
|
578
|
+
domain: scriptDomain,
|
|
579
|
+
_script: name,
|
|
580
|
+
};
|
|
581
|
+
sunEvents.push(obj);
|
|
582
|
+
sunScheduleEvent(obj);
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if (options.random) {
|
|
587
|
+
_myJobs.push(
|
|
588
|
+
scheduler.scheduleJob(pattern, () => {
|
|
589
|
+
setTimeout(scriptDomain.bind(callback), (parseFloat(options.random) || 0) * 1000 * Math.random());
|
|
590
|
+
}),
|
|
591
|
+
);
|
|
592
|
+
} else {
|
|
593
|
+
_myJobs.push(scheduler.scheduleJob(pattern, scriptDomain.bind(callback)));
|
|
594
|
+
}
|
|
595
|
+
},
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Publish a MQTT message
|
|
599
|
+
* @method mqttpub
|
|
600
|
+
* @param {(string|string[])} topic - topic or array of topics to publish to
|
|
601
|
+
* @param {(string|Object)} payload - the payload string. If an object is given it will be JSON.stringified
|
|
602
|
+
* @param {Object} [options] - the options to publish with
|
|
603
|
+
* @param {number} [options.qos=0] - QoS Level
|
|
604
|
+
* @param {boolean} [options.retain=false] - retain flag
|
|
605
|
+
*/
|
|
606
|
+
mqttpub: function Sandbox_mqttpub(topic, payload, options) {
|
|
607
|
+
if (typeof topic === 'object' && topic.length > 0) {
|
|
608
|
+
topic = Array.prototype.slice.call(topic);
|
|
609
|
+
topic.forEach((tp) => {
|
|
610
|
+
she.mqttpub(tp, payload, options);
|
|
611
|
+
});
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
topic = topic.replace(/^([^/]+)\/\/(.+)$/, '$1/set/$2');
|
|
616
|
+
|
|
617
|
+
if (typeof payload === 'object') {
|
|
618
|
+
payload = JSON.stringify(payload);
|
|
619
|
+
} else {
|
|
620
|
+
payload = String(payload);
|
|
621
|
+
}
|
|
622
|
+
if (!mqtt || !connected) return; // silently drop when MQTT is not connected
|
|
623
|
+
mqtt.publish(topic, payload, options);
|
|
624
|
+
},
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Set a value on one or more topics
|
|
628
|
+
* @method setValue
|
|
629
|
+
* @param {(string|string[])} topic - topic or array of topics to set value on
|
|
630
|
+
* @param {mixed} val
|
|
631
|
+
*/
|
|
632
|
+
setValue: function Sandbox_setValue(topic, val) {
|
|
633
|
+
if (typeof topic === 'object' && topic.length > 0) {
|
|
634
|
+
topic = Array.prototype.slice.call(topic);
|
|
635
|
+
topic.forEach((tp) => {
|
|
636
|
+
she.setValue(tp, val);
|
|
637
|
+
});
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const tmp = topic.split('/');
|
|
642
|
+
if (tmp[0] === config.variablePrefix && !config.disableVariables) {
|
|
643
|
+
// Variable — delegate to setVariable (handles var:: store + MQTT publish)
|
|
644
|
+
const varName = tmp.slice(2).join('/');
|
|
645
|
+
setVariable(varName, val);
|
|
646
|
+
} else if (tmp[0] === config.variablePrefix && config.disableVariables) {
|
|
647
|
+
tmp[1] = 'status';
|
|
648
|
+
topic = tmp.join('/');
|
|
649
|
+
if (!store.has('mqtt::' + topic) || store.get('mqtt::' + topic) !== val) {
|
|
650
|
+
tmp[1] = 'set';
|
|
651
|
+
topic = tmp.join('/');
|
|
652
|
+
she.mqttpub(topic, val, { retain: false });
|
|
653
|
+
}
|
|
654
|
+
} else {
|
|
655
|
+
topic = topic.replace(/^([^/]+)\/\/(.+)$/, '$1/set/$2');
|
|
656
|
+
she.mqttpub(topic, val, { retain: false });
|
|
657
|
+
}
|
|
658
|
+
},
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* @method getValue
|
|
662
|
+
* @param {string} topic
|
|
663
|
+
* @returns {mixed} the topics value
|
|
664
|
+
*/
|
|
665
|
+
getValue: function Sandbox_getValue(topic) {
|
|
666
|
+
topic = topic.replace(/^([^/]+)\/\/(.+)$/, '$1/status/$2');
|
|
667
|
+
return store.get('mqtt::' + topic);
|
|
668
|
+
},
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Get a specific property of a topic
|
|
672
|
+
* @method getProp
|
|
673
|
+
* @param {string} topic
|
|
674
|
+
* @param {...string} [property] - the property to retrieve. May be repeated for nested properties. If omitted the whole topic object is returned.
|
|
675
|
+
* @returns {mixed} the topics properties value
|
|
676
|
+
* @example // returns the timestamp of a given topic
|
|
677
|
+
* she.getProp('hm//Bewegungsmelder Keller/MOTION', 'ts');
|
|
678
|
+
*/
|
|
679
|
+
getProp: function Sandbox_getProp(topic /* , optional property, optional nested property, ... */) {
|
|
680
|
+
topic = topic.replace(/^([^/]+)\/\/(.+)$/, '$1/status/$2');
|
|
681
|
+
if (arguments.length > 1) {
|
|
682
|
+
let tmp = store.getObject('mqtt::' + topic);
|
|
683
|
+
if (typeof tmp === 'undefined') {
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
for (let i = 1; i < arguments.length; i++) {
|
|
687
|
+
if (typeof tmp[arguments[i]] === 'undefined') {
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
tmp = tmp[arguments[i]];
|
|
691
|
+
}
|
|
692
|
+
return tmp;
|
|
693
|
+
}
|
|
694
|
+
return store.getObject('mqtt::' + topic);
|
|
695
|
+
},
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Universal read by namespaced key.
|
|
699
|
+
* @method get
|
|
700
|
+
* @param {string} key Namespaced key, e.g. 'mqtt::home/sensor/temp' or 'var::myVar'
|
|
701
|
+
* @returns {*} current value, or undefined
|
|
702
|
+
*/
|
|
703
|
+
get: function Sandbox_she_get(key) {
|
|
704
|
+
if (key.startsWith('var::')) {
|
|
705
|
+
return store.get('var::' + key.slice(5));
|
|
706
|
+
}
|
|
707
|
+
return store.get(key);
|
|
708
|
+
},
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Universal read (full state object) by namespaced key.
|
|
712
|
+
* @method getObject
|
|
713
|
+
* @param {string} key
|
|
714
|
+
* @returns {{ val:*, ts:number, lc:number } | undefined}
|
|
715
|
+
*/
|
|
716
|
+
getObject: function Sandbox_she_getObject(key) {
|
|
717
|
+
if (key.startsWith('var::')) {
|
|
718
|
+
return store.getObject('var::' + key.slice(5));
|
|
719
|
+
}
|
|
720
|
+
return store.getObject(key);
|
|
721
|
+
},
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Universal subscribe by namespaced key.
|
|
725
|
+
* Callback receives (val, obj, prevObj).
|
|
726
|
+
* @method on
|
|
727
|
+
* @param {string} key Namespaced key: 'mqtt::topic', 'var::name', 'matter::nodeId/ep/Cluster/attr'
|
|
728
|
+
* @param {function} callback
|
|
729
|
+
*/
|
|
730
|
+
on: function Sandbox_she_on(key, callback) {
|
|
731
|
+
if (typeof key !== 'string') throw new TypeError('she.on: key must be a string');
|
|
732
|
+
if (typeof callback !== 'function') throw new TypeError('she.on: callback must be a function');
|
|
733
|
+
|
|
734
|
+
if (key.startsWith('mqtt::')) {
|
|
735
|
+
she.mqttsub(key.slice(6), { retain: true }, (_topic, val, obj, prevObj) => {
|
|
736
|
+
callback(val, obj, prevObj);
|
|
737
|
+
});
|
|
738
|
+
} else if (key.startsWith('var::')) {
|
|
739
|
+
const varStoreKey = 'var::' + key.slice(5);
|
|
740
|
+
const boundCb = scriptDomain.bind(callback);
|
|
741
|
+
const varHandler = (changedKey, val, obj, prevObj) => {
|
|
742
|
+
if (changedKey === varStoreKey) boundCb(val, obj, prevObj);
|
|
743
|
+
};
|
|
744
|
+
store.on('change', varHandler);
|
|
745
|
+
varSubscriptions.push({ key: varStoreKey, handler: varHandler, _script: name });
|
|
746
|
+
// Fire immediately if var already has a value (retain semantics)
|
|
747
|
+
const currentVarObj = store.getObject(varStoreKey);
|
|
748
|
+
if (currentVarObj !== undefined) boundCb(currentVarObj.val, currentVarObj, undefined);
|
|
749
|
+
} else if (key.startsWith('matter::')) {
|
|
750
|
+
const parts = key.slice(8).split('/');
|
|
751
|
+
if (parts.length !== 4) throw new TypeError('she.on: invalid matter key (expected matter::nodeId/ep/Cluster/attr)');
|
|
752
|
+
const [nodeId, endpointId, clusterName, attrName] = parts;
|
|
753
|
+
if (!she.matter) throw new Error('she.on: Matter not configured');
|
|
754
|
+
she.matter.sub(Number(nodeId), Number(endpointId), clusterName, attrName, callback);
|
|
755
|
+
} else {
|
|
756
|
+
throw new TypeError('she.on: unknown namespace in key: ' + key);
|
|
757
|
+
}
|
|
758
|
+
},
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Universal write by namespaced key.
|
|
762
|
+
* @method set
|
|
763
|
+
* @param {string} key Namespaced key: 'mqtt::topic', 'var::name'
|
|
764
|
+
* @param {*} val
|
|
765
|
+
*/
|
|
766
|
+
set: function Sandbox_she_set(key, val) {
|
|
767
|
+
if (typeof key !== 'string') throw new TypeError('she.set: key must be a string');
|
|
768
|
+
|
|
769
|
+
if (key.startsWith('mqtt::')) {
|
|
770
|
+
she.mqttpub(key.slice(6), val);
|
|
771
|
+
} else if (key.startsWith('var::')) {
|
|
772
|
+
setVariable(key.slice(5), val);
|
|
773
|
+
} else if (key.startsWith('matter::')) {
|
|
774
|
+
throw new Error('she.set: matter:: write not yet implemented');
|
|
775
|
+
} else {
|
|
776
|
+
throw new TypeError('she.set: unknown namespace in key: ' + key);
|
|
777
|
+
}
|
|
778
|
+
},
|
|
779
|
+
|
|
780
|
+
/** @internal Register a callback for MQTT connection lifecycle events. */
|
|
781
|
+
_registerMqttEvent: function Sandbox_she_registerMqttEvent(event, callback) {
|
|
782
|
+
if (event !== 'connect' && event !== 'disconnect') {
|
|
783
|
+
throw new TypeError('she.mqtt.on: unknown event "' + event + '" — use "connect" or "disconnect"');
|
|
784
|
+
}
|
|
785
|
+
mqttEventCallbacks.push({ event, callback: scriptDomain.bind(callback), _script: name });
|
|
786
|
+
},
|
|
787
|
+
};
|
|
788
|
+
|
|
789
|
+
// she.log is an alias for she.info
|
|
790
|
+
she.log = she.info;
|
|
791
|
+
|
|
792
|
+
const Sandbox = {
|
|
793
|
+
setTimeout: (fn, delay, ...args) => {
|
|
794
|
+
const id = setTimeout(fn, delay, ...args);
|
|
795
|
+
_myTimers.add(id);
|
|
796
|
+
return id;
|
|
797
|
+
},
|
|
798
|
+
setInterval: (fn, delay, ...args) => {
|
|
799
|
+
const id = setInterval(fn, delay, ...args);
|
|
800
|
+
_myTimers.add(id);
|
|
801
|
+
return id;
|
|
802
|
+
},
|
|
803
|
+
clearTimeout: (id) => {
|
|
804
|
+
_myTimers.delete(id);
|
|
805
|
+
clearTimeout(id);
|
|
806
|
+
},
|
|
807
|
+
clearInterval: (id) => {
|
|
808
|
+
_myTimers.delete(id);
|
|
809
|
+
clearInterval(id);
|
|
810
|
+
},
|
|
811
|
+
|
|
812
|
+
Buffer,
|
|
813
|
+
|
|
814
|
+
require(md) {
|
|
815
|
+
if (modules[md]) {
|
|
816
|
+
return modules[md];
|
|
817
|
+
}
|
|
818
|
+
try {
|
|
819
|
+
let result;
|
|
820
|
+
if (md.match(/^\.\//) || md.match(/^\.\.\//)) {
|
|
821
|
+
// Relative import — resolve from the script's own directory
|
|
822
|
+
const tmp = './' + path.relative(__dirname, path.join(scriptDir, md));
|
|
823
|
+
she.debug('require', tmp);
|
|
824
|
+
result = require(tmp);
|
|
825
|
+
} else {
|
|
826
|
+
// Absolute import — try ~/.she/node_modules/ first (user-installed),
|
|
827
|
+
// then fall back to engine's own require (builtins + engine deps).
|
|
828
|
+
try {
|
|
829
|
+
result = _userRequire(md);
|
|
830
|
+
she.debug('require (user)', md);
|
|
831
|
+
} catch {
|
|
832
|
+
const localMod = path.join(scriptDir, 'node_modules', md, 'package.json');
|
|
833
|
+
if (fs.existsSync(localMod)) {
|
|
834
|
+
result = require(path.join(scriptDir, 'node_modules', md));
|
|
835
|
+
she.debug('require (local)', md);
|
|
836
|
+
} else {
|
|
837
|
+
result = require(md);
|
|
838
|
+
she.debug('require', md);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
modules[md] = result;
|
|
843
|
+
return result;
|
|
844
|
+
} catch (err) {
|
|
845
|
+
const lines = err.stack.split('\n');
|
|
846
|
+
const stack = [];
|
|
847
|
+
lines.forEach((line) => {
|
|
848
|
+
if (!line.match(/module\.js:/)) {
|
|
849
|
+
stack.push(line);
|
|
850
|
+
}
|
|
851
|
+
});
|
|
852
|
+
log.error(name + ': ' + stack);
|
|
853
|
+
}
|
|
854
|
+
},
|
|
855
|
+
|
|
856
|
+
console: {
|
|
857
|
+
log: (...args) => she.info(...args),
|
|
858
|
+
error: (...args) => she.error(...args),
|
|
859
|
+
},
|
|
860
|
+
|
|
861
|
+
she,
|
|
862
|
+
};
|
|
863
|
+
|
|
864
|
+
const scriptName = path.basename(name, path.extname(name));
|
|
865
|
+
sandboxModules.forEach((md) => {
|
|
866
|
+
md(she, { scriptDomain, scriptName });
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
log.debug(name, 'contextifying sandbox');
|
|
870
|
+
const context = vm.createContext(Sandbox);
|
|
871
|
+
|
|
872
|
+
scriptDomain.on('error', (e) => {
|
|
873
|
+
if (!e.stack) {
|
|
874
|
+
log.error(logLabel, 'unknown exception');
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
const lines = e.stack.split('\n');
|
|
878
|
+
const stack = [];
|
|
879
|
+
for (let i = 0; i < lines.length; i++) {
|
|
880
|
+
if (lines[i].match(/at ContextifyScript\.Script\.runInContext|at Script\.runInContext/)) {
|
|
881
|
+
break;
|
|
882
|
+
}
|
|
883
|
+
stack.push(lines[i]);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
log.error(logLabel + ' ' + e.name + ': ' + e.message + '\n' + stack.join('\n'));
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
scriptDomain.run(() => {
|
|
890
|
+
log.debug(logLabel, 'running');
|
|
891
|
+
script.runInContext(context);
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function loadScript(file, origin) {
|
|
896
|
+
origin = origin || 'user';
|
|
897
|
+
file = file.replace(/\\/g, '/');
|
|
898
|
+
const loadLabel = (origin || 'user') + '::' + path.basename(file) + ':';
|
|
899
|
+
if (scripts[file]) {
|
|
900
|
+
log.error(loadLabel, 'already loaded?!');
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
log.info(loadLabel, 'loading');
|
|
905
|
+
fs.readFile(file, (err, src) => {
|
|
906
|
+
if (err && err.code === 'ENOENT') {
|
|
907
|
+
log.error(loadLabel, 'not found');
|
|
908
|
+
} else if (err) {
|
|
909
|
+
log.error(loadLabel, err);
|
|
910
|
+
} else {
|
|
911
|
+
if (file.match(/\.js$/)) {
|
|
912
|
+
// Javascript
|
|
913
|
+
scripts[file] = createScript(src, file);
|
|
914
|
+
}
|
|
915
|
+
if (scripts[file]) {
|
|
916
|
+
scriptOrigins.set(file, origin);
|
|
917
|
+
runScript(scripts[file], file, origin);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
function unloadScript(file) {
|
|
924
|
+
file = file.replace(/\\/g, '/');
|
|
925
|
+
const origin = scriptOrigins.get(file) || 'user';
|
|
926
|
+
const unloadLabel = (origin || 'user') + '::' + path.basename(file) + ':';
|
|
927
|
+
log.info(unloadLabel, 'unloading');
|
|
928
|
+
scriptOrigins.delete(file);
|
|
929
|
+
|
|
930
|
+
// Remove MQTT subscriptions belonging to this script
|
|
931
|
+
for (let i = subscriptions.length - 1; i >= 0; i--) {
|
|
932
|
+
if (subscriptions[i]._script === file) subscriptions.splice(i, 1);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// Remove MQTT event callbacks (connect/disconnect) belonging to this script
|
|
936
|
+
for (let i = mqttEventCallbacks.length - 1; i >= 0; i--) {
|
|
937
|
+
if (mqttEventCallbacks[i]._script === file) mqttEventCallbacks.splice(i, 1);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// Cancel all node-schedule jobs for this script
|
|
941
|
+
const jobs = scriptJobs.get(file);
|
|
942
|
+
if (jobs) {
|
|
943
|
+
jobs.forEach((job) => job && job.cancel());
|
|
944
|
+
scriptJobs.delete(file);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// Remove sun events belonging to this script
|
|
948
|
+
for (let i = sunEvents.length - 1; i >= 0; i--) {
|
|
949
|
+
if (sunEvents[i]._script === file) sunEvents.splice(i, 1);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// Clear all tracked timers for this script
|
|
953
|
+
const timers = scriptTimers.get(file);
|
|
954
|
+
if (timers) {
|
|
955
|
+
timers.forEach((id) => clearTimeout(id));
|
|
956
|
+
scriptTimers.delete(file);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// Remove store-based var:: subscriptions belonging to this script
|
|
960
|
+
for (let i = varSubscriptions.length - 1; i >= 0; i--) {
|
|
961
|
+
if (varSubscriptions[i]._script === file) {
|
|
962
|
+
store.removeListener('change', varSubscriptions[i].handler);
|
|
963
|
+
varSubscriptions.splice(i, 1);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// Remove shedb listeners belonging to this script
|
|
968
|
+
const shedbSandbox = require('./sandbox/shedb-sandbox');
|
|
969
|
+
shedbSandbox.cleanup(file);
|
|
970
|
+
|
|
971
|
+
// Remove matter subscriptions belonging to this script
|
|
972
|
+
if (config.matterStorage) {
|
|
973
|
+
const matterSandbox = require('./sandbox/matter-sandbox');
|
|
974
|
+
matterSandbox.cleanup(file);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// Remove from scripts map so it can be re-loaded
|
|
978
|
+
delete scripts[file];
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function loadBuiltinsDir(callback) {
|
|
982
|
+
const dir = path.join(__dirname, 'scripts');
|
|
983
|
+
fs.readdir(dir, (err, data) => {
|
|
984
|
+
if (err) {
|
|
985
|
+
if (err.code !== 'ENOENT') {
|
|
986
|
+
log.error('readdir builtin scripts', dir, err);
|
|
987
|
+
}
|
|
988
|
+
callback();
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
data.sort().forEach((file) => {
|
|
992
|
+
if (file.match(/\.js$/)) {
|
|
993
|
+
loadScript(path.join(dir, file), 'builtin');
|
|
994
|
+
}
|
|
995
|
+
});
|
|
996
|
+
callback();
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function loadSandbox(callback) {
|
|
1001
|
+
const dir = path.join(__dirname, 'sandbox');
|
|
1002
|
+
fs.readdir(dir, (err, data) => {
|
|
1003
|
+
if (err) {
|
|
1004
|
+
if (err.errno === 34) {
|
|
1005
|
+
log.error('directory ' + path.resolve(dir) + ' not found');
|
|
1006
|
+
} else {
|
|
1007
|
+
log.error('readdir', dir, err);
|
|
1008
|
+
}
|
|
1009
|
+
} else {
|
|
1010
|
+
data.sort().forEach((file) => {
|
|
1011
|
+
if (file.match(/\.js$/)) {
|
|
1012
|
+
sandboxModules.push(require(path.join(dir, file)));
|
|
1013
|
+
}
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
if (!config.disableWatch) {
|
|
1017
|
+
const sandboxWatcher = chokidar.watch(dir, {
|
|
1018
|
+
ignored: (p, stats) => stats?.isFile() && !p.endsWith('.js'),
|
|
1019
|
+
persistent: true,
|
|
1020
|
+
ignoreInitial: true,
|
|
1021
|
+
usePolling: true,
|
|
1022
|
+
});
|
|
1023
|
+
sandboxWatcher.on('ready', () => log.debug('watch', dir, 'initialized'));
|
|
1024
|
+
sandboxWatcher.on('all', (event, filePath) => {
|
|
1025
|
+
sandboxWatcher.close();
|
|
1026
|
+
log.info(filePath, 'sandbox change detected. exiting.');
|
|
1027
|
+
process.exit(0);
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
callback();
|
|
1032
|
+
}
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* Returns true if any ancestor directory of absFile (between it and scriptRoot)
|
|
1038
|
+
* contains a .shelib marker file.
|
|
1039
|
+
*/
|
|
1040
|
+
function isLibFile(absFile, scriptRoot) {
|
|
1041
|
+
const root = path.resolve(scriptRoot);
|
|
1042
|
+
let dir = path.dirname(path.resolve(absFile));
|
|
1043
|
+
while (dir.length >= root.length && dir.startsWith(root)) {
|
|
1044
|
+
if (dir !== root && fs.existsSync(path.join(dir, '.shelib'))) return true;
|
|
1045
|
+
const parent = path.dirname(dir);
|
|
1046
|
+
if (parent === dir) break;
|
|
1047
|
+
dir = parent;
|
|
1048
|
+
}
|
|
1049
|
+
return false;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/**
|
|
1053
|
+
* Recursively load all user .js scripts from a directory tree.
|
|
1054
|
+
* Files inside directories that contain a .shelib marker are skipped.
|
|
1055
|
+
*/
|
|
1056
|
+
function loadDirRecursive(dir, scriptRoot) {
|
|
1057
|
+
let entries;
|
|
1058
|
+
try {
|
|
1059
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
1060
|
+
} catch (err) {
|
|
1061
|
+
log.error('readdir', dir, err);
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
entries
|
|
1065
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
1066
|
+
.forEach((entry) => {
|
|
1067
|
+
const abs = path.join(dir, entry.name);
|
|
1068
|
+
if (entry.isDirectory()) {
|
|
1069
|
+
loadDirRecursive(abs, scriptRoot);
|
|
1070
|
+
} else if (entry.name.endsWith('.js') && !isLibFile(abs, scriptRoot)) {
|
|
1071
|
+
loadScript(abs.replace(/\\/g, '/'));
|
|
1072
|
+
}
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
function loadDir(dir) {
|
|
1077
|
+
const scriptRoot = path.resolve(dir);
|
|
1078
|
+
loadDirRecursive(scriptRoot, scriptRoot);
|
|
1079
|
+
|
|
1080
|
+
if (!config.disableWatch) {
|
|
1081
|
+
const dirWatcher = chokidar.watch(dir, {
|
|
1082
|
+
ignored: (p, stats) => {
|
|
1083
|
+
const name = path.basename(p);
|
|
1084
|
+
return stats?.isFile() && !name.endsWith('.js') && name !== '.shelib';
|
|
1085
|
+
},
|
|
1086
|
+
persistent: true,
|
|
1087
|
+
ignoreInitial: true,
|
|
1088
|
+
usePolling: true,
|
|
1089
|
+
});
|
|
1090
|
+
dirWatcher.on('ready', () => log.info('watch', dir, 'initialized'));
|
|
1091
|
+
dirWatcher.on('all', (event, filePath) => {
|
|
1092
|
+
filePath = filePath.replace(/\\/g, '/');
|
|
1093
|
+
const basename = path.basename(filePath);
|
|
1094
|
+
|
|
1095
|
+
// .shelib marker changes — warn only, manual restart required
|
|
1096
|
+
if (basename === '.shelib') {
|
|
1097
|
+
if (event === 'add') {
|
|
1098
|
+
log.warn(filePath, 'library marker added — .js files in this directory will no longer load as scripts after daemon restart');
|
|
1099
|
+
} else if (event === 'unlink') {
|
|
1100
|
+
log.warn(filePath, 'library marker removed — .js files in this directory will load as scripts after daemon restart');
|
|
1101
|
+
}
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// Directory events — handle gracefully (no process.exit)
|
|
1106
|
+
if (event === 'addDir') return;
|
|
1107
|
+
|
|
1108
|
+
if (event === 'unlinkDir') {
|
|
1109
|
+
const absDir = path.resolve(filePath);
|
|
1110
|
+
Object.keys(scripts).forEach((scriptFile) => {
|
|
1111
|
+
const absScript = path.resolve(scriptFile);
|
|
1112
|
+
if (absScript.startsWith(absDir + path.sep) || absScript === absDir) {
|
|
1113
|
+
log.info(scriptFile, 'directory removed. unloading.');
|
|
1114
|
+
unloadScript(scriptFile);
|
|
1115
|
+
}
|
|
1116
|
+
});
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
if (event === 'change' && filePath.endsWith('.js')) {
|
|
1121
|
+
if (isLibFile(filePath, dir)) {
|
|
1122
|
+
log.warn(filePath, 'is a library file — scripts that require() it will see the old version until they or the daemon are restarted');
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
log.info(filePath, 'change detected. hot-reloading.');
|
|
1126
|
+
unloadScript(filePath);
|
|
1127
|
+
loadScript(filePath);
|
|
1128
|
+
} else if (event === 'add' && filePath.endsWith('.js')) {
|
|
1129
|
+
if (isLibFile(filePath, dir)) {
|
|
1130
|
+
log.debug(filePath, 'is a library file — not loading as script');
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
log.info(filePath, 'added. loading.');
|
|
1134
|
+
loadScript(filePath);
|
|
1135
|
+
} else if (event === 'unlink' && filePath.endsWith('.js')) {
|
|
1136
|
+
if (scripts[filePath]) {
|
|
1137
|
+
log.info(filePath, 'removed. unloading.');
|
|
1138
|
+
unloadScript(filePath);
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
function start() {
|
|
1146
|
+
if (config.file) {
|
|
1147
|
+
if (typeof config.file === 'string') {
|
|
1148
|
+
loadScript(config.file);
|
|
1149
|
+
} else {
|
|
1150
|
+
config.file.forEach((file) => {
|
|
1151
|
+
loadScript(file);
|
|
1152
|
+
});
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
loadSandbox(() => {
|
|
1157
|
+
loadBuiltinsDir(() => {
|
|
1158
|
+
if (config.dir) {
|
|
1159
|
+
if (typeof config.dir === 'string') {
|
|
1160
|
+
loadDir(config.dir);
|
|
1161
|
+
} else {
|
|
1162
|
+
config.dir.forEach((dir) => {
|
|
1163
|
+
loadDir(dir);
|
|
1164
|
+
});
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
});
|
|
1168
|
+
});
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
async function gracefulShutdown(signal) {
|
|
1172
|
+
log.info(`got ${signal}. exiting.`);
|
|
1173
|
+
if (config.matterStorage) {
|
|
1174
|
+
try {
|
|
1175
|
+
await require('./matter/controller').close();
|
|
1176
|
+
} catch {
|
|
1177
|
+
// ignore
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
process.exit(0);
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|
1184
|
+
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|