smart-home-engine 1.19.1 → 1.20.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.
@@ -1,4 +1,4 @@
1
- import{m as O}from"./monaco-langs-Dt8a9eeo.js";import{t as I}from"./index-BAMNkTvJ.js";/*!-----------------------------------------------------------------------------
1
+ import{m as O}from"./monaco-langs-Dt8a9eeo.js";import{t as I}from"./index-DxR31sxM.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-BAMNkTvJ.js"></script>
175
+ <script type="module" crossorigin src="/assets/index-DxR31sxM.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-nf1nG74s.css">
178
+ <link rel="stylesheet" crossorigin href="/assets/index-CjRDTePT.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.19.1",
3
+ "version": "1.20.1",
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": {
@@ -9,6 +9,8 @@ User=she
9
9
  Group=she
10
10
  Restart=on-failure
11
11
  RestartSec=5s
12
+ TimeoutStopSec=3
13
+ KillMode=mixed
12
14
 
13
15
  ExecStart=/usr/local/bin/she --data-dir /var/lib/she
14
16
 
package/src/influx.js CHANGED
@@ -1,16 +1,38 @@
1
1
  'use strict';
2
2
 
3
- let _client = null;
3
+ let _client = null; // v2 client (@influxdata/influxdb-client)
4
4
  let _opts = null;
5
+ let _mode = null; // 'v1' | 'v2' | null
6
+
7
+ const V1_TIMEOUT_MS = 10000;
8
+
9
+ function v1Timeout() {
10
+ return Number(_opts.timeout) || V1_TIMEOUT_MS;
11
+ }
5
12
 
6
13
  /**
7
14
  * Initialise the InfluxDB client. Called from index.js when config.influx is set.
8
- * @param {{ url: string, token: string, org: string, bucket: string }} opts
15
+ *
16
+ * v2 (token/org/bucket): { url, token, org, bucket }
17
+ * v1 (database/user/password): { url, database, username?, password?, retentionPolicy?, version?: 1 }
18
+ *
19
+ * The API version is taken from opts.version when present; otherwise it is
20
+ * inferred: a `database` key (and no `token`) means v1, a `token` means v2.
21
+ * @param {object} opts
9
22
  */
10
23
  function init(opts) {
11
- if (!opts || !opts.url || !opts.token) return;
24
+ if (!opts || !opts.url) return;
25
+ const version = Number(opts.version) || (opts.database && !opts.token ? 1 : 2);
26
+ if (version === 1) {
27
+ if (!opts.database) return;
28
+ _opts = opts;
29
+ _mode = 'v1';
30
+ return;
31
+ }
32
+ if (!opts.token) return;
12
33
  const { InfluxDB } = require('@influxdata/influxdb-client');
13
34
  _opts = opts;
35
+ _mode = 'v2';
14
36
  _client = new InfluxDB({ url: opts.url, token: opts.token });
15
37
  }
16
38
 
@@ -22,4 +44,143 @@ function getOpts() {
22
44
  return _opts;
23
45
  }
24
46
 
25
- module.exports = { init, getClient, getOpts };
47
+ /** @returns {'v1'|'v2'|null} */
48
+ function getMode() {
49
+ return _mode;
50
+ }
51
+
52
+ // ── v1 (InfluxDB 1.x) HTTP API ──────────────────────────────────────────────
53
+
54
+ function v1Headers() {
55
+ const headers = {};
56
+ if (_opts.username) {
57
+ headers.Authorization = 'Basic ' + Buffer.from(`${_opts.username}:${_opts.password || ''}`).toString('base64');
58
+ }
59
+ return headers;
60
+ }
61
+
62
+ /**
63
+ * Escape a measurement name for line protocol (commas and spaces).
64
+ * @param {string} s
65
+ */
66
+ function escapeMeasurement(s) {
67
+ return String(s).replace(/,/g, '\\,').replace(/ /g, '\\ ');
68
+ }
69
+
70
+ /**
71
+ * Escape a tag key, tag value or field key for line protocol (commas, equals signs, spaces).
72
+ * @param {string} s
73
+ */
74
+ function escapeTag(s) {
75
+ return String(s).replace(/,/g, '\\,').replace(/=/g, '\\=').replace(/ /g, '\\ ');
76
+ }
77
+
78
+ /**
79
+ * Serialise a field value for line protocol: booleans and finite numbers pass
80
+ * through natively, everything else becomes a quoted string.
81
+ * @param {*} v
82
+ */
83
+ function fieldValue(v) {
84
+ if (typeof v === 'boolean') return v ? 'true' : 'false';
85
+ if (typeof v === 'number' && Number.isFinite(v)) return String(v);
86
+ return '"' + String(v).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
87
+ }
88
+
89
+ /**
90
+ * Build a single line-protocol line.
91
+ * @param {string} measurement
92
+ * @param {object} fields e.g. { temperature: 21.5 }
93
+ * @param {object} [tags] e.g. { room: 'living' }
94
+ * @param {Date|number} [timestamp] ms precision
95
+ * @returns {string}
96
+ */
97
+ function buildLine(measurement, fields, tags, timestamp) {
98
+ let line = escapeMeasurement(measurement);
99
+ if (tags) {
100
+ for (const [k, v] of Object.entries(tags)) line += `,${escapeTag(k)}=${escapeTag(v)}`;
101
+ }
102
+ line +=
103
+ ' ' +
104
+ Object.entries(fields)
105
+ .map(([k, v]) => `${escapeTag(k)}=${fieldValue(v)}`)
106
+ .join(',');
107
+ if (timestamp !== undefined) {
108
+ const ts = timestamp instanceof Date ? timestamp.getTime() : Number(timestamp);
109
+ line += ' ' + ts;
110
+ }
111
+ return line;
112
+ }
113
+
114
+ /**
115
+ * Write line-protocol data via the v1 endpoint (POST /write?db=…&precision=ms).
116
+ * @param {string} lines one or more line-protocol lines, newline-separated
117
+ * @returns {Promise<void>}
118
+ */
119
+ async function v1Write(lines) {
120
+ const params = new URLSearchParams({ db: _opts.database, precision: 'ms' });
121
+ if (_opts.retentionPolicy) params.set('rp', _opts.retentionPolicy);
122
+ const res = await fetch(`${_opts.url.replace(/\/$/, '')}/write?${params}`, {
123
+ method: 'POST',
124
+ headers: v1Headers(),
125
+ body: lines,
126
+ signal: AbortSignal.timeout(v1Timeout()),
127
+ });
128
+ if (!res.ok) {
129
+ const body = await res.text().catch(() => '');
130
+ throw new Error(`influx v1 write failed: HTTP ${res.status} ${body}`.trim());
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Run an InfluxQL query via the v1 endpoint (GET /query?db=…&q=…&epoch=ms) and
136
+ * flatten the result into plain row objects: one object per value row, with the
137
+ * series' columns as keys, series tags merged in, and the measurement name as
138
+ * `_measurement`. `time` is a millisecond timestamp (epoch=ms).
139
+ * @param {string} q InfluxQL query
140
+ * @returns {Promise<object[]>}
141
+ */
142
+ async function v1Query(q) {
143
+ const params = new URLSearchParams({ db: _opts.database, q, epoch: 'ms' });
144
+ const res = await fetch(`${_opts.url.replace(/\/$/, '')}/query?${params}`, {
145
+ headers: v1Headers(),
146
+ signal: AbortSignal.timeout(v1Timeout()),
147
+ });
148
+ if (!res.ok) {
149
+ const body = await res.text().catch(() => '');
150
+ throw new Error(`influx v1 query failed: HTTP ${res.status} ${body}`.trim());
151
+ }
152
+ const json = await res.json();
153
+ const rows = [];
154
+ for (const result of json.results || []) {
155
+ if (result.error) throw new Error(`influx v1 query failed: ${result.error}`);
156
+ for (const series of result.series || []) {
157
+ for (const values of series.values || []) {
158
+ const row = { _measurement: series.name, ...series.tags };
159
+ series.columns.forEach((col, i) => {
160
+ row[col] = values[i];
161
+ });
162
+ rows.push(row);
163
+ }
164
+ }
165
+ }
166
+ return rows;
167
+ }
168
+
169
+ /**
170
+ * Escape a string for use inside an InfluxQL single-quoted string literal.
171
+ * @param {string} s
172
+ */
173
+ function escapeQL(s) {
174
+ return String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
175
+ }
176
+
177
+ /**
178
+ * Escape a string for use inside an InfluxQL double-quoted identifier
179
+ * (measurement/tag/field name).
180
+ * @param {string} s
181
+ */
182
+ function escapeIdent(s) {
183
+ return String(s).replace(/"/g, '\\"');
184
+ }
185
+
186
+ module.exports = { init, getClient, getOpts, getMode, buildLine, v1Write, v1Query, escapeQL, escapeIdent };
@@ -5,30 +5,66 @@ const influx = require('../influx');
5
5
  /**
6
6
  * Sandbox module — adds she.influx.* to every script context.
7
7
  *
8
+ * Works against InfluxDB 2.x (url/token/org/bucket → Flux) and InfluxDB 1.x
9
+ * (url/database/username/password → InfluxQL over the v1 HTTP API).
10
+ *
8
11
  * All methods return Promises and are no-ops (returning empty results) when
9
- * InfluxDB is not configured (no --influx.url / --influx.token in config).
12
+ * InfluxDB is not configured (no influx block in config).
10
13
  *
11
14
  * she.influx API:
12
- * she.influx.query(fluxQuery) → Promise<object[]>
15
+ * she.influx.query(query) → Promise<object[]>
16
+ * (Flux in v2 mode, InfluxQL in v1 mode)
13
17
  * she.influx.write(measurement, fields, tags, ts) → Promise<void>
14
18
  * she.influx.getLast(topic, n) → Promise<{ ts, val }[]>
15
19
  * she.influx.getRange(topic, from, to) → Promise<{ ts, val }[]>
16
20
  */
21
+
22
+ /**
23
+ * Extract the value from a v1 result row: prefer the conventional "value"
24
+ * (then "val") field, otherwise take the first column that is not time/topic
25
+ * metadata.
26
+ * @param {object} row
27
+ */
28
+ function v1RowVal(row) {
29
+ if (row.value !== undefined && row.value !== null) return row.value;
30
+ if (row.val !== undefined && row.val !== null) return row.val;
31
+ for (const [k, v] of Object.entries(row)) {
32
+ if (k === 'time' || k === 'topic' || k === '_measurement' || v === null || v === undefined) continue;
33
+ return v;
34
+ }
35
+ return null;
36
+ }
37
+
38
+ /**
39
+ * Map v1 result rows to the { ts, val } shape, newest-trimmed to n (LIMIT
40
+ * applies per series in InfluxQL, so multi-series results must be merged and
41
+ * re-trimmed), sorted oldest-first.
42
+ * @param {object[]} rows
43
+ * @param {number} [n]
44
+ */
45
+ function v1Points(rows, n) {
46
+ const pts = rows.map((r) => ({ ts: r.time, val: v1RowVal(r) })).sort((a, b) => b.ts - a.ts);
47
+ return (n ? pts.slice(0, Number(n)) : pts).reverse();
48
+ }
49
+
17
50
  module.exports = function (she) {
18
51
  she.influx = {
19
52
  /**
20
- * Execute a Flux query against InfluxDB.
21
- * @param {string} fluxQuery
53
+ * Execute a query against InfluxDB — a Flux query in v2 mode, an
54
+ * InfluxQL query in v1 mode.
55
+ * @param {string} query
22
56
  * @returns {Promise<object[]>}
23
57
  */
24
- query(fluxQuery) {
58
+ query(query) {
59
+ const mode = influx.getMode();
60
+ if (!mode) return Promise.resolve([]);
61
+ if (mode === 'v1') return influx.v1Query(query);
25
62
  const client = influx.getClient();
26
63
  const opts = influx.getOpts();
27
- if (!client) return Promise.resolve([]);
28
64
  const queryApi = client.getQueryApi(opts.org);
29
65
  return new Promise((resolve, reject) => {
30
66
  const rows = [];
31
- queryApi.queryRows(fluxQuery, {
67
+ queryApi.queryRows(query, {
32
68
  next(row, tableMeta) {
33
69
  rows.push(tableMeta.toObject(row));
34
70
  },
@@ -49,9 +85,13 @@ module.exports = function (she) {
49
85
  * @returns {Promise<void>}
50
86
  */
51
87
  write(measurement, fields, tags, timestamp) {
88
+ const mode = influx.getMode();
89
+ if (!mode) return Promise.resolve();
90
+ if (mode === 'v1') {
91
+ return influx.v1Write(influx.buildLine(measurement, fields, tags, timestamp));
92
+ }
52
93
  const client = influx.getClient();
53
94
  const opts = influx.getOpts();
54
- if (!client) return Promise.resolve();
55
95
  const { Point } = require('@influxdata/influxdb-client');
56
96
  const writeApi = client.getWriteApi(opts.org, opts.bucket, 'ns');
57
97
  const point = new Point(measurement);
@@ -73,15 +113,32 @@ module.exports = function (she) {
73
113
  },
74
114
 
75
115
  /**
76
- * Return the last N recorded values for an MQTT topic.
77
- * Assumes data was stored with a "topic" tag and value in the "_value" field.
116
+ * Return the last N recorded values for an MQTT topic (last 30 days).
117
+ * v2: assumes points carry a "topic" tag, value from "_value".
118
+ * v1: tries a measurement named like the topic first (influx4mqtt-style
119
+ * schema), then falls back to a "topic" tag scan; value from the
120
+ * "value" field, falling back to the first data column.
121
+ * Results are sorted oldest-first.
78
122
  * @param {string} topic
79
123
  * @param {number} n
80
124
  * @returns {Promise<{ ts: number, val: any }[]>}
81
125
  */
82
126
  getLast(topic, n) {
127
+ const mode = influx.getMode();
128
+ if (!mode) return Promise.resolve([]);
129
+ if (mode === 'v1') {
130
+ // Fast path: measurement named like the topic (the common
131
+ // mqtt-to-influx v1 schema, e.g. influx4mqtt). Fallback: points
132
+ // stored with a "topic" tag — that needs a scan across all
133
+ // measurements, so keep it time-bounded (30d, like the v2 path).
134
+ const mQl = `SELECT * FROM "${influx.escapeIdent(topic)}" WHERE time >= now() - 30d ORDER BY time DESC LIMIT ${Number(n)}`;
135
+ return influx.v1Query(mQl).then((rows) => {
136
+ if (rows.length) return v1Points(rows, n);
137
+ const tagQl = `SELECT * FROM /.*/ WHERE "topic" = '${influx.escapeQL(topic)}' AND time >= now() - 30d ORDER BY time DESC LIMIT ${Number(n)}`;
138
+ return influx.v1Query(tagQl).then((tagRows) => v1Points(tagRows, n));
139
+ });
140
+ }
83
141
  const opts = influx.getOpts();
84
- if (!opts) return Promise.resolve([]);
85
142
  const safeTopic = topic.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
86
143
  const flux = `from(bucket: "${opts.bucket}")` + ` |> range(start: -30d)` + ` |> filter(fn: (r) => r["topic"] == "${safeTopic}")` + ` |> tail(n: ${Number(n)})`;
87
144
  return this.query(flux).then((rows) => rows.map((r) => ({ ts: new Date(r._time).getTime(), val: r._value })));
@@ -95,8 +152,20 @@ module.exports = function (she) {
95
152
  * @returns {Promise<{ ts: number, val: any }[]>}
96
153
  */
97
154
  getRange(topic, from, to) {
155
+ const mode = influx.getMode();
156
+ if (!mode) return Promise.resolve([]);
157
+ if (mode === 'v1') {
158
+ const fromMs = new Date(from).getTime();
159
+ const toMs = new Date(to).getTime();
160
+ const bounds = `time >= ${fromMs}ms AND time <= ${toMs}ms`;
161
+ const mQl = `SELECT * FROM "${influx.escapeIdent(topic)}" WHERE ${bounds}`;
162
+ return influx.v1Query(mQl).then((rows) => {
163
+ if (rows.length) return v1Points(rows);
164
+ const tagQl = `SELECT * FROM /.*/ WHERE "topic" = '${influx.escapeQL(topic)}' AND ${bounds}`;
165
+ return influx.v1Query(tagQl).then((tagRows) => v1Points(tagRows));
166
+ });
167
+ }
98
168
  const opts = influx.getOpts();
99
- if (!opts) return Promise.resolve([]);
100
169
  const safeTopic = topic.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
101
170
  const start = new Date(from).toISOString();
102
171
  const stop = new Date(to).toISOString();
package/src/web/log-ws.js CHANGED
@@ -23,8 +23,8 @@ const LOG_BUFFER_MAX = 500;
23
23
  /**
24
24
  * Attach a WebSocketServer to an existing http.Server.
25
25
  * Clients connect to /she/ws. Once connected they receive:
26
- * - { type: 'log', line: string } — every pino log line
27
- * - { type: 'ping' } — keepalive every 30 s
26
+ * - { type: 'log', level, msg, ts } — every daemon/script log entry
27
+ * - { type: 'ping' } — keepalive every 30 s
28
28
  *
29
29
  * @param {import('http').Server} httpServer
30
30
  * @param {(req: import('http').IncomingMessage) => boolean} [authCheck]