smart-home-engine 2.1.0 → 3.0.0-beta.1

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/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
- ws.on('close', () => _clients.delete(ws));
58
- ws.on('error', () => _clients.delete(ws));
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
  };
@@ -6,12 +6,14 @@ she.mqtt.pub(topic, payload, [opts]) publish (commands: no retain); { changed: t
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
14
  setTimeout / setInterval available; she.timeout(ms, cb) → handle.restart()/cancel() is a restartable timer (motion light)
14
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()
15
17
  ```
16
18
  Variables: read `var/status/<name>`, write `she.mqtt.pub('var/set/<name>', value)`.
17
19
  For anything else ask with the tool she_api_doc or say that you are not sure.
@@ -22,6 +22,14 @@ she.mqtt.link(src, target, [fn]) Forward src changes to target, optionally
22
22
  she.mqtt.or / and / max / min(srcs[], topicOrCb) Combine sources; publish 1/0 or the extreme value; re-evaluated on every change
23
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
24
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.
25
33
  ```
26
34
 
27
35
  ### Timing helpers (MILLISECONDS, like setTimeout; cleared on unload/reload; pending runs listed by list_timers with kind and name)
@@ -45,6 +53,21 @@ she.now() Milliseconds since the epoch
45
53
  setTimeout / setInterval Available; cleared automatically when the script is unloaded; a bad delay is logged with the script name
46
54
  ```
47
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
69
+ ```
70
+
48
71
  ### sheDB (documents, ids like topics: devices/lamp1)
49
72
  ```
50
73
  she.db.get(id) Document or undefined
@@ -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
- // npm version check — poll once on startup and every 24 hours
97
- let _latestNpmVersion = null;
98
- async function _checkNpmVersion() {
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 res = await fetch('https://registry.npmjs.org/smart-home-engine/latest');
101
- const data = await res.json();
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
- /* best-effort */
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 _checkNpmVersion();
113
- res.json({ latestVersion: _latestNpmVersion });
136
+ const r = await _checkUpdate();
137
+ res.json({ latestVersion: r.latest, ..._updateFields() });
114
138
  });
115
139
 
116
- // Update — install latest npm package, then restart
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
- res.json({ ok: true });
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', 'smart-home-engine'], { stdio: 'inherit' });
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, () => {