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.
@@ -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 };