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
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Where in a script a registration came from: the first stack frame that lies in the script's own
|
|
4
|
+
* file. The sandbox APIs call this when a script registers a subscription, a schedule or a timer, so
|
|
5
|
+
* the editor's Runtime panel can jump to the line. Frames of the daemon (index.js, the sandbox
|
|
6
|
+
* modules, the timing library) are skipped — a `she.mqtt.timer()` call reports the script line that
|
|
7
|
+
* called it, not the stdlib line that subscribed on its behalf.
|
|
8
|
+
*
|
|
9
|
+
* Cost: one `new Error()` with its stack captured — tens of microseconds. Registrations happen at
|
|
10
|
+
* load and a few times a second at most; the timing helpers capture once when the helper is created,
|
|
11
|
+
* not on every re-arm.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const FRAME_DEPTH = 30;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {string} file the script's file name as given to `new vm.Script`
|
|
18
|
+
* @returns {{line: number, column: number}|null}
|
|
19
|
+
*/
|
|
20
|
+
function callerLine(file) {
|
|
21
|
+
if (!file) return null;
|
|
22
|
+
const limit = Error.stackTraceLimit;
|
|
23
|
+
Error.stackTraceLimit = FRAME_DEPTH;
|
|
24
|
+
const { stack } = new Error();
|
|
25
|
+
Error.stackTraceLimit = limit;
|
|
26
|
+
return parseStack(stack, file);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {string} stack an Error's stack
|
|
31
|
+
* @param {string} file
|
|
32
|
+
* @returns {{line: number, column: number}|null}
|
|
33
|
+
*/
|
|
34
|
+
function parseStack(stack, file) {
|
|
35
|
+
if (typeof stack !== 'string') return null;
|
|
36
|
+
const needle = file + ':';
|
|
37
|
+
for (const frame of stack.split('\n')) {
|
|
38
|
+
const i = frame.indexOf(needle);
|
|
39
|
+
if (i === -1) continue;
|
|
40
|
+
// the file name must start a location: "(file:l:c)" or "at file:l:c" — not be the tail of a
|
|
41
|
+
// longer path (".../otherdir/file")
|
|
42
|
+
const before = frame[i - 1];
|
|
43
|
+
if (before !== '(' && before !== ' ' && before !== undefined) continue;
|
|
44
|
+
const m = /^(\d+):(\d+)\)?\s*$/.exec(frame.slice(i + needle.length));
|
|
45
|
+
if (!m) continue;
|
|
46
|
+
return { line: Number(m[1]), column: Number(m[2]) };
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { callerLine, parseStack };
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Topic conventions behind `she.mqtt.command`: where a command's effect shows up, how to tell a
|
|
4
|
+
* device is offline, and which topics a script should not publish to because an adapter owns them.
|
|
5
|
+
*
|
|
6
|
+
* Two conventions are known:
|
|
7
|
+
*
|
|
8
|
+
* - mqtt-smarthome: `<inst>/set/<path>` is answered on `<inst>/status/<path>` with `{ val, … }`;
|
|
9
|
+
* `<inst>/connected` is 0 (gone), 1 (mqtt only) or 2 (device reachable).
|
|
10
|
+
* - zigbee2mqtt: `<base>/<device>/set` takes `{ state: "ON" }` and the device's state is published as
|
|
11
|
+
* one JSON object on `<base>/<device>`; `<base>/<device>/availability` is `online`/`offline` (or
|
|
12
|
+
* `{ state: "offline" }`); `<base>/<device>/set/<attribute>` sets one attribute.
|
|
13
|
+
*
|
|
14
|
+
* Everything else needs `opts.confirm` — the functions here say so by returning null.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const SMARTHOME_SET = /^(.+?)\/set\/(.+)$/;
|
|
18
|
+
const Z2M_SET = /^(.+)\/set$/;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The topic (and JSON field) where the effect of a command shows up, and the value to expect.
|
|
22
|
+
* @param {string} topic the command topic (the `a//b` shorthand already expanded)
|
|
23
|
+
* @param {*} payload what is sent
|
|
24
|
+
* @param {object} [opts] `confirm` (topic), `path` (JSON field of the status payload)
|
|
25
|
+
* @param {(topic: string) => boolean} [has] whether the state store knows a topic — breaks the tie
|
|
26
|
+
* between `<inst>/set/<dev>/<dp>` and zigbee2mqtt's `<base>/<dev>/set/<attr>`
|
|
27
|
+
* @returns {{ topic: string, field: string|null, expect: * }|null} null when nothing can be derived
|
|
28
|
+
*/
|
|
29
|
+
function deriveConfirm(topic, payload, opts = {}, has = () => false) {
|
|
30
|
+
const isObj = payload !== null && typeof payload === 'object' && !Array.isArray(payload);
|
|
31
|
+
const keys = isObj ? Object.keys(payload) : [];
|
|
32
|
+
const field = typeof opts.path === 'string' && opts.path ? opts.path : null;
|
|
33
|
+
const expectFor = (f) => {
|
|
34
|
+
if (isObj) {
|
|
35
|
+
if (f && f in payload) return payload[f];
|
|
36
|
+
if ('val' in payload) return payload.val;
|
|
37
|
+
if (keys.length === 1) return payload[keys[0]];
|
|
38
|
+
return payload;
|
|
39
|
+
}
|
|
40
|
+
return payload;
|
|
41
|
+
};
|
|
42
|
+
if (typeof opts.confirm === 'string' && opts.confirm) {
|
|
43
|
+
const f = field || (isObj && !('val' in payload) && keys.length === 1 ? keys[0] : null);
|
|
44
|
+
return { topic: opts.confirm, field: f, expect: expectFor(f) };
|
|
45
|
+
}
|
|
46
|
+
let m = Z2M_SET.exec(topic);
|
|
47
|
+
if (m) {
|
|
48
|
+
// zigbee2mqtt: the device's state object; the field is the payload's single key, or `state`
|
|
49
|
+
// for a bare "ON"/"OFF"
|
|
50
|
+
const f = field || (isObj && keys.length === 1 ? keys[0] : isObj ? null : 'state');
|
|
51
|
+
return { topic: m[1], field: f, expect: expectFor(f) };
|
|
52
|
+
}
|
|
53
|
+
m = SMARTHOME_SET.exec(topic);
|
|
54
|
+
if (m) {
|
|
55
|
+
const status = `${m[1]}/status/${m[2]}`;
|
|
56
|
+
// `<base>/<dev>/set/<attr>` of zigbee2mqtt: no status topic, but the device object exists
|
|
57
|
+
if (!has(status) && has(m[1])) {
|
|
58
|
+
const attr = m[2];
|
|
59
|
+
return { topic: m[1], field: field || attr, expect: expectFor(field || attr) };
|
|
60
|
+
}
|
|
61
|
+
return { topic: status, field, expect: expectFor(field) };
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Whether the device behind a command topic is known to be offline.
|
|
68
|
+
* @param {string} topic the command topic
|
|
69
|
+
* @param {(topic: string) => *} get the state store's value of a topic (undefined when unknown)
|
|
70
|
+
* @returns {string|null} what says so (`hm/connected = 1`), or null
|
|
71
|
+
*/
|
|
72
|
+
function offlineReason(topic, get) {
|
|
73
|
+
const z = Z2M_SET.exec(topic);
|
|
74
|
+
if (z) {
|
|
75
|
+
const avail = get(`${z[1]}/availability`);
|
|
76
|
+
const state = avail !== null && typeof avail === 'object' ? avail.state : avail;
|
|
77
|
+
if (typeof state === 'string' && state.toLowerCase() === 'offline') return `${z[1]}/availability = offline`;
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
const inst = topic.split('/')[0];
|
|
81
|
+
if (!inst) return null;
|
|
82
|
+
const c = get(`${inst}/connected`);
|
|
83
|
+
if (c === undefined || c === null) return null;
|
|
84
|
+
const n = typeof c === 'object' ? Number(c.val) : Number(c);
|
|
85
|
+
if (Number.isFinite(n) && n < 2) return `${inst}/connected = ${n}`;
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** on/true → "1", off/false → "0", else the trimmed lower-case string */
|
|
90
|
+
function norm(v) {
|
|
91
|
+
if (v === true) return '1';
|
|
92
|
+
if (v === false) return '0';
|
|
93
|
+
const s = String(v).trim().toLowerCase();
|
|
94
|
+
if (s === 'on' || s === 'true') return '1';
|
|
95
|
+
if (s === 'off' || s === 'false') return '0';
|
|
96
|
+
return s;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Does a status value confirm the command?
|
|
101
|
+
* @param {*} actual the value seen on the confirm topic (the field already picked)
|
|
102
|
+
* @param {*} expect a value, or fn(actual) → boolean
|
|
103
|
+
* @param {number} [tolerance=0] for numbers
|
|
104
|
+
*/
|
|
105
|
+
function confirms(actual, expect, tolerance = 0) {
|
|
106
|
+
if (typeof expect === 'function') return !!expect(actual);
|
|
107
|
+
if (actual === undefined) return false;
|
|
108
|
+
if (expect !== null && typeof expect === 'object') {
|
|
109
|
+
// an object payload (several fields): every field must match
|
|
110
|
+
if (actual === null || typeof actual !== 'object') return false;
|
|
111
|
+
return Object.keys(expect).every((k) => confirms(actual[k], expect[k], tolerance));
|
|
112
|
+
}
|
|
113
|
+
const a = typeof actual === 'string' ? actual.trim() : actual;
|
|
114
|
+
const na = Number(a);
|
|
115
|
+
const ne = Number(expect);
|
|
116
|
+
if (a !== '' && expect !== '' && typeof a !== 'boolean' && typeof expect !== 'boolean' && Number.isFinite(na) && Number.isFinite(ne)) {
|
|
117
|
+
return Math.abs(na - ne) <= tolerance;
|
|
118
|
+
}
|
|
119
|
+
return norm(actual) === norm(expect);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Why a script should not publish to a topic: an adapter writes it as status.
|
|
124
|
+
* @param {string} topic
|
|
125
|
+
* @param {(topic: string) => boolean} has whether the state store knows a topic
|
|
126
|
+
* @returns {string|null}
|
|
127
|
+
*/
|
|
128
|
+
function statusTopicWarning(topic, has = () => false) {
|
|
129
|
+
const parts = topic.split('/');
|
|
130
|
+
if (parts.length >= 3 && parts[1] === 'status') {
|
|
131
|
+
return `"${topic}" is a status topic — adapters publish there; a command goes to "${parts[0]}/set/${parts.slice(2).join('/')}"`;
|
|
132
|
+
}
|
|
133
|
+
if (parts.length >= 2 && parts[parts.length - 1] !== 'set' && parts[parts.length - 1] !== 'get' && !topic.includes('/set/')) {
|
|
134
|
+
const base = parts[0];
|
|
135
|
+
if (has(`${base}/bridge/info`) || has(`${base}/bridge/state`)) {
|
|
136
|
+
if (parts[1] === 'bridge') return null;
|
|
137
|
+
return `"${topic}" is published by zigbee2mqtt as the device's state; a command goes to "${topic}/set"`;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const PUBLISH_CALL = /\bshe\.(mqtt\.pub|mqttpub|setValue|mqtt\.command|mqttcommand)\(\s*(['"`])([^'"`$\\\n]+)\2/g;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The literal topics a script publishes to — for the lint at load time.
|
|
147
|
+
* @param {string} source
|
|
148
|
+
* @returns {{ line: number, api: string, topic: string }[]}
|
|
149
|
+
*/
|
|
150
|
+
function scanPublishTopics(source) {
|
|
151
|
+
const out = [];
|
|
152
|
+
const text = String(source || '');
|
|
153
|
+
let m;
|
|
154
|
+
PUBLISH_CALL.lastIndex = 0;
|
|
155
|
+
while ((m = PUBLISH_CALL.exec(text)) !== null) {
|
|
156
|
+
const line = text.slice(0, m.index).split('\n').length;
|
|
157
|
+
out.push({ line, api: 'she.' + m[1], topic: m[3].replace(/^([^/]+)\/\/(.+)$/, '$1/set/$2') });
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
module.exports = { deriveConfirm, offlineReason, confirms, statusTopicWarning, scanPublishTopics };
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* `she.mqtt.command(topic, payload, [opts])`: publish a command and wait for the device's status to
|
|
4
|
+
* confirm it — with a timeout per attempt, retries, a fast failure when the device is known to be
|
|
5
|
+
* offline, and a warning in the log when the command did not take.
|
|
6
|
+
*
|
|
7
|
+
* The commander is created per script with the script's primitives (publish, the state store, the
|
|
8
|
+
* tracked timers, the unload hook), so a pending command is a timer of the script: it shows in the
|
|
9
|
+
* timer list, is cancelled when the script is unloaded, and the editor can cancel it.
|
|
10
|
+
*
|
|
11
|
+
* `she.mqtt.pub` stays fire-and-forget; this is for the commands whose effect matters.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { deriveConfirm, offlineReason, confirms } = require('./command-topics');
|
|
15
|
+
|
|
16
|
+
const DEFAULT_TIMEOUT_S = 5;
|
|
17
|
+
|
|
18
|
+
class CommandError extends Error {
|
|
19
|
+
/**
|
|
20
|
+
* @param {'timeout'|'offline'|'superseded'|'unloaded'|'cancelled'} reason
|
|
21
|
+
* @param {object} info topic, payload (as sent), tries, detail
|
|
22
|
+
*/
|
|
23
|
+
constructor(reason, info) {
|
|
24
|
+
const why = {
|
|
25
|
+
timeout: `no confirmation within ${info.timeoutS} s${info.tries > 1 ? ` (${info.tries} attempts)` : ''}`,
|
|
26
|
+
offline: `device offline (${info.detail})`,
|
|
27
|
+
superseded: 'superseded by a newer command to the same topic',
|
|
28
|
+
unloaded: 'script unloaded',
|
|
29
|
+
cancelled: 'cancelled from the editor',
|
|
30
|
+
}[reason];
|
|
31
|
+
super(`she.mqtt.command ${info.topic} ← ${info.payload}: ${why}`);
|
|
32
|
+
this.name = 'CommandError';
|
|
33
|
+
this.reason = reason;
|
|
34
|
+
this.topic = info.topic;
|
|
35
|
+
this.payload = info.payload;
|
|
36
|
+
this.tries = info.tries;
|
|
37
|
+
if (info.confirm) this.confirm = info.confirm;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {object} prim the script's primitives
|
|
43
|
+
* @param {(topic: string, payload: string) => void} prim.publish
|
|
44
|
+
* @param {(topic: string) => *} prim.get the state store's value of a topic
|
|
45
|
+
* @param {(topic: string) => boolean} prim.has
|
|
46
|
+
* @param {(topic: string, cb: (val: *) => void) => () => void} prim.watch called with every new value
|
|
47
|
+
* stored for the topic, until the returned function is called
|
|
48
|
+
* @param {(fn: Function, ms: number, meta: object) => *} prim.setTimeout the script's tracked timer;
|
|
49
|
+
* meta is { kind: 'command', name, extra, onCancel, actions }
|
|
50
|
+
* @param {(id: *) => void} prim.clearTimeout
|
|
51
|
+
* @param {(fn: Function) => () => void} [prim.onUnload]
|
|
52
|
+
* @param {(msg: string) => void} [prim.warn] the script's warning log
|
|
53
|
+
* @param {() => void} [prim.onFail] counts a failed command (timeout, offline)
|
|
54
|
+
* @param {() => number} [prim.now]
|
|
55
|
+
* @param {() => number|null} [prim.line] the script line of the call
|
|
56
|
+
* @returns {Function} command(topic, payload, [opts]) → Promise<{ val, ms, tries, already? }>
|
|
57
|
+
*/
|
|
58
|
+
function createCommander(prim) {
|
|
59
|
+
const now = prim.now || Date.now;
|
|
60
|
+
const onUnload = typeof prim.onUnload === 'function' ? prim.onUnload : () => () => {};
|
|
61
|
+
const warn = typeof prim.warn === 'function' ? prim.warn : () => {};
|
|
62
|
+
const onFail = typeof prim.onFail === 'function' ? prim.onFail : () => {};
|
|
63
|
+
const lineOf = typeof prim.line === 'function' ? prim.line : () => null;
|
|
64
|
+
/** topic → the running command's abort(reason) */
|
|
65
|
+
const active = new Map();
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {string} topic the command topic (`a//b` is `a/set/b`)
|
|
69
|
+
* @param {*} payload a string, number, boolean or object (JSON)
|
|
70
|
+
* @param {object} [opts]
|
|
71
|
+
* @param {string} [opts.confirm] the status topic that shows the effect (derived when omitted)
|
|
72
|
+
* @param {string} [opts.path] the field of a JSON status payload to look at
|
|
73
|
+
* @param {*} [opts.expect] the value to expect (default: the payload), or fn(val) → boolean
|
|
74
|
+
* @param {number} [opts.tolerance=0] for numbers
|
|
75
|
+
* @param {number} [opts.timeout=5] seconds per attempt
|
|
76
|
+
* @param {number} [opts.retries=0] further attempts after a timeout
|
|
77
|
+
* @param {boolean} [opts.quiet=false] no warning in the log when the command fails
|
|
78
|
+
* @returns {Promise<{ val: *, ms: number, tries: number, already?: boolean }>}
|
|
79
|
+
*/
|
|
80
|
+
function command(topic, payload, opts = {}) {
|
|
81
|
+
const api = 'she.mqtt.command(topic, payload, [opts])';
|
|
82
|
+
if (typeof topic !== 'string' || !topic) throw new TypeError(`${api}: topic must be a string (got ${typeof topic})`);
|
|
83
|
+
if (payload === undefined) throw new TypeError(`${api}: payload missing`);
|
|
84
|
+
if (opts === null || typeof opts !== 'object') throw new TypeError(`${api}: opts must be an object`);
|
|
85
|
+
topic = topic.replace(/^([^/]+)\/\/(.+)$/, '$1/set/$2');
|
|
86
|
+
const payloadStr = payload !== null && typeof payload === 'object' ? JSON.stringify(payload) : String(payload);
|
|
87
|
+
const confirm = deriveConfirm(topic, payload, opts, prim.has);
|
|
88
|
+
if (!confirm) throw new TypeError(`${api}: cannot derive the status topic of "${topic}" — pass opts.confirm`);
|
|
89
|
+
const expect = 'expect' in opts ? opts.expect : confirm.expect;
|
|
90
|
+
const tolerance = typeof opts.tolerance === 'number' && opts.tolerance >= 0 ? opts.tolerance : 0;
|
|
91
|
+
const timeoutS = typeof opts.timeout === 'number' && Number.isFinite(opts.timeout) && opts.timeout > 0 ? opts.timeout : DEFAULT_TIMEOUT_S;
|
|
92
|
+
const retries = Number.isInteger(opts.retries) && opts.retries >= 0 ? opts.retries : 0;
|
|
93
|
+
if (opts.timeout !== undefined && timeoutS !== opts.timeout) throw new TypeError(`${api}: opts.timeout must be a number of seconds > 0`);
|
|
94
|
+
if (opts.retries !== undefined && retries !== opts.retries) throw new TypeError(`${api}: opts.retries must be a whole number ≥ 0`);
|
|
95
|
+
const line = lineOf();
|
|
96
|
+
const pick = (val) => (confirm.field ? (val !== null && typeof val === 'object' ? val[confirm.field] : undefined) : val);
|
|
97
|
+
|
|
98
|
+
const promise = new Promise((resolve, reject) => {
|
|
99
|
+
const t0 = now();
|
|
100
|
+
let tries = 0;
|
|
101
|
+
let timer = null;
|
|
102
|
+
let offWatch = null;
|
|
103
|
+
let offUnload = null;
|
|
104
|
+
let settled = false;
|
|
105
|
+
|
|
106
|
+
const cleanup = () => {
|
|
107
|
+
settled = true;
|
|
108
|
+
if (timer !== null) prim.clearTimeout(timer);
|
|
109
|
+
timer = null;
|
|
110
|
+
if (offWatch) offWatch();
|
|
111
|
+
if (offUnload) offUnload();
|
|
112
|
+
if (active.get(topic) === abort) active.delete(topic);
|
|
113
|
+
};
|
|
114
|
+
const fail = (reason, detail) => {
|
|
115
|
+
if (settled) return;
|
|
116
|
+
cleanup();
|
|
117
|
+
const err = new CommandError(reason, { topic, payload: payloadStr, tries: Math.max(tries, 1), timeoutS, detail, confirm: confirm.topic });
|
|
118
|
+
if (reason === 'timeout' || reason === 'offline') {
|
|
119
|
+
onFail();
|
|
120
|
+
if (!opts.quiet) warn(err.message + (reason === 'timeout' ? ` — nothing matching on ${confirm.topic}${confirm.field ? ' .' + confirm.field : ''}` : ''));
|
|
121
|
+
}
|
|
122
|
+
reject(err);
|
|
123
|
+
};
|
|
124
|
+
const abort = (reason) => fail(reason);
|
|
125
|
+
|
|
126
|
+
// a newer command to the same topic replaces the running one
|
|
127
|
+
const previous = active.get(topic);
|
|
128
|
+
if (previous) previous('superseded');
|
|
129
|
+
active.set(topic, abort);
|
|
130
|
+
|
|
131
|
+
const offline = offlineReason(topic, prim.get);
|
|
132
|
+
if (offline) {
|
|
133
|
+
fail('offline', offline);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const current = pick(prim.get(confirm.topic));
|
|
138
|
+
if (confirms(current, expect, tolerance)) {
|
|
139
|
+
// the status already shows the target: send anyway (the device may be out of sync
|
|
140
|
+
// with its last report) and do not wait for an echo many devices will not send
|
|
141
|
+
prim.publish(topic, payloadStr);
|
|
142
|
+
cleanup();
|
|
143
|
+
resolve({ val: current, ms: 0, tries: 1, already: true });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
offUnload = onUnload(() => fail('unloaded'));
|
|
148
|
+
offWatch = prim.watch(confirm.topic, (val) => {
|
|
149
|
+
if (settled) return;
|
|
150
|
+
const v = pick(val);
|
|
151
|
+
if (!confirms(v, expect, tolerance)) return;
|
|
152
|
+
cleanup();
|
|
153
|
+
resolve({ val: v, ms: now() - t0, tries });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const attempt = () => {
|
|
157
|
+
tries++;
|
|
158
|
+
prim.publish(topic, payloadStr);
|
|
159
|
+
timer = prim.setTimeout(
|
|
160
|
+
() => {
|
|
161
|
+
timer = null;
|
|
162
|
+
if (settled) return;
|
|
163
|
+
if (tries <= retries) attempt();
|
|
164
|
+
else fail('timeout');
|
|
165
|
+
},
|
|
166
|
+
timeoutS * 1000,
|
|
167
|
+
{
|
|
168
|
+
kind: 'command',
|
|
169
|
+
name: topic,
|
|
170
|
+
line,
|
|
171
|
+
extra: { topic, payload: payloadStr, tries, confirm: confirm.topic },
|
|
172
|
+
actions: { run: false },
|
|
173
|
+
onCancel: () => fail('cancelled'),
|
|
174
|
+
},
|
|
175
|
+
);
|
|
176
|
+
};
|
|
177
|
+
attempt();
|
|
178
|
+
});
|
|
179
|
+
// a failure is reported in the log (unless quiet); a script that does not catch it must not
|
|
180
|
+
// produce an unhandled rejection on top
|
|
181
|
+
promise.catch(() => {});
|
|
182
|
+
return promise;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return command;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
module.exports = { createCommander, CommandError, DEFAULT_TIMEOUT_S };
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Public holidays as a small rule table — fixed dates and days counted from Easter Sunday —
|
|
4
|
+
* for `she.time.isHoliday()`. Regions are an ISO 3166 country code, optionally with a
|
|
5
|
+
* subdivision (`DE`, `DE-BW`, `AT`); a subdivision inherits its country's rules.
|
|
6
|
+
*
|
|
7
|
+
* Deliberately small: no library, no calendar data files. What is not a public holiday
|
|
8
|
+
* everywhere in a region is left out (Mariä Himmelfahrt in Bavaria's catholic communities,
|
|
9
|
+
* the Augsburger Friedensfest) — a local day goes into the `holidaysExtra` config list.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const DAY = 86400000;
|
|
13
|
+
|
|
14
|
+
/** Easter Sunday of a Gregorian year as UTC ms (Meeus/Jones/Butcher). */
|
|
15
|
+
function easterSunday(year) {
|
|
16
|
+
const a = year % 19;
|
|
17
|
+
const b = Math.floor(year / 100);
|
|
18
|
+
const c = year % 100;
|
|
19
|
+
const d = Math.floor(b / 4);
|
|
20
|
+
const e = b % 4;
|
|
21
|
+
const f = Math.floor((b + 8) / 25);
|
|
22
|
+
const g = Math.floor((b - f + 1) / 3);
|
|
23
|
+
const h = (19 * a + b - d - g + 15) % 30;
|
|
24
|
+
const i = Math.floor(c / 4);
|
|
25
|
+
const k = c % 4;
|
|
26
|
+
const l = (32 + 2 * e + 2 * i - h - k) % 7;
|
|
27
|
+
const m = Math.floor((a + 11 * h + 22 * l) / 451);
|
|
28
|
+
const month = Math.floor((h + l - 7 * m + 114) / 31);
|
|
29
|
+
const day = ((h + l - 7 * m + 114) % 31) + 1;
|
|
30
|
+
return Date.UTC(year, month - 1, day);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Buß- und Bettag: the Wednesday before November 23. */
|
|
34
|
+
function penanceDay(year) {
|
|
35
|
+
let t = Date.UTC(year, 10, 22);
|
|
36
|
+
while (new Date(t).getUTCDay() !== 3) t -= DAY;
|
|
37
|
+
return t;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const fixed = (date, name) => ({ fixed: date, name });
|
|
41
|
+
const easter = (offset, name) => ({ easter: offset, name });
|
|
42
|
+
|
|
43
|
+
const DE_NATIONAL = [
|
|
44
|
+
fixed('01-01', 'Neujahr'),
|
|
45
|
+
easter(-2, 'Karfreitag'),
|
|
46
|
+
easter(1, 'Ostermontag'),
|
|
47
|
+
fixed('05-01', 'Tag der Arbeit'),
|
|
48
|
+
easter(39, 'Christi Himmelfahrt'),
|
|
49
|
+
easter(50, 'Pfingstmontag'),
|
|
50
|
+
fixed('10-03', 'Tag der Deutschen Einheit'),
|
|
51
|
+
fixed('12-25', '1. Weihnachtstag'),
|
|
52
|
+
fixed('12-26', '2. Weihnachtstag'),
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
const EPIPHANY = fixed('01-06', 'Heilige Drei Könige');
|
|
56
|
+
const WOMENS_DAY = fixed('03-08', 'Internationaler Frauentag');
|
|
57
|
+
const CORPUS_CHRISTI = easter(60, 'Fronleichnam');
|
|
58
|
+
const ASSUMPTION = fixed('08-15', 'Mariä Himmelfahrt');
|
|
59
|
+
const REFORMATION = fixed('10-31', 'Reformationstag');
|
|
60
|
+
const ALL_SAINTS = fixed('11-01', 'Allerheiligen');
|
|
61
|
+
const CHILDRENS_DAY = fixed('09-20', 'Weltkindertag');
|
|
62
|
+
const PENANCE = { compute: penanceDay, name: 'Buß- und Bettag' };
|
|
63
|
+
|
|
64
|
+
const REGIONS = {
|
|
65
|
+
'DE': { name: 'Deutschland (bundesweite Feiertage)', rules: DE_NATIONAL },
|
|
66
|
+
'DE-BW': { name: 'Baden-Württemberg', parent: 'DE', rules: [EPIPHANY, CORPUS_CHRISTI, ALL_SAINTS] },
|
|
67
|
+
'DE-BY': { name: 'Bayern', parent: 'DE', rules: [EPIPHANY, CORPUS_CHRISTI, ALL_SAINTS] },
|
|
68
|
+
'DE-BE': { name: 'Berlin', parent: 'DE', rules: [WOMENS_DAY] },
|
|
69
|
+
'DE-BB': { name: 'Brandenburg', parent: 'DE', rules: [easter(0, 'Ostersonntag'), easter(49, 'Pfingstsonntag'), REFORMATION] },
|
|
70
|
+
'DE-HB': { name: 'Bremen', parent: 'DE', rules: [REFORMATION] },
|
|
71
|
+
'DE-HH': { name: 'Hamburg', parent: 'DE', rules: [REFORMATION] },
|
|
72
|
+
'DE-HE': { name: 'Hessen', parent: 'DE', rules: [CORPUS_CHRISTI] },
|
|
73
|
+
'DE-MV': { name: 'Mecklenburg-Vorpommern', parent: 'DE', rules: [WOMENS_DAY, REFORMATION] },
|
|
74
|
+
'DE-NI': { name: 'Niedersachsen', parent: 'DE', rules: [REFORMATION] },
|
|
75
|
+
'DE-NW': { name: 'Nordrhein-Westfalen', parent: 'DE', rules: [CORPUS_CHRISTI, ALL_SAINTS] },
|
|
76
|
+
'DE-RP': { name: 'Rheinland-Pfalz', parent: 'DE', rules: [CORPUS_CHRISTI, ALL_SAINTS] },
|
|
77
|
+
'DE-SL': { name: 'Saarland', parent: 'DE', rules: [CORPUS_CHRISTI, ASSUMPTION, ALL_SAINTS] },
|
|
78
|
+
'DE-SN': { name: 'Sachsen', parent: 'DE', rules: [REFORMATION, PENANCE] },
|
|
79
|
+
'DE-ST': { name: 'Sachsen-Anhalt', parent: 'DE', rules: [EPIPHANY, REFORMATION] },
|
|
80
|
+
'DE-SH': { name: 'Schleswig-Holstein', parent: 'DE', rules: [REFORMATION] },
|
|
81
|
+
'DE-TH': { name: 'Thüringen', parent: 'DE', rules: [CHILDRENS_DAY, REFORMATION] },
|
|
82
|
+
'AT': {
|
|
83
|
+
name: 'Österreich',
|
|
84
|
+
rules: [
|
|
85
|
+
fixed('01-01', 'Neujahr'),
|
|
86
|
+
EPIPHANY,
|
|
87
|
+
easter(1, 'Ostermontag'),
|
|
88
|
+
fixed('05-01', 'Staatsfeiertag'),
|
|
89
|
+
easter(39, 'Christi Himmelfahrt'),
|
|
90
|
+
easter(50, 'Pfingstmontag'),
|
|
91
|
+
CORPUS_CHRISTI,
|
|
92
|
+
ASSUMPTION,
|
|
93
|
+
fixed('10-26', 'Nationalfeiertag'),
|
|
94
|
+
ALL_SAINTS,
|
|
95
|
+
fixed('12-08', 'Mariä Empfängnis'),
|
|
96
|
+
fixed('12-25', 'Christtag'),
|
|
97
|
+
fixed('12-26', 'Stefanitag'),
|
|
98
|
+
],
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
function normalizeRegion(code) {
|
|
103
|
+
return typeof code === 'string' ? code.trim().toUpperCase() : '';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isRegion(code) {
|
|
107
|
+
return Object.prototype.hasOwnProperty.call(REGIONS, normalizeRegion(code));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** @returns {Array<{code: string, name: string}>} */
|
|
111
|
+
function listRegions() {
|
|
112
|
+
return Object.keys(REGIONS).map((code) => ({ code, name: REGIONS[code].name }));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const pad2 = (n) => String(n).padStart(2, '0');
|
|
116
|
+
const mmdd = (utcMs) => {
|
|
117
|
+
const d = new Date(utcMs);
|
|
118
|
+
return `${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
function rulesOf(code) {
|
|
122
|
+
const region = REGIONS[normalizeRegion(code)];
|
|
123
|
+
if (!region) return [];
|
|
124
|
+
return region.parent ? [...rulesOf(region.parent), ...region.rules] : region.rules;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const _cache = new Map();
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The holidays of a region in one year.
|
|
131
|
+
* @param {string} code region, e.g. 'DE-BW'
|
|
132
|
+
* @param {number} year
|
|
133
|
+
* @returns {Map<string, string>} 'MM-DD' → name, in calendar order; empty for an unknown region
|
|
134
|
+
*/
|
|
135
|
+
function holidaysOfYear(code, year) {
|
|
136
|
+
const key = `${normalizeRegion(code)}|${year}`;
|
|
137
|
+
const hit = _cache.get(key);
|
|
138
|
+
if (hit) return hit;
|
|
139
|
+
const es = easterSunday(year);
|
|
140
|
+
const days = rulesOf(code).map((rule) => {
|
|
141
|
+
if (rule.fixed) return [rule.fixed, rule.name];
|
|
142
|
+
if (typeof rule.easter === 'number') return [mmdd(es + rule.easter * DAY), rule.name];
|
|
143
|
+
return [mmdd(rule.compute(year)), rule.name];
|
|
144
|
+
});
|
|
145
|
+
days.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
146
|
+
const map = new Map(days);
|
|
147
|
+
if (_cache.size > 200) _cache.clear();
|
|
148
|
+
_cache.set(key, map);
|
|
149
|
+
return map;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Parse the `holidaysExtra` config list: strings `MM-DD` or `YYYY-MM-DD`, optionally followed
|
|
154
|
+
* by a name (`"05-08 Tag der Befreiung"`), or objects `{ date, name }`.
|
|
155
|
+
* @param {unknown} list
|
|
156
|
+
* @returns {{ entries: Array<{date: string, name: string}>, errors: string[] }} date is 'MM-DD' or 'YYYY-MM-DD'
|
|
157
|
+
*/
|
|
158
|
+
function parseExtra(list) {
|
|
159
|
+
const entries = [];
|
|
160
|
+
const errors = [];
|
|
161
|
+
if (list === undefined || list === null || list === '') return { entries, errors };
|
|
162
|
+
if (!Array.isArray(list)) return { entries, errors: ['holidaysExtra must be a list'] };
|
|
163
|
+
for (const item of list) {
|
|
164
|
+
let date, name;
|
|
165
|
+
if (typeof item === 'string') {
|
|
166
|
+
const m = /^\s*(\d{4}-\d{2}-\d{2}|\d{2}-\d{2})(?:\s+(.*?))?\s*$/.exec(item);
|
|
167
|
+
if (m) [, date, name] = m;
|
|
168
|
+
} else if (item && typeof item === 'object' && typeof item.date === 'string') {
|
|
169
|
+
if (/^(\d{4}-\d{2}-\d{2}|\d{2}-\d{2})$/.test(item.date)) {
|
|
170
|
+
date = item.date;
|
|
171
|
+
name = typeof item.name === 'string' ? item.name : undefined;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (!date) {
|
|
175
|
+
errors.push(`holidaysExtra: "${typeof item === 'string' ? item : JSON.stringify(item)}" is not "MM-DD" or "YYYY-MM-DD" (optionally followed by a name)`);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const [mo, d] = date.slice(-5).split('-').map(Number);
|
|
179
|
+
if (mo < 1 || mo > 12 || d < 1 || d > 31) {
|
|
180
|
+
errors.push(`holidaysExtra: "${date}" is not a calendar date`);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
entries.push({ date, name: name || date });
|
|
184
|
+
}
|
|
185
|
+
return { entries, errors };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
module.exports = { REGIONS, easterSunday, isRegion, listRegions, holidaysOfYear, normalizeRegion, parseExtra };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* `she.mqtt.pub(topic, payload, { changed: true })`: publish only when the payload
|
|
4
|
+
* differs from what this script last sent to the topic. Before the first publish of a run, the
|
|
5
|
+
* comparison falls back to the topic's current value in the state store — for a command topic
|
|
6
|
+
* (`<prefix>/set/<name>`) to its `status` sibling, which is where a variable's or an adapter's
|
|
7
|
+
* state lives. The last payloads are kept per script, so a hot reload starts over with the store.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const parsePayload = require('./parse-payload');
|
|
11
|
+
|
|
12
|
+
const CAP = 200; // topics remembered per script, like the publish record
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {Map<string,string>} last topic → the payload this script last published
|
|
16
|
+
* @param {string} topic
|
|
17
|
+
* @param {string} payload the stringified payload
|
|
18
|
+
* @param {Function} lookup (topic) → the state object { val, ts, lc } or undefined
|
|
19
|
+
* @returns {boolean} true when the publish would repeat the last one
|
|
20
|
+
*/
|
|
21
|
+
function isRepeat(last, topic, payload, lookup) {
|
|
22
|
+
if (last.has(topic)) return last.get(topic) === payload;
|
|
23
|
+
let state = lookup(topic);
|
|
24
|
+
if (state === undefined) {
|
|
25
|
+
const parts = topic.split('/');
|
|
26
|
+
if (parts.length > 2 && parts[1] === 'set') {
|
|
27
|
+
parts[1] = 'status';
|
|
28
|
+
state = lookup(parts.join('/'));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (state === undefined || state === null) return false;
|
|
32
|
+
return JSON.stringify(parsePayload(payload).val) === JSON.stringify(state.val);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Remember what was published; at most CAP topics per script.
|
|
37
|
+
* @param {Map<string,string>} last
|
|
38
|
+
* @param {string} topic
|
|
39
|
+
* @param {string} payload
|
|
40
|
+
*/
|
|
41
|
+
function remember(last, topic, payload) {
|
|
42
|
+
if (last.size < CAP || last.has(topic)) last.set(topic, payload);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = { isRepeat, remember, CAP };
|