smart-home-engine 2.0.1 → 3.0.0-beta.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/dist/web/assets/index-BZWF5ORr.js +394 -0
- package/dist/web/assets/{index-Bu5o4KXc.css → index-CdLKmsX4.css} +1 -1
- package/dist/web/assets/{tsMode-20i8zomj.js → tsMode-BquNtbO5.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/src/config.js +21 -1
- package/src/index.js +532 -86
- package/src/lib/call-site.js +51 -0
- package/src/lib/command-topics.js +162 -0
- package/src/lib/command.js +188 -0
- package/src/lib/holidays.js +188 -0
- package/src/lib/publish-changed.js +45 -0
- package/src/lib/runtime.js +350 -0
- package/src/lib/script-timers.js +49 -7
- package/src/lib/time.js +402 -0
- package/src/lib/timing.js +421 -0
- package/src/lib/update-channel.js +152 -0
- package/src/sandbox/stdlib.js +14 -0
- package/src/web/ai-tools.js +7 -3
- package/src/web/config-api.js +4 -3
- package/src/web/log-ws.js +49 -2
- package/src/web/prompts/api-ref-compact.md +6 -3
- package/src/web/prompts/api-ref.md +45 -4
- package/src/web/runtime-api.js +66 -0
- package/src/web/server.js +51 -19
- package/dist/web/assets/index-CqYTIaIG.js +0 -306
package/src/web/log-ws.js
CHANGED
|
@@ -19,6 +19,34 @@ function sendOrDrop(ws, str) {
|
|
|
19
19
|
ws.send(str);
|
|
20
20
|
}
|
|
21
21
|
const _welcomeProviders = [];
|
|
22
|
+
const _clientHandlers = new Map(); // message type → (ws, msg)
|
|
23
|
+
const _closeHandlers = new Set(); // (ws) when a client goes away
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Handle a message a client sends: `{ type, ... }`. One handler per type.
|
|
27
|
+
* @param {string} type
|
|
28
|
+
* @param {(ws: import('ws').WebSocket, msg: object) => void} fn
|
|
29
|
+
*/
|
|
30
|
+
function onClientMessage(type, fn) {
|
|
31
|
+
_clientHandlers.set(type, fn);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Called with the socket when a client disconnects — to drop what it subscribed to.
|
|
36
|
+
* @param {(ws: import('ws').WebSocket) => void} fn
|
|
37
|
+
*/
|
|
38
|
+
function onClientClose(fn) {
|
|
39
|
+
_closeHandlers.add(fn);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Send a JSON message to one client (dropped when it is gone or not reading).
|
|
44
|
+
* @param {import('ws').WebSocket} ws
|
|
45
|
+
* @param {object} msg
|
|
46
|
+
*/
|
|
47
|
+
function sendWs(ws, msg) {
|
|
48
|
+
sendOrDrop(ws, JSON.stringify(msg));
|
|
49
|
+
}
|
|
22
50
|
|
|
23
51
|
/**
|
|
24
52
|
* Register a function that returns a welcome message (or array of messages)
|
|
@@ -54,8 +82,24 @@ function attachWss(httpServer, authCheck = () => true) {
|
|
|
54
82
|
return;
|
|
55
83
|
}
|
|
56
84
|
_clients.add(ws);
|
|
57
|
-
|
|
58
|
-
|
|
85
|
+
const gone = () => {
|
|
86
|
+
if (!_clients.delete(ws)) return;
|
|
87
|
+
for (const fn of _closeHandlers) fn(ws);
|
|
88
|
+
};
|
|
89
|
+
ws.on('close', gone);
|
|
90
|
+
ws.on('error', gone);
|
|
91
|
+
// A client may ask for something — a page subscribing to the runtime snapshot: { type, ... }
|
|
92
|
+
ws.on('message', (data) => {
|
|
93
|
+
let msg;
|
|
94
|
+
try {
|
|
95
|
+
msg = JSON.parse(data.toString());
|
|
96
|
+
} catch {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (!msg || typeof msg.type !== 'string') return;
|
|
100
|
+
const handler = _clientHandlers.get(msg.type);
|
|
101
|
+
if (handler) handler(ws, msg);
|
|
102
|
+
});
|
|
59
103
|
// Send current state to this new client immediately
|
|
60
104
|
for (const provider of _welcomeProviders) {
|
|
61
105
|
const msgs = provider();
|
|
@@ -153,5 +197,8 @@ module.exports = {
|
|
|
153
197
|
getBrokerLogBuffer,
|
|
154
198
|
getLogBuffer,
|
|
155
199
|
setWelcomeProvider,
|
|
200
|
+
onClientMessage,
|
|
201
|
+
onClientClose,
|
|
202
|
+
sendWs,
|
|
156
203
|
_internal: { clients: _clients, MAX_BACKLOG },
|
|
157
204
|
};
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
## she sandbox API (the common part)
|
|
2
2
|
Scripts start with `/* global she */` and `'use strict';`. `require()` works for installed npm packages.
|
|
3
3
|
```
|
|
4
|
-
she.mqtt.sub(topic, [opts], cb) cb(topic, val, obj) opts: { change: true } fires on changes only; wildcards + and #
|
|
5
|
-
she.mqtt.pub(topic, payload)
|
|
4
|
+
she.mqtt.sub(topic, [opts], cb) cb(topic, val, obj) opts: { change: true } fires on changes only; { debounce: 5, maxWait: 15 } (SECONDS) bundles events; wildcards + and #
|
|
5
|
+
she.mqtt.pub(topic, payload, [opts]) publish (commands: no retain); { changed: true } publishes only when the payload differs from the last one
|
|
6
6
|
she.mqtt.get(topic) last known value
|
|
7
7
|
she.mqtt.age(topic) seconds since the value last changed
|
|
8
8
|
she.mqtt.timer(src, ms, target) publish 1 when src turns truthy, 0 after ms
|
|
9
|
+
she.mqtt.command(topic, payload, [{ timeout: 5, retries: 0, confirm }]) → Promise<{ val, ms, tries }>; waits for the status topic to confirm; rejects with reason timeout | offline | superseded
|
|
9
10
|
she.mqtt.or(srcs[], target) / and publish 1 when any / all sources are truthy, else 0
|
|
10
11
|
she.schedule('0 7 * * *', cb) cron string, Date, or 'sunrise' / 'sunset' with { shift: seconds }
|
|
11
12
|
she.http.fetch(url, [opts]) → Promise<{ body, code, headers }>
|
|
12
13
|
she.debug / info / warn / error logging
|
|
13
|
-
setTimeout / setInterval available
|
|
14
|
+
setTimeout / setInterval available; she.timeout(ms, cb) → handle.restart()/cancel() is a restartable timer (motion light)
|
|
15
|
+
she.debounce(fn, ms, [{ maxWait }]) / she.throttle(fn, ms) / she.retry(fn, { tries, delay, onFail }) MILLISECONDS; cleared on reload
|
|
16
|
+
she.time.between('22:00', '06:00') / at('06:00') / isWorkday() / format(ts, 'dd HH:mm') / iso(ts) / duration(a, b) / humanize(ms) the daemon's zone, DST-safe; not getHours()
|
|
14
17
|
```
|
|
15
18
|
Variables: read `var/status/<name>`, write `she.mqtt.pub('var/set/<name>', value)`.
|
|
16
19
|
For anything else ask with the tool she_api_doc or say that you are not sure.
|
|
@@ -8,15 +8,41 @@ she.mqtt.sub(topic, [opts], cb) Subscribe; topic may be a string or an ar
|
|
|
8
8
|
opts.retain also fire for the retained value present on connect and script start
|
|
9
9
|
opts.shift delay the callback by N seconds; opts.random adds up to N random seconds
|
|
10
10
|
opts.condition fn(val) or a one-line JS string over val/obj/objPrev; the callback runs only when truthy
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
opts.debounce SECONDS: bundle events, one callback once nothing came in for N s; opts.maxWait (seconds) runs it at the latest then
|
|
12
|
+
opts.throttle SECONDS: the first event at once, then at most one callback per N s (with the last event)
|
|
13
|
+
opts.perTopic with debounce/throttle on an array or wildcard: one bundle per topic; opts.name names it in the timer list
|
|
14
|
+
opts.flushOnUnload with debounce: run a pending callback once on unload/reload (default: dropped)
|
|
15
|
+
cb(topic, val, obj, objPrev, msg) obj and objPrev are { val, ts, lc }; debounced: the last event, objPrev from before the first bundled one
|
|
16
|
+
she.mqtt.pub(topic, payload, [opts]) Publish; objects are JSON-stringified; opts { qos, retain, changed }
|
|
17
|
+
opts.changed publish only when the payload differs from this script's last publish to the topic (before that: from the topic's state, set→status)
|
|
13
18
|
she.mqtt.get(topic) Last known value (undefined when never seen); an empty payload removes a topic
|
|
14
19
|
she.mqtt.getProp(topic, 'val'|'ts'|'lc') One property of the state; without a property the whole { val, ts, lc }
|
|
15
20
|
she.mqtt.age(topic, ['message']) Seconds since the last change, or since the last message with 'message'; NaN when never seen
|
|
16
21
|
she.mqtt.link(src, target, [fn]) Forward src changes to target, optionally transformed
|
|
17
22
|
she.mqtt.or / and / max / min(srcs[], topicOrCb) Combine sources; publish 1/0 or the extreme value; re-evaluated on every change
|
|
18
|
-
she.mqtt.timer(src, ms, topicOrCb) Publish 1 when src turns truthy, 0 after ms; restarted when src fires again
|
|
23
|
+
she.mqtt.timer(src, ms, topicOrCb) Publish 1 when src turns truthy, 0 after ms (a number — swapped arguments throw at load); restarted when src fires again
|
|
19
24
|
topicOrCb: a topic string or cb(topic, val)
|
|
25
|
+
she.mqtt.command(topic, payload, [opts]) → Promise<{ val, ms, tries, already? }>: publish a command and wait for the device's status to confirm it (3.0.0-beta.0)
|
|
26
|
+
confirm topic derived: <inst>/set/<path> → <inst>/status/<path> (val); zigbee2mqtt <base>/<dev>/set → <base>/<dev> (field = the payload's key, or opts.path); else opts.confirm is required (TypeError)
|
|
27
|
+
opts { confirm, path, expect (value or fn(val); default the payload), tolerance (numbers), timeout = 5 SECONDS per attempt, retries = 0, quiet }
|
|
28
|
+
status already at the target → sent anyway, resolves at once with already: true; device offline (<inst>/connected < 2, <dev>/availability offline) → rejects at once
|
|
29
|
+
rejects with Error { reason: 'timeout' | 'offline' | 'superseded' | 'unloaded', topic, payload, tries }; timeout/offline are logged as a warning with the script name (unless quiet)
|
|
30
|
+
and counted in get_health; a rejection nobody catches is NOT an unhandled rejection. A new command to the same topic supersedes the running one. Pending commands are listed by list_timers.
|
|
31
|
+
Use it for commands whose effect matters (heating, gates, switching off); she.mqtt.pub stays fire-and-forget. Callbacks that await it become async: wrap in try/catch.
|
|
32
|
+
Lint: a pub/command to <inst>/status/… or to a zigbee2mqtt device topic is warned about at load (with the line) — commands go to the set topic.
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Timing helpers (MILLISECONDS, like setTimeout; cleared on unload/reload; pending runs listed by list_timers with kind and name)
|
|
36
|
+
```
|
|
37
|
+
she.timeout(ms, cb, [{ name }]) A restartable timer, created idle → handle.restart([ms]) arms or starts over, cancel(), pending(), remaining()
|
|
38
|
+
the motion-light pattern: off.restart() on every motion, off.cancel() when the state changes
|
|
39
|
+
she.debounce(fn, wait, [opts]) Bundle calls: fn runs with the LAST call's args after `wait` ms of quiet; opts { maxWait, leading=false, trailing=true, flushOnUnload, name }
|
|
40
|
+
→ wrapped(...args) with cancel(), flush(), pending(); maxWait keeps a never-resting source from starving fn
|
|
41
|
+
she.throttle(fn, wait, [opts]) Rate-limit: first call at once, then at most once per `wait` ms; opts { leading=true, trailing=true, name }; trailing:false = fire, then block
|
|
42
|
+
she.debounce.keyed / she.throttle.keyed(fn, wait, [opts]) One state per key (the first argument, or opts.key(...args)); cancel(key)/flush(key)/pending(key), without key: all
|
|
43
|
+
she.retry(fn, [opts]) → Promise; fn(attempt) may be async; opts { tries=3, delay=1000, backoff=1, onFail(err, tries), name }
|
|
44
|
+
resolves with fn's result; after the last failure: onFail's return value, or rejects without onFail; an unload stops it
|
|
45
|
+
she.setTimeout / she.clearTimeout The sandbox's setTimeout/clearTimeout under she (official aliases); a non-number delay is logged with the script name
|
|
20
46
|
```
|
|
21
47
|
|
|
22
48
|
### Scheduling
|
|
@@ -24,7 +50,22 @@ she.mqtt.timer(src, ms, topicOrCb) Publish 1 when src turns truthy, 0 after
|
|
|
24
50
|
she.schedule(pattern, [opts], cb) pattern: cron string | Date | 'sunrise' 'sunset' 'dawn' 'dusk' 'nauticalDawn' 'nauticalDusk' 'solarNoon' 'night'
|
|
25
51
|
opts.shift seconds offset (-1800 = 30 min before); opts.random up to N random seconds later
|
|
26
52
|
she.now() Milliseconds since the epoch
|
|
27
|
-
setTimeout / setInterval Available; cleared automatically when the script is unloaded
|
|
53
|
+
setTimeout / setInterval Available; cleared automatically when the script is unloaded; a bad delay is logged with the script name
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Time (she.time — the daemon's zone, DST-safe; ts = ms | Date | ISO string, default now)
|
|
57
|
+
```
|
|
58
|
+
she.time.between(from, to, [ts]) In the window? from/to: 'HH:mm' or a sun event with offset ('sunset-30m', 'dawn+1h', 'sunrise+90s'); from inclusive, to exclusive
|
|
59
|
+
to before from crosses midnight: she.time.between('22:00', '06:00') is the whole night in one call
|
|
60
|
+
she.time.at(time, [ts]) ms of 'HH:mm' or a sun event on the day of ts; '24:00' = next midnight; NaN when the sun event does not occur
|
|
61
|
+
she.time.startOfDay / endOfDay([ts]) ms of midnight / of the day's last ms; she.time.addDays(ts, n) the same wall clock n days later (DST-safe, not +86400000)
|
|
62
|
+
she.time.parts([ts]) { year, month 1-12, day, hour, minute, second, millisecond, weekday 0=Sunday, offset (min), zone } — instead of getHours()/getDay()
|
|
63
|
+
she.time.isWeekend / isHoliday / isWorkday([ts]) holidays from the config (holidays: 'DE-BW', holidaysExtra: [...]); she.time.holiday([ts]) → name or null
|
|
64
|
+
she.time.format(ts, [pattern]) tokens YYYY YY M MM MMM MMMM D DD H HH m mm s ss SSS dd (So) ddd (Sonntag) Z (+01:00) [literal]; default 'YYYY-MM-DD HH:mm:ss'
|
|
65
|
+
she.time.iso([ts]) '2026-10-25T02:41:00+02:00' — ISO 8601 WITH offset: sortable even in the autumn double hour
|
|
66
|
+
she.time.duration(a, b) b - a in ms, the real difference; she.time.humanize(ms, [{ parts: 2, long }]) → '2 h 40 min' / '2 Stunden 40 Minuten'
|
|
67
|
+
DST rules: a time in the spring gap (02:30) resolves to 03:30; one in the autumn double hour to its first occurrence; windows and durations compare instants
|
|
68
|
+
Prefer she.time over new Date().getHours()/getDay()/toLocale*/padStart: those use the process zone and break around the DST change
|
|
28
69
|
```
|
|
29
70
|
|
|
30
71
|
### sheDB (documents, ids like topics: devices/lamp1)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The runtime model over HTTP: what every script has registered, with statistics and pending timers
|
|
5
|
+
* (`GET /she/runtime`, optionally one script), the hour's gauge history (`GET /she/runtime/history`),
|
|
6
|
+
* and the editor's actions on a timer or handler (`POST /she/runtime/action`). The live view goes
|
|
7
|
+
* over the WebSocket (`runtime:subscribe`); these endpoints serve a one-off read and the actions.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const express = require('express');
|
|
11
|
+
const { NotFoundError } = require('../lib/runtime');
|
|
12
|
+
|
|
13
|
+
const router = express.Router();
|
|
14
|
+
|
|
15
|
+
let _ctx = null;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {object} ctx
|
|
19
|
+
* @param {import('../lib/runtime').Runtime} ctx.runtime
|
|
20
|
+
* @param {(label?: string) => object} ctx.snapshot the model, or one script's part by its label
|
|
21
|
+
* @param {() => {intervalMs: number, samples: object[]}} ctx.history
|
|
22
|
+
* @param {(file: string, msg: string) => void} ctx.log writes into a script's log
|
|
23
|
+
*/
|
|
24
|
+
function init(ctx) {
|
|
25
|
+
_ctx = ctx;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
router.get('/', (req, res) => {
|
|
29
|
+
if (!_ctx) return res.status(503).json({ error: 'runtime not ready' });
|
|
30
|
+
const label = typeof req.query.script === 'string' ? req.query.script : undefined;
|
|
31
|
+
res.set('Cache-Control', 'no-store');
|
|
32
|
+
res.json(_ctx.snapshot(label));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
router.get('/history', (req, res) => {
|
|
36
|
+
if (!_ctx) return res.status(503).json({ error: 'runtime not ready' });
|
|
37
|
+
res.set('Cache-Control', 'no-store');
|
|
38
|
+
res.json(_ctx.history());
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* An action from the editor's Runtime panel. Body: `{ id, action }` with action `cancel` or `run`.
|
|
43
|
+
* `cancel` removes a pending timer (or an interval) without running it; `run` fires a pending timer
|
|
44
|
+
* now (a debounce: its flush), runs an interval once, or runs a schedule or sun event once — the
|
|
45
|
+
* schedule stays. Every action is written into the script's log.
|
|
46
|
+
*/
|
|
47
|
+
router.post('/action', (req, res) => {
|
|
48
|
+
if (!_ctx) return res.status(503).json({ error: 'runtime not ready' });
|
|
49
|
+
const { id, action } = req.body || {};
|
|
50
|
+
if (typeof id !== 'string' || (action !== 'cancel' && action !== 'run')) {
|
|
51
|
+
return res.status(400).json({ error: 'expected { id, action: "cancel" | "run" }' });
|
|
52
|
+
}
|
|
53
|
+
let result;
|
|
54
|
+
try {
|
|
55
|
+
result = _ctx.runtime.action(id, action);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
if (err instanceof NotFoundError) return res.status(404).json({ error: err.message });
|
|
58
|
+
if (err instanceof TypeError) return res.status(400).json({ error: err.message });
|
|
59
|
+
return res.status(500).json({ error: err.message });
|
|
60
|
+
}
|
|
61
|
+
const what = `${result.kind}${result.name ? ` "${result.name}"` : ''}`;
|
|
62
|
+
_ctx.log(result.script, `${what} ${action === 'cancel' ? 'cancelled' : 'run now'} from the editor`);
|
|
63
|
+
res.json({ ok: true, ...result });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
module.exports = { router, init };
|
package/src/web/server.js
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
const express = require('express');
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const { spawn } = require('child_process');
|
|
6
|
-
const semverCompare = require('semantic-compare');
|
|
7
6
|
const pkg = require('../../package.json');
|
|
8
|
-
const { router: configRouter } = require('./config-api');
|
|
7
|
+
const { router: configRouter, DEFAULT_CONFIG_PATH } = require('./config-api');
|
|
8
|
+
const updateChannel = require('../lib/update-channel');
|
|
9
9
|
const { router: scriptsRouter } = require('./scripts-api');
|
|
10
10
|
const { router: shedbRouter } = require('./shedb-api');
|
|
11
11
|
const { router: matterRouter } = require('./matter-api');
|
|
@@ -76,6 +76,9 @@ app.use('/she/services', servicesRouter);
|
|
|
76
76
|
// Secrets (roadmap A5): write-only, values never leave the daemon
|
|
77
77
|
app.use('/she/secrets', secretsRouter);
|
|
78
78
|
|
|
79
|
+
// The runtime model: handlers, timers, statistics, gauge history, the editor's timer actions
|
|
80
|
+
app.use('/she/runtime', require('./runtime-api').router);
|
|
81
|
+
|
|
79
82
|
// Graceful daemon restart
|
|
80
83
|
// When running under systemd, delegate to `sudo systemctl restart` so the
|
|
81
84
|
// service actually comes back up. Otherwise fall back to exit(0) and let
|
|
@@ -93,33 +96,61 @@ app.post('/she/restart', (req, res) => {
|
|
|
93
96
|
setTimeout(_systemdRestart, 200);
|
|
94
97
|
});
|
|
95
98
|
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
+
// Update check — the GitHub releases of she, by channel: `stable` offers the newest release,
|
|
100
|
+
// `beta` also the pre-releases. Polled once at start and every 24 hours, and on the UI's button.
|
|
101
|
+
const _isDocker = require('fs').existsSync('/.dockerenv');
|
|
102
|
+
let _update = null; // the last check's result (update-channel.checkUpdate)
|
|
103
|
+
let _startupChannel = 'stable';
|
|
104
|
+
|
|
105
|
+
// The channel is read from the config file at every check, so a change on the Config page
|
|
106
|
+
// takes effect without a restart; the CLI/env value is the fallback when the file has none.
|
|
107
|
+
function currentChannel() {
|
|
99
108
|
try {
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
_latestNpmVersion = data.version && semverCompare(pkg.version, data.version) < 0 ? data.version : null;
|
|
109
|
+
const file = JSON.parse(require('fs').readFileSync(app.locals.configPath || DEFAULT_CONFIG_PATH, 'utf8'));
|
|
110
|
+
if (updateChannel.CHANNELS.includes(file.updateChannel)) return file.updateChannel;
|
|
103
111
|
} catch {
|
|
104
|
-
/*
|
|
112
|
+
/* no file, or not ours to parse */
|
|
105
113
|
}
|
|
114
|
+
return _startupChannel;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function _checkUpdate() {
|
|
118
|
+
_update = await updateChannel.checkUpdate({ installed: pkg.version, channel: currentChannel(), docker: _isDocker });
|
|
119
|
+
return _update;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function _updateFields() {
|
|
123
|
+
const f = { updateChannel: _update ? _update.channel : currentChannel() };
|
|
124
|
+
if (_update && _update.latest) {
|
|
125
|
+
f.latestVersion = _update.latest;
|
|
126
|
+
if (_update.latestIsPrerelease) f.latestPrerelease = true;
|
|
127
|
+
}
|
|
128
|
+
if (_update && _update.stable) f.latestStable = _update.stable;
|
|
129
|
+
if (_update && _update.prerelease) f.latestBeta = _update.prerelease;
|
|
130
|
+
if (_update && _update.error) f.updateError = _update.error;
|
|
131
|
+
return f;
|
|
106
132
|
}
|
|
107
|
-
_checkNpmVersion();
|
|
108
|
-
setInterval(_checkNpmVersion, 24 * 60 * 60 * 1000);
|
|
109
133
|
|
|
110
134
|
// Trigger an immediate version check (called by the UI refresh button)
|
|
111
135
|
app.post('/she/check-update', async (req, res) => {
|
|
112
|
-
await
|
|
113
|
-
res.json({ latestVersion:
|
|
136
|
+
const r = await _checkUpdate();
|
|
137
|
+
res.json({ latestVersion: r.latest, ..._updateFields() });
|
|
114
138
|
});
|
|
115
139
|
|
|
116
|
-
// Update — install
|
|
140
|
+
// Update — install the offered version exactly, then restart. Without a known version there is
|
|
141
|
+
// nothing to install: `npm install -g smart-home-engine` would take npm's `latest`, which can be
|
|
142
|
+
// older than a running pre-release.
|
|
117
143
|
app.post('/she/update', (req, res) => {
|
|
118
|
-
|
|
144
|
+
const version = _update && _update.latest;
|
|
145
|
+
if (!version || _isDocker) {
|
|
146
|
+
res.status(409).json({ ok: false, error: _isDocker ? 'a Docker container is updated by pulling a new image' : 'no update known — check for updates first' });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
res.json({ ok: true, version });
|
|
119
150
|
setTimeout(() => {
|
|
120
151
|
// Use async spawn so the HTTP server (and event loop) remain responsive
|
|
121
152
|
// while npm runs. spawnSync would block all HTTP polling from the UI.
|
|
122
|
-
const child = spawn('sudo', ['npm', 'install', '-g',
|
|
153
|
+
const child = spawn('sudo', ['npm', 'install', '-g', `${updateChannel.PACKAGE}@${version}`], { stdio: 'inherit' });
|
|
123
154
|
child.on('close', () => _systemdRestart());
|
|
124
155
|
}, 200);
|
|
125
156
|
});
|
|
@@ -158,10 +189,8 @@ app.get('/she/health', (req, res) => {
|
|
|
158
189
|
res.set('Cache-Control', 'no-store');
|
|
159
190
|
res.status(ok ? 200 : 503).json(body);
|
|
160
191
|
});
|
|
161
|
-
const _isDocker = require('fs').existsSync('/.dockerenv');
|
|
162
192
|
app.get('/she/status', (req, res) => {
|
|
163
|
-
const s = _getStats ? _getStats() : { scripts: 0, topics: 0 };
|
|
164
|
-
if (_latestNpmVersion) s.latestVersion = _latestNpmVersion;
|
|
193
|
+
const s = Object.assign(_getStats ? _getStats() : { scripts: 0, topics: 0 }, _updateFields());
|
|
165
194
|
s.dataDir = require('../lib/storage').STORAGE_ROOT;
|
|
166
195
|
s.user = require('os').userInfo().username; // the OS user the daemon runs as (default SSH user for managed hosts)
|
|
167
196
|
s.startedAt = SERVER_START_TIME;
|
|
@@ -297,6 +326,9 @@ function startServer(port, options = {}) {
|
|
|
297
326
|
if (options.scriptDir) {
|
|
298
327
|
app.locals.scriptDir = options.scriptDir;
|
|
299
328
|
}
|
|
329
|
+
_startupChannel = updateChannel.normalizeChannel(options.updateChannel);
|
|
330
|
+
_checkUpdate();
|
|
331
|
+
setInterval(_checkUpdate, 24 * 60 * 60 * 1000).unref();
|
|
300
332
|
const host = options.bindAddress || '0.0.0.0';
|
|
301
333
|
return new Promise((resolve, reject) => {
|
|
302
334
|
httpServer = app.listen(port, host, () => {
|