smart-home-engine 1.19.0 → 1.20.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/README.md +1 -1
- package/dist/web/assets/index-BUev42o8.js +234 -0
- package/dist/web/assets/{tsMode-DXWg2fgD.js → tsMode-Dbzvu1EX.js} +1 -1
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/service/smart-home-engine.service +2 -0
- package/src/index.js +9 -6
- package/src/influx.js +152 -4
- package/src/sandbox/influx-sandbox.js +61 -11
- package/src/web/log-ws.js +2 -2
- package/dist/web/assets/index-xdnNresq.js +0 -234
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{m as O}from"./monaco-langs-Dt8a9eeo.js";import{t as I}from"./index-
|
|
1
|
+
import{m as O}from"./monaco-langs-Dt8a9eeo.js";import{t as I}from"./index-BUev42o8.js";/*!-----------------------------------------------------------------------------
|
|
2
2
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
3
3
|
* Version: 0.52.2(404545bded1df6ffa41ea0af4e8ddb219018c6c1)
|
|
4
4
|
* Released under the MIT license
|
package/dist/web/index.html
CHANGED
|
@@ -172,7 +172,7 @@
|
|
|
172
172
|
}
|
|
173
173
|
})();
|
|
174
174
|
</script>
|
|
175
|
-
<script type="module" crossorigin src="/assets/index-
|
|
175
|
+
<script type="module" crossorigin src="/assets/index-BUev42o8.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
178
|
<link rel="stylesheet" crossorigin href="/assets/index-nf1nG74s.css">
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -194,14 +194,15 @@ const _global = {};
|
|
|
194
194
|
|
|
195
195
|
// ── Event-loop heartbeat ─────────────────────────────────────────────────────
|
|
196
196
|
let _blockingScript = null;
|
|
197
|
+
let _blockingScriptTs = 0; // timestamp when the last _dispatch() call finished
|
|
197
198
|
|
|
198
199
|
/** Wrap a user callback dispatch so the heartbeat can identify the active script. */
|
|
199
200
|
function _dispatch(label, fn) {
|
|
200
201
|
_blockingScript = label;
|
|
201
202
|
fn();
|
|
202
|
-
setImmediate
|
|
203
|
-
|
|
204
|
-
|
|
203
|
+
// Record end-time synchronously — no setImmediate, which would race with the
|
|
204
|
+
// heartbeat timer when blocking occurs inside a poll-phase (I/O) callback.
|
|
205
|
+
_blockingScriptTs = Date.now();
|
|
205
206
|
}
|
|
206
207
|
|
|
207
208
|
// Sun scheduling
|
|
@@ -361,7 +362,7 @@ require('./web/server').setStatsProvider(() => {
|
|
|
361
362
|
const eluDelta = performance.eventLoopUtilization(_prevElu);
|
|
362
363
|
_prevElu = performance.eventLoopUtilization();
|
|
363
364
|
const elMeanMs = Math.round(_elHisto.mean / 1e6);
|
|
364
|
-
const
|
|
365
|
+
const elMaxMs = Math.round(_elHisto.max / 1e6);
|
|
365
366
|
_elHisto.reset();
|
|
366
367
|
const mem = process.memoryUsage();
|
|
367
368
|
const memMb = Math.round(mem.rss / 1048576);
|
|
@@ -402,7 +403,7 @@ require('./web/server').setStatsProvider(() => {
|
|
|
402
403
|
cpuPercent,
|
|
403
404
|
eluPercent: Math.round(eluDelta.utilization * 100),
|
|
404
405
|
elMeanMs,
|
|
405
|
-
|
|
406
|
+
elMaxMs,
|
|
406
407
|
};
|
|
407
408
|
});
|
|
408
409
|
|
|
@@ -1512,9 +1513,11 @@ function start() {
|
|
|
1512
1513
|
const _hbTimer = setInterval(() => {
|
|
1513
1514
|
const now = Date.now();
|
|
1514
1515
|
const lag = now - _lastBeat - _hbInterval;
|
|
1516
|
+
const prevBeat = _lastBeat;
|
|
1515
1517
|
_lastBeat = now;
|
|
1516
1518
|
if (lag > _hbThreshold) {
|
|
1517
|
-
|
|
1519
|
+
// Attribute to a script only if it dispatched a callback since the previous beat.
|
|
1520
|
+
const label = _blockingScriptTs >= prevBeat ? (_blockingScript ?? 'unknown') : 'unknown';
|
|
1518
1521
|
if (lag > 2000) {
|
|
1519
1522
|
log.warn(`event loop blocked ${lag}ms — script: ${label}`);
|
|
1520
1523
|
} else {
|
package/src/influx.js
CHANGED
|
@@ -1,16 +1,34 @@
|
|
|
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;
|
|
5
8
|
|
|
6
9
|
/**
|
|
7
10
|
* Initialise the InfluxDB client. Called from index.js when config.influx is set.
|
|
8
|
-
*
|
|
11
|
+
*
|
|
12
|
+
* v2 (token/org/bucket): { url, token, org, bucket }
|
|
13
|
+
* v1 (database/user/password): { url, database, username?, password?, retentionPolicy?, version?: 1 }
|
|
14
|
+
*
|
|
15
|
+
* The API version is taken from opts.version when present; otherwise it is
|
|
16
|
+
* inferred: a `database` key (and no `token`) means v1, a `token` means v2.
|
|
17
|
+
* @param {object} opts
|
|
9
18
|
*/
|
|
10
19
|
function init(opts) {
|
|
11
|
-
if (!opts || !opts.url
|
|
20
|
+
if (!opts || !opts.url) return;
|
|
21
|
+
const version = Number(opts.version) || (opts.database && !opts.token ? 1 : 2);
|
|
22
|
+
if (version === 1) {
|
|
23
|
+
if (!opts.database) return;
|
|
24
|
+
_opts = opts;
|
|
25
|
+
_mode = 'v1';
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (!opts.token) return;
|
|
12
29
|
const { InfluxDB } = require('@influxdata/influxdb-client');
|
|
13
30
|
_opts = opts;
|
|
31
|
+
_mode = 'v2';
|
|
14
32
|
_client = new InfluxDB({ url: opts.url, token: opts.token });
|
|
15
33
|
}
|
|
16
34
|
|
|
@@ -22,4 +40,134 @@ function getOpts() {
|
|
|
22
40
|
return _opts;
|
|
23
41
|
}
|
|
24
42
|
|
|
25
|
-
|
|
43
|
+
/** @returns {'v1'|'v2'|null} */
|
|
44
|
+
function getMode() {
|
|
45
|
+
return _mode;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── v1 (InfluxDB 1.x) HTTP API ──────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
function v1Headers() {
|
|
51
|
+
const headers = {};
|
|
52
|
+
if (_opts.username) {
|
|
53
|
+
headers.Authorization = 'Basic ' + Buffer.from(`${_opts.username}:${_opts.password || ''}`).toString('base64');
|
|
54
|
+
}
|
|
55
|
+
return headers;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Escape a measurement name for line protocol (commas and spaces).
|
|
60
|
+
* @param {string} s
|
|
61
|
+
*/
|
|
62
|
+
function escapeMeasurement(s) {
|
|
63
|
+
return String(s).replace(/,/g, '\\,').replace(/ /g, '\\ ');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Escape a tag key, tag value or field key for line protocol (commas, equals signs, spaces).
|
|
68
|
+
* @param {string} s
|
|
69
|
+
*/
|
|
70
|
+
function escapeTag(s) {
|
|
71
|
+
return String(s).replace(/,/g, '\\,').replace(/=/g, '\\=').replace(/ /g, '\\ ');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Serialise a field value for line protocol: booleans and finite numbers pass
|
|
76
|
+
* through natively, everything else becomes a quoted string.
|
|
77
|
+
* @param {*} v
|
|
78
|
+
*/
|
|
79
|
+
function fieldValue(v) {
|
|
80
|
+
if (typeof v === 'boolean') return v ? 'true' : 'false';
|
|
81
|
+
if (typeof v === 'number' && Number.isFinite(v)) return String(v);
|
|
82
|
+
return '"' + String(v).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Build a single line-protocol line.
|
|
87
|
+
* @param {string} measurement
|
|
88
|
+
* @param {object} fields e.g. { temperature: 21.5 }
|
|
89
|
+
* @param {object} [tags] e.g. { room: 'living' }
|
|
90
|
+
* @param {Date|number} [timestamp] ms precision
|
|
91
|
+
* @returns {string}
|
|
92
|
+
*/
|
|
93
|
+
function buildLine(measurement, fields, tags, timestamp) {
|
|
94
|
+
let line = escapeMeasurement(measurement);
|
|
95
|
+
if (tags) {
|
|
96
|
+
for (const [k, v] of Object.entries(tags)) line += `,${escapeTag(k)}=${escapeTag(v)}`;
|
|
97
|
+
}
|
|
98
|
+
line +=
|
|
99
|
+
' ' +
|
|
100
|
+
Object.entries(fields)
|
|
101
|
+
.map(([k, v]) => `${escapeTag(k)}=${fieldValue(v)}`)
|
|
102
|
+
.join(',');
|
|
103
|
+
if (timestamp !== undefined) {
|
|
104
|
+
const ts = timestamp instanceof Date ? timestamp.getTime() : Number(timestamp);
|
|
105
|
+
line += ' ' + ts;
|
|
106
|
+
}
|
|
107
|
+
return line;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Write line-protocol data via the v1 endpoint (POST /write?db=…&precision=ms).
|
|
112
|
+
* @param {string} lines one or more line-protocol lines, newline-separated
|
|
113
|
+
* @returns {Promise<void>}
|
|
114
|
+
*/
|
|
115
|
+
async function v1Write(lines) {
|
|
116
|
+
const params = new URLSearchParams({ db: _opts.database, precision: 'ms' });
|
|
117
|
+
if (_opts.retentionPolicy) params.set('rp', _opts.retentionPolicy);
|
|
118
|
+
const res = await fetch(`${_opts.url.replace(/\/$/, '')}/write?${params}`, {
|
|
119
|
+
method: 'POST',
|
|
120
|
+
headers: v1Headers(),
|
|
121
|
+
body: lines,
|
|
122
|
+
signal: AbortSignal.timeout(V1_TIMEOUT_MS),
|
|
123
|
+
});
|
|
124
|
+
if (!res.ok) {
|
|
125
|
+
const body = await res.text().catch(() => '');
|
|
126
|
+
throw new Error(`influx v1 write failed: HTTP ${res.status} ${body}`.trim());
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Run an InfluxQL query via the v1 endpoint (GET /query?db=…&q=…&epoch=ms) and
|
|
132
|
+
* flatten the result into plain row objects: one object per value row, with the
|
|
133
|
+
* series' columns as keys, series tags merged in, and the measurement name as
|
|
134
|
+
* `_measurement`. `time` is a millisecond timestamp (epoch=ms).
|
|
135
|
+
* @param {string} q InfluxQL query
|
|
136
|
+
* @returns {Promise<object[]>}
|
|
137
|
+
*/
|
|
138
|
+
async function v1Query(q) {
|
|
139
|
+
const params = new URLSearchParams({ db: _opts.database, q, epoch: 'ms' });
|
|
140
|
+
const res = await fetch(`${_opts.url.replace(/\/$/, '')}/query?${params}`, {
|
|
141
|
+
headers: v1Headers(),
|
|
142
|
+
signal: AbortSignal.timeout(V1_TIMEOUT_MS),
|
|
143
|
+
});
|
|
144
|
+
if (!res.ok) {
|
|
145
|
+
const body = await res.text().catch(() => '');
|
|
146
|
+
throw new Error(`influx v1 query failed: HTTP ${res.status} ${body}`.trim());
|
|
147
|
+
}
|
|
148
|
+
const json = await res.json();
|
|
149
|
+
const rows = [];
|
|
150
|
+
for (const result of json.results || []) {
|
|
151
|
+
if (result.error) throw new Error(`influx v1 query failed: ${result.error}`);
|
|
152
|
+
for (const series of result.series || []) {
|
|
153
|
+
for (const values of series.values || []) {
|
|
154
|
+
const row = { _measurement: series.name, ...series.tags };
|
|
155
|
+
series.columns.forEach((col, i) => {
|
|
156
|
+
row[col] = values[i];
|
|
157
|
+
});
|
|
158
|
+
rows.push(row);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return rows;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Escape a string for use inside an InfluxQL single-quoted string literal.
|
|
167
|
+
* @param {string} s
|
|
168
|
+
*/
|
|
169
|
+
function escapeQL(s) {
|
|
170
|
+
return String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
module.exports = { init, getClient, getOpts, getMode, buildLine, v1Write, v1Query, escapeQL };
|
|
@@ -5,30 +5,54 @@ 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
|
|
12
|
+
* InfluxDB is not configured (no influx block in config).
|
|
10
13
|
*
|
|
11
14
|
* she.influx API:
|
|
12
|
-
* she.influx.query(
|
|
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
|
+
|
|
17
38
|
module.exports = function (she) {
|
|
18
39
|
she.influx = {
|
|
19
40
|
/**
|
|
20
|
-
* Execute a Flux query
|
|
21
|
-
*
|
|
41
|
+
* Execute a query against InfluxDB — a Flux query in v2 mode, an
|
|
42
|
+
* InfluxQL query in v1 mode.
|
|
43
|
+
* @param {string} query
|
|
22
44
|
* @returns {Promise<object[]>}
|
|
23
45
|
*/
|
|
24
|
-
query(
|
|
46
|
+
query(query) {
|
|
47
|
+
const mode = influx.getMode();
|
|
48
|
+
if (!mode) return Promise.resolve([]);
|
|
49
|
+
if (mode === 'v1') return influx.v1Query(query);
|
|
25
50
|
const client = influx.getClient();
|
|
26
51
|
const opts = influx.getOpts();
|
|
27
|
-
if (!client) return Promise.resolve([]);
|
|
28
52
|
const queryApi = client.getQueryApi(opts.org);
|
|
29
53
|
return new Promise((resolve, reject) => {
|
|
30
54
|
const rows = [];
|
|
31
|
-
queryApi.queryRows(
|
|
55
|
+
queryApi.queryRows(query, {
|
|
32
56
|
next(row, tableMeta) {
|
|
33
57
|
rows.push(tableMeta.toObject(row));
|
|
34
58
|
},
|
|
@@ -49,9 +73,13 @@ module.exports = function (she) {
|
|
|
49
73
|
* @returns {Promise<void>}
|
|
50
74
|
*/
|
|
51
75
|
write(measurement, fields, tags, timestamp) {
|
|
76
|
+
const mode = influx.getMode();
|
|
77
|
+
if (!mode) return Promise.resolve();
|
|
78
|
+
if (mode === 'v1') {
|
|
79
|
+
return influx.v1Write(influx.buildLine(measurement, fields, tags, timestamp));
|
|
80
|
+
}
|
|
52
81
|
const client = influx.getClient();
|
|
53
82
|
const opts = influx.getOpts();
|
|
54
|
-
if (!client) return Promise.resolve();
|
|
55
83
|
const { Point } = require('@influxdata/influxdb-client');
|
|
56
84
|
const writeApi = client.getWriteApi(opts.org, opts.bucket, 'ns');
|
|
57
85
|
const point = new Point(measurement);
|
|
@@ -74,14 +102,29 @@ module.exports = function (she) {
|
|
|
74
102
|
|
|
75
103
|
/**
|
|
76
104
|
* Return the last N recorded values for an MQTT topic.
|
|
77
|
-
* Assumes data was stored with a "topic" tag
|
|
105
|
+
* Assumes data was stored with a "topic" tag; the value comes from the
|
|
106
|
+
* "_value" field (v2) or the "value" field (v1, falling back to the
|
|
107
|
+
* first data column). Results are sorted oldest-first.
|
|
78
108
|
* @param {string} topic
|
|
79
109
|
* @param {number} n
|
|
80
110
|
* @returns {Promise<{ ts: number, val: any }[]>}
|
|
81
111
|
*/
|
|
82
112
|
getLast(topic, n) {
|
|
113
|
+
const mode = influx.getMode();
|
|
114
|
+
if (!mode) return Promise.resolve([]);
|
|
115
|
+
if (mode === 'v1') {
|
|
116
|
+
// LIMIT applies per series in InfluxQL — query across all
|
|
117
|
+
// measurements, then merge, trim to n and sort ascending.
|
|
118
|
+
const ql = `SELECT * FROM /.*/ WHERE "topic" = '${influx.escapeQL(topic)}' ORDER BY time DESC LIMIT ${Number(n)}`;
|
|
119
|
+
return influx.v1Query(ql).then((rows) =>
|
|
120
|
+
rows
|
|
121
|
+
.map((r) => ({ ts: r.time, val: v1RowVal(r) }))
|
|
122
|
+
.sort((a, b) => b.ts - a.ts)
|
|
123
|
+
.slice(0, Number(n))
|
|
124
|
+
.reverse(),
|
|
125
|
+
);
|
|
126
|
+
}
|
|
83
127
|
const opts = influx.getOpts();
|
|
84
|
-
if (!opts) return Promise.resolve([]);
|
|
85
128
|
const safeTopic = topic.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
86
129
|
const flux = `from(bucket: "${opts.bucket}")` + ` |> range(start: -30d)` + ` |> filter(fn: (r) => r["topic"] == "${safeTopic}")` + ` |> tail(n: ${Number(n)})`;
|
|
87
130
|
return this.query(flux).then((rows) => rows.map((r) => ({ ts: new Date(r._time).getTime(), val: r._value })));
|
|
@@ -95,8 +138,15 @@ module.exports = function (she) {
|
|
|
95
138
|
* @returns {Promise<{ ts: number, val: any }[]>}
|
|
96
139
|
*/
|
|
97
140
|
getRange(topic, from, to) {
|
|
141
|
+
const mode = influx.getMode();
|
|
142
|
+
if (!mode) return Promise.resolve([]);
|
|
143
|
+
if (mode === 'v1') {
|
|
144
|
+
const fromMs = new Date(from).getTime();
|
|
145
|
+
const toMs = new Date(to).getTime();
|
|
146
|
+
const ql = `SELECT * FROM /.*/ WHERE "topic" = '${influx.escapeQL(topic)}' AND time >= ${fromMs}ms AND time <= ${toMs}ms`;
|
|
147
|
+
return influx.v1Query(ql).then((rows) => rows.map((r) => ({ ts: r.time, val: v1RowVal(r) })).sort((a, b) => a.ts - b.ts));
|
|
148
|
+
}
|
|
98
149
|
const opts = influx.getOpts();
|
|
99
|
-
if (!opts) return Promise.resolve([]);
|
|
100
150
|
const safeTopic = topic.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
101
151
|
const start = new Date(from).toISOString();
|
|
102
152
|
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',
|
|
27
|
-
* - { type: 'ping' }
|
|
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]
|