iobroker.statechart 0.1.1 → 0.3.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/LICENSE +21 -21
- package/README.md +59 -438
- package/admin/build/tab.js +133 -130
- package/admin/statechart.png +0 -0
- package/admin/{style.css → tab.css} +0 -2
- package/admin/tab_m.html +1 -1
- package/build/lib/chart-analysis.js +367 -0
- package/build/lib/chart-analysis.js.map +7 -0
- package/build/lib/chart-codegen.js +405 -0
- package/build/lib/chart-codegen.js.map +7 -0
- package/build/lib/chart-helpers.js +102 -0
- package/build/lib/chart-helpers.js.map +7 -0
- package/build/lib/chart-model.js +477 -0
- package/build/lib/chart-model.js.map +7 -0
- package/build/lib/chart-runtime.js +1521 -0
- package/build/lib/chart-runtime.js.map +7 -0
- package/build/lib/chart-structure.js +172 -0
- package/build/lib/chart-structure.js.map +7 -0
- package/build/lib/chart-validation.js +372 -0
- package/build/lib/chart-validation.js.map +7 -0
- package/build/main.js +638 -0
- package/build/main.js.map +7 -0
- package/io-package.json +31 -34
- package/package.json +21 -17
- package/admin/i18n/de.json +0 -4
- package/admin/i18n/en.json +0 -4
- package/admin/i18n/es.json +0 -4
- package/admin/i18n/fr.json +0 -4
- package/admin/i18n/it.json +0 -4
- package/admin/i18n/nl.json +0 -4
- package/admin/i18n/pl.json +0 -4
- package/admin/i18n/pt.json +0 -4
- package/admin/i18n/ru.json +0 -4
- package/admin/i18n/uk.json +0 -4
- package/admin/i18n/zh-cn.json +0 -4
- package/admin/jsonConfig.json +0 -19
- package/admin/statechart.svg +0 -38
- package/lib/adapter-config.d.ts +0 -19
- package/lib/chart-codegen.js +0 -436
- package/lib/chart-codegen.test.js +0 -132
- package/lib/chart-helpers.js +0 -74
- package/lib/chart-helpers.test.js +0 -38
- package/lib/chart-model.js +0 -577
- package/lib/chart-model.test.js +0 -189
- package/lib/chart-runtime.js +0 -797
- package/lib/chart-runtime.test.js +0 -699
- package/lib/chart-structure.js +0 -106
- package/lib/chart-validation.js +0 -328
- package/lib/chart-validation.test.js +0 -149
- package/main.js +0 -279
package/lib/chart-runtime.js
DELETED
|
@@ -1,797 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
|
|
3
|
-
const crypto = require("node:crypto");
|
|
4
|
-
const vm = require("node:vm");
|
|
5
|
-
const SunCalc = require("suncalc");
|
|
6
|
-
const { createActor, createMachine } = require("xstate");
|
|
7
|
-
const { isTimeBetween } = require("./chart-helpers");
|
|
8
|
-
const { parseTransitionLabel } = require("./chart-model");
|
|
9
|
-
const { ChartStructure } = require("./chart-structure");
|
|
10
|
-
const { getDeclarations, validateChart } = require("./chart-validation");
|
|
11
|
-
|
|
12
|
-
/** Maximum run time of a single condition or action */
|
|
13
|
-
const SCRIPT_TIMEOUT_MS = 1000;
|
|
14
|
-
|
|
15
|
-
/** Longer delays are not supported by setTimeout() */
|
|
16
|
-
const MAX_TIMEOUT_MS = 2 ** 31 - 1;
|
|
17
|
-
|
|
18
|
-
/** Version of the persisted data, increase on incompatible changes */
|
|
19
|
-
const PERSISTED_VERSION = 1;
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* The persisted state of a running chart, restored when the adapter starts again
|
|
23
|
-
*
|
|
24
|
-
* @typedef {object} PersistedChart
|
|
25
|
-
* @property {number} version See PERSISTED_VERSION
|
|
26
|
-
* @property {string} hash Of the chart logic, the data is only restored for an unchanged chart
|
|
27
|
-
* @property {any} machine Persisted XState snapshot (active states)
|
|
28
|
-
* @property {Record<string, number>} entered Time each active state was entered (ms since 1970)
|
|
29
|
-
* @property {Record<string, any>} locals Values of the local variables
|
|
30
|
-
*/
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* A hash over everything which influences the execution, but not e.g. the positions in the editor
|
|
34
|
-
*
|
|
35
|
-
* @param {Chart} chart
|
|
36
|
-
* @returns {string}
|
|
37
|
-
*/
|
|
38
|
-
function getChartHash(chart) {
|
|
39
|
-
const logic = {
|
|
40
|
-
initial: chart.initial,
|
|
41
|
-
symbols: chart.symbols,
|
|
42
|
-
// comments and notes are only documentation
|
|
43
|
-
states: chart.states.map(({ x: _x, y: _y, width: _w, height: _h, comment: _c, ...state }) => state),
|
|
44
|
-
transitions: chart.transitions.map(
|
|
45
|
-
({ sourceHandle: _s, targetHandle: _t, comment: _c, ...transition }) => transition,
|
|
46
|
-
),
|
|
47
|
-
functions: (chart.functions || []).map(fn => fn.code),
|
|
48
|
-
};
|
|
49
|
-
return crypto.createHash("sha1").update(JSON.stringify(logic)).digest("hex");
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* @typedef {import("./chart-model").Chart} Chart
|
|
54
|
-
* @typedef {import("./chart-model").ChartState} ChartState
|
|
55
|
-
* @typedef {import("./chart-model").ChartTransition} ChartTransition
|
|
56
|
-
*/
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* The connection of a chart to the outside world, provided by the adapter
|
|
60
|
-
*
|
|
61
|
-
* @typedef {object} RuntimeHost
|
|
62
|
-
* @property {(stateId: string) => Promise<any>} readState Reads the current value of a state
|
|
63
|
-
* @property {(stateId: string, val: any, ack?: boolean) => Promise<void>} writeState Writes the value of a state
|
|
64
|
-
* @property {(instance: string, command: string, message: any) => void} [sendTo] Sends a message to an adapter
|
|
65
|
-
* @property {() => { latitude: number; longitude: number } | null} [getLocation] Of the ioBroker system settings
|
|
66
|
-
* @property {(active: { names: string; ids: string[] }) => void} onActiveChange Called when the active states change
|
|
67
|
-
* @property {(data: PersistedChart) => void} [persist] Stores the state of the chart, called when it changes
|
|
68
|
-
* @property {() => Promise<PersistedChart | null>} [loadPersisted] Loads the stored state of the chart
|
|
69
|
-
* @property {Pick<ioBroker.Logger, "debug" | "info" | "warn" | "error">} log
|
|
70
|
-
*/
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* A transition which is checked on wakeups
|
|
74
|
-
*
|
|
75
|
-
* @typedef {object} CheckedTransition
|
|
76
|
-
* @property {string} id
|
|
77
|
-
* @property {string} source ID of the source state
|
|
78
|
-
* @property {string} where Used in error messages
|
|
79
|
-
* @property {string} [event] Name of the input which must have caused the wakeup
|
|
80
|
-
* @property {number} [afterMs] Temporal transition: only valid this long after entering the source state
|
|
81
|
-
* @property {import("node:vm").Script | null} condition
|
|
82
|
-
*/
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Executes a single chart with XState.
|
|
86
|
-
*
|
|
87
|
-
* Wakeup semantics like Stateflow: every change of an input wakes up the chart once, as well as every
|
|
88
|
-
* expiring after(...) of an active state. Transitions with event are only checked when that input changed,
|
|
89
|
-
* all others on every wakeup. A temporal transition after(n, unit) is valid from n units after entering its
|
|
90
|
-
* source state on, so its condition is checked again on every following wakeup.
|
|
91
|
-
*
|
|
92
|
-
* Starting with the active top level state, each active state is processed like this:
|
|
93
|
-
* 1. its outer transitions, 2. its "during" action, 3. its inner transitions (to its own children),
|
|
94
|
-
* 4. its active child. Processing stops as soon as a transition is taken.
|
|
95
|
-
*
|
|
96
|
-
* The runtime selects the transition itself and sends an event for exactly this transition to XState,
|
|
97
|
-
* which executes the exit, transition and entry actions in the right order.
|
|
98
|
-
*/
|
|
99
|
-
class ChartRuntime {
|
|
100
|
-
/**
|
|
101
|
-
* @param {string} chartId
|
|
102
|
-
* @param {Chart} chart
|
|
103
|
-
* @param {RuntimeHost} host
|
|
104
|
-
*/
|
|
105
|
-
constructor(chartId, chart, host) {
|
|
106
|
-
this.chartId = chartId;
|
|
107
|
-
this.chart = chart;
|
|
108
|
-
this.host = host;
|
|
109
|
-
this.structure = new ChartStructure(chart);
|
|
110
|
-
/** @type {Map<string, ChartState>} */
|
|
111
|
-
this.statesById = this.structure.statesById;
|
|
112
|
-
/** values of all symbols, this is also the global scope of conditions and actions */
|
|
113
|
-
/** @type {Set<NodeJS.Timeout>} timers of setStateDelayed() */
|
|
114
|
-
this.delayedWrites = new Set();
|
|
115
|
-
this.sandbox = vm.createContext(this.createHelpers());
|
|
116
|
-
/** last value written to (or read from) each output */
|
|
117
|
-
this.outputValues = new Map();
|
|
118
|
-
/** @type {Map<string, CheckedTransition[]>} transitions which leave the source state, by source, sorted */
|
|
119
|
-
this.outerTransitions = new Map();
|
|
120
|
-
/** @type {Map<string, CheckedTransition[]>} transitions to children of the source state, by source, sorted */
|
|
121
|
-
this.innerTransitions = new Map();
|
|
122
|
-
/** @type {Map<string, CheckedTransition[]>} temporal transitions by source */
|
|
123
|
-
this.temporalTransitions = new Map();
|
|
124
|
-
/** @type {string[]} all active states, parents before children */
|
|
125
|
-
this.activeIds = [];
|
|
126
|
-
/** @type {Map<string, number>} time each active state was entered */
|
|
127
|
-
this.enteredAt = new Map();
|
|
128
|
-
/** @type {Map<string, { handle?: NodeJS.Timeout }[]>} timers of the temporal transitions by state */
|
|
129
|
-
this.timers = new Map();
|
|
130
|
-
/** @type {Set<string>} temporal transitions whose time has come */
|
|
131
|
-
this.expired = new Set();
|
|
132
|
-
/** @type {Map<string, NodeJS.Timeout>} timers of inputs with debounce, by variable name */
|
|
133
|
-
this.debounceTimers = new Map();
|
|
134
|
-
/** JSON of the last persisted data, to persist only changes */
|
|
135
|
-
this.lastPersisted = "";
|
|
136
|
-
this.hash = getChartHash(chart);
|
|
137
|
-
/** @type {import("xstate").AnyActor | null} */
|
|
138
|
-
this.actor = null;
|
|
139
|
-
/** the current time, can be replaced in tests */
|
|
140
|
-
this.now = () => Date.now();
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* IDs of all states this chart reads
|
|
145
|
-
*
|
|
146
|
-
* @returns {string[]}
|
|
147
|
-
*/
|
|
148
|
-
get inputStateIds() {
|
|
149
|
-
return this.chart.symbols
|
|
150
|
-
.filter(s => s.scope === "input" && s.stateId)
|
|
151
|
-
.map(s => /** @type {string} */ (s.stateId));
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* @param {object} [options]
|
|
156
|
-
* @param {boolean} [options.restore] Continue with the persisted state, if the chart was not changed since
|
|
157
|
-
*/
|
|
158
|
-
async start(options = {}) {
|
|
159
|
-
this.logProblems();
|
|
160
|
-
for (const symbol of this.chart.symbols) {
|
|
161
|
-
if (symbol.scope === "local") {
|
|
162
|
-
// a copy, so actions changing an object or array do not change the chart model
|
|
163
|
-
this.sandbox[symbol.name] = structuredClone(symbol.initial);
|
|
164
|
-
} else if (symbol.stateId) {
|
|
165
|
-
const val = await this.host.readState(symbol.stateId);
|
|
166
|
-
this.sandbox[symbol.name] = val;
|
|
167
|
-
if (symbol.scope === "output") {
|
|
168
|
-
this.outputValues.set(symbol.name, val);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// defines the functions, without block around the code so they become globals of the sandbox
|
|
174
|
-
for (const fn of this.chart.functions || []) {
|
|
175
|
-
const where = `function block ${getDeclarations(fn.code)
|
|
176
|
-
.map(d => d.name)
|
|
177
|
-
.join(", ")}`;
|
|
178
|
-
this.run(this.compile(fn.code, where), where);
|
|
179
|
-
}
|
|
180
|
-
this.writeChangedOutputs();
|
|
181
|
-
|
|
182
|
-
const machine = createMachine(this.buildMachineConfig());
|
|
183
|
-
const persisted = options.restore && this.chart.restoreState !== false ? await this.loadPersisted() : null;
|
|
184
|
-
if (persisted) {
|
|
185
|
-
try {
|
|
186
|
-
this.restore(machine, persisted);
|
|
187
|
-
this.host.log.info(`[${this.chart.name}] Restored the state from before the restart`);
|
|
188
|
-
} catch (err) {
|
|
189
|
-
this.host.log.warn(`[${this.chart.name}] Cannot restore the state, starting anew: ${err.message}`);
|
|
190
|
-
this.stop();
|
|
191
|
-
this.startActor(machine);
|
|
192
|
-
}
|
|
193
|
-
} else {
|
|
194
|
-
this.startActor(machine);
|
|
195
|
-
}
|
|
196
|
-
this.persist();
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* Starts the chart in its initial state, the entry actions are executed
|
|
201
|
-
*
|
|
202
|
-
* @param {import("xstate").AnyStateMachine} machine
|
|
203
|
-
*/
|
|
204
|
-
startActor(machine) {
|
|
205
|
-
this.activeIds = [];
|
|
206
|
-
this.enteredAt.clear();
|
|
207
|
-
this.actor = createActor(machine);
|
|
208
|
-
this.subscribe();
|
|
209
|
-
this.actor.start();
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/**
|
|
213
|
-
* Continues with a persisted state: the entry actions are not executed again,
|
|
214
|
-
* the timers of the temporal transitions continue with their remaining time
|
|
215
|
-
*
|
|
216
|
-
* @param {import("xstate").AnyStateMachine} machine
|
|
217
|
-
* @param {PersistedChart} persisted
|
|
218
|
-
*/
|
|
219
|
-
restore(machine, persisted) {
|
|
220
|
-
for (const symbol of this.chart.symbols) {
|
|
221
|
-
if (symbol.scope === "local" && symbol.name in persisted.locals) {
|
|
222
|
-
this.sandbox[symbol.name] = persisted.locals[symbol.name];
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
this.actor = createActor(machine, { snapshot: persisted.machine });
|
|
226
|
-
this.subscribe();
|
|
227
|
-
this.actor.start();
|
|
228
|
-
this.publishActiveStates(this.actor.getSnapshot().value);
|
|
229
|
-
for (const id of this.activeIds) {
|
|
230
|
-
this.enteredAt.set(id, persisted.entered[id] ?? this.now());
|
|
231
|
-
this.startTimers(id);
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
subscribe() {
|
|
236
|
-
this.actor?.subscribe({
|
|
237
|
-
next: snapshot => this.publishActiveStates(snapshot.value),
|
|
238
|
-
error: err => this.host.log.error(`[${this.chart.name}] ${err}`),
|
|
239
|
-
});
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/**
|
|
243
|
-
* @returns {Promise<PersistedChart | null>} the persisted data, if it fits to this chart
|
|
244
|
-
*/
|
|
245
|
-
async loadPersisted() {
|
|
246
|
-
let persisted;
|
|
247
|
-
try {
|
|
248
|
-
persisted = await this.host.loadPersisted?.();
|
|
249
|
-
} catch (err) {
|
|
250
|
-
this.host.log.warn(`[${this.chart.name}] Cannot load the stored state: ${err.message}`);
|
|
251
|
-
return null;
|
|
252
|
-
}
|
|
253
|
-
if (!persisted?.machine || persisted.version !== PERSISTED_VERSION) {
|
|
254
|
-
return null;
|
|
255
|
-
}
|
|
256
|
-
if (persisted.hash !== this.hash) {
|
|
257
|
-
this.host.log.info(`[${this.chart.name}] The chart was changed, starting in the initial state`);
|
|
258
|
-
return null;
|
|
259
|
-
}
|
|
260
|
-
return { ...persisted, entered: persisted.entered || {}, locals: persisted.locals || {} };
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
/**
|
|
264
|
-
* Stores the current state of the chart, if it changed since the last time
|
|
265
|
-
*/
|
|
266
|
-
persist() {
|
|
267
|
-
if (!this.actor || !this.host.persist) {
|
|
268
|
-
return;
|
|
269
|
-
}
|
|
270
|
-
/** @type {PersistedChart} */
|
|
271
|
-
const data = {
|
|
272
|
-
version: PERSISTED_VERSION,
|
|
273
|
-
hash: this.hash,
|
|
274
|
-
machine: this.actor.getPersistedSnapshot(),
|
|
275
|
-
entered: Object.fromEntries(this.enteredAt),
|
|
276
|
-
locals: Object.fromEntries(
|
|
277
|
-
this.chart.symbols.filter(s => s.scope === "local").map(s => [s.name, this.sandbox[s.name]]),
|
|
278
|
-
),
|
|
279
|
-
};
|
|
280
|
-
let json;
|
|
281
|
-
try {
|
|
282
|
-
json = JSON.stringify(data);
|
|
283
|
-
} catch (err) {
|
|
284
|
-
this.host.log.warn(`[${this.chart.name}] Cannot store the state: ${err.message}`);
|
|
285
|
-
return;
|
|
286
|
-
}
|
|
287
|
-
if (json !== this.lastPersisted) {
|
|
288
|
-
this.lastPersisted = json;
|
|
289
|
-
this.host.persist(data);
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/**
|
|
294
|
-
* Called by XState before the entry action of a state
|
|
295
|
-
*
|
|
296
|
-
* @param {string} id
|
|
297
|
-
*/
|
|
298
|
-
onEnter(id) {
|
|
299
|
-
this.enteredAt.set(id, this.now());
|
|
300
|
-
this.startTimers(id);
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
/**
|
|
304
|
-
* Called by XState after the exit action of a state
|
|
305
|
-
*
|
|
306
|
-
* @param {string} id
|
|
307
|
-
*/
|
|
308
|
-
onExit(id) {
|
|
309
|
-
this.enteredAt.delete(id);
|
|
310
|
-
for (const timer of this.timers.get(id) || []) {
|
|
311
|
-
clearTimeout(timer.handle);
|
|
312
|
-
}
|
|
313
|
-
this.timers.delete(id);
|
|
314
|
-
for (const transition of this.temporalTransitions.get(id) || []) {
|
|
315
|
-
this.expired.delete(transition.id);
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
/**
|
|
320
|
-
* Starts a timer for every temporal transition of a state, which wakes up the chart when its time has come
|
|
321
|
-
*
|
|
322
|
-
* @param {string} id
|
|
323
|
-
*/
|
|
324
|
-
startTimers(id) {
|
|
325
|
-
const entered = this.enteredAt.get(id) ?? this.now();
|
|
326
|
-
const timers = [];
|
|
327
|
-
for (const transition of this.temporalTransitions.get(id) || []) {
|
|
328
|
-
const timer = {};
|
|
329
|
-
const due = entered + /** @type {number} */ (transition.afterMs);
|
|
330
|
-
const wait = () => {
|
|
331
|
-
const remaining = due - this.now();
|
|
332
|
-
timer.handle = setTimeout(
|
|
333
|
-
() => {
|
|
334
|
-
if (due - this.now() > 0 && remaining > MAX_TIMEOUT_MS) {
|
|
335
|
-
wait();
|
|
336
|
-
return;
|
|
337
|
-
}
|
|
338
|
-
this.expired.add(transition.id);
|
|
339
|
-
this.wakeup(null);
|
|
340
|
-
},
|
|
341
|
-
Math.min(Math.max(remaining, 0), MAX_TIMEOUT_MS),
|
|
342
|
-
);
|
|
343
|
-
};
|
|
344
|
-
wait();
|
|
345
|
-
timers.push(timer);
|
|
346
|
-
}
|
|
347
|
-
this.timers.set(id, timers);
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
/**
|
|
351
|
-
* Logs the problems found by the validation, the chart is started anyway
|
|
352
|
-
*/
|
|
353
|
-
logProblems() {
|
|
354
|
-
for (const { kind, id, field, diagnostic } of validateChart(this.chart)) {
|
|
355
|
-
let where;
|
|
356
|
-
if (kind === "state") {
|
|
357
|
-
where = `${this.statesById.get(id)?.name ?? id}/${field}`;
|
|
358
|
-
} else if (kind === "function") {
|
|
359
|
-
const fn = this.chart.functions?.find(f => f.id === id);
|
|
360
|
-
where = `Function block ${getDeclarations(fn?.code)
|
|
361
|
-
.map(d => d.name)
|
|
362
|
-
.join(", ")}`;
|
|
363
|
-
} else {
|
|
364
|
-
const transition = this.chart.transitions.find(t => t.id === id);
|
|
365
|
-
const source = this.statesById.get(transition?.source ?? "")?.name;
|
|
366
|
-
const target = this.statesById.get(transition?.target ?? "")?.name;
|
|
367
|
-
where = `Transition ${source} -> ${target}`;
|
|
368
|
-
}
|
|
369
|
-
const log = diagnostic.severity === "error" ? this.host.log.error : this.host.log.warn;
|
|
370
|
-
log(`[${this.chart.name}] ${where}: ${diagnostic.message}`);
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
stop() {
|
|
375
|
-
this.actor?.stop();
|
|
376
|
-
this.actor = null;
|
|
377
|
-
for (const timers of this.timers.values()) {
|
|
378
|
-
for (const timer of timers) {
|
|
379
|
-
clearTimeout(timer.handle);
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
this.timers.clear();
|
|
383
|
-
this.expired.clear();
|
|
384
|
-
for (const timer of this.debounceTimers.values()) {
|
|
385
|
-
clearTimeout(timer);
|
|
386
|
-
}
|
|
387
|
-
this.debounceTimers.clear();
|
|
388
|
-
for (const timer of this.delayedWrites) {
|
|
389
|
-
clearTimeout(timer);
|
|
390
|
-
}
|
|
391
|
-
this.delayedWrites.clear();
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
/**
|
|
395
|
-
* The helper functions which can be used in conditions and actions, see lib/chart-helpers.js
|
|
396
|
-
*
|
|
397
|
-
* @returns {Record<string, (...args: any[]) => any>}
|
|
398
|
-
*/
|
|
399
|
-
createHelpers() {
|
|
400
|
-
const prefix = `[${this.chart.name}]`;
|
|
401
|
-
/**
|
|
402
|
-
* @param {string} id
|
|
403
|
-
* @param {any} value
|
|
404
|
-
* @param {boolean} ack
|
|
405
|
-
*/
|
|
406
|
-
const write = (id, value, ack) =>
|
|
407
|
-
this.host
|
|
408
|
-
.writeState(String(id), value, !!ack)
|
|
409
|
-
.catch(err => this.host.log.warn(`${prefix} Cannot write ${id}: ${err}`));
|
|
410
|
-
return {
|
|
411
|
-
log: (message, level = "info") => {
|
|
412
|
-
const method = ["debug", "info", "warn", "error"].includes(level) ? level : "info";
|
|
413
|
-
this.host.log[method](`${prefix} ${message}`);
|
|
414
|
-
},
|
|
415
|
-
setState: (id, value, ack = false) => {
|
|
416
|
-
write(id, value, ack);
|
|
417
|
-
},
|
|
418
|
-
setStateDelayed: (id, value, delayMs, ack = false) => {
|
|
419
|
-
const timer = setTimeout(
|
|
420
|
-
() => {
|
|
421
|
-
this.delayedWrites.delete(timer);
|
|
422
|
-
write(id, value, ack);
|
|
423
|
-
},
|
|
424
|
-
Math.min(Math.max(Number(delayMs) || 0, 0), MAX_TIMEOUT_MS),
|
|
425
|
-
);
|
|
426
|
-
this.delayedWrites.add(timer);
|
|
427
|
-
},
|
|
428
|
-
sendTo: (instance, command, message) => {
|
|
429
|
-
if (!this.host.sendTo) {
|
|
430
|
-
throw new Error("sendTo() is not available");
|
|
431
|
-
}
|
|
432
|
-
this.host.sendTo(String(instance), String(command), message);
|
|
433
|
-
},
|
|
434
|
-
isTimeBetween: (start, end) => isTimeBetween(start, end, new Date(this.now())),
|
|
435
|
-
isDaytime: () => {
|
|
436
|
-
const now = new Date(this.now());
|
|
437
|
-
const times = this.getSunTimes(now);
|
|
438
|
-
// in polar regions there may be no sunrise or sunset, the comparisons are false then
|
|
439
|
-
return now >= times.sunrise && now < times.sunset;
|
|
440
|
-
},
|
|
441
|
-
astroTime: (name, offsetMinutes = 0) => {
|
|
442
|
-
const time = this.getSunTimes(new Date(this.now()))[name];
|
|
443
|
-
if (!(time instanceof Date)) {
|
|
444
|
-
throw new Error(`Unknown astro event "${name}"`);
|
|
445
|
-
}
|
|
446
|
-
return new Date(time.getTime() + (Number(offsetMinutes) || 0) * 60_000);
|
|
447
|
-
},
|
|
448
|
-
};
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
/**
|
|
452
|
-
* @param {Date} date
|
|
453
|
-
* @returns {Record<string, Date>} the astro events of the day
|
|
454
|
-
* @throws {Error} if no location is configured
|
|
455
|
-
*/
|
|
456
|
-
getSunTimes(date) {
|
|
457
|
-
const location = this.host.getLocation?.();
|
|
458
|
-
if (!location) {
|
|
459
|
-
throw new Error("No location (latitude/longitude) configured in the ioBroker system settings");
|
|
460
|
-
}
|
|
461
|
-
return /** @type {any} */ (SunCalc.getTimes(date, location.latitude, location.longitude));
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
/**
|
|
465
|
-
* Must be called when an input state is updated
|
|
466
|
-
*
|
|
467
|
-
* @param {string} stateId
|
|
468
|
-
* @param {any} val
|
|
469
|
-
* @param {{ ack?: boolean }} [options] The ack flag of the update
|
|
470
|
-
*/
|
|
471
|
-
handleInput(stateId, val, options = {}) {
|
|
472
|
-
if (!this.actor) {
|
|
473
|
-
return;
|
|
474
|
-
}
|
|
475
|
-
for (const symbol of this.chart.symbols) {
|
|
476
|
-
if (symbol.scope !== "input" || symbol.stateId !== stateId) {
|
|
477
|
-
continue;
|
|
478
|
-
}
|
|
479
|
-
// an update which is filtered out does not change the variable either
|
|
480
|
-
if ((symbol.ack === "ack" && options.ack !== true) || (symbol.ack === "command" && options.ack !== false)) {
|
|
481
|
-
continue;
|
|
482
|
-
}
|
|
483
|
-
const debounceMs = symbol.debounceMs || 0;
|
|
484
|
-
if (debounceMs > 0) {
|
|
485
|
-
// wake up only after the input was quiet for the debounce time, with its last value
|
|
486
|
-
clearTimeout(this.debounceTimers.get(symbol.name));
|
|
487
|
-
this.debounceTimers.set(
|
|
488
|
-
symbol.name,
|
|
489
|
-
setTimeout(() => {
|
|
490
|
-
this.debounceTimers.delete(symbol.name);
|
|
491
|
-
this.applyInput(symbol, val);
|
|
492
|
-
}, debounceMs),
|
|
493
|
-
);
|
|
494
|
-
} else {
|
|
495
|
-
this.applyInput(symbol, val);
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
/**
|
|
501
|
-
* Sets the variable of an input and wakes up the chart
|
|
502
|
-
*
|
|
503
|
-
* @param {import("./chart-model").ChartSymbol} symbol
|
|
504
|
-
* @param {any} val
|
|
505
|
-
*/
|
|
506
|
-
applyInput(symbol, val) {
|
|
507
|
-
if (symbol.trigger === "change" && val === this.sandbox[symbol.name]) {
|
|
508
|
-
return;
|
|
509
|
-
}
|
|
510
|
-
this.sandbox[symbol.name] = val;
|
|
511
|
-
this.wakeup(symbol.name);
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
/**
|
|
515
|
-
* @param {string | null} input Name of the input which caused the wakeup, null for timers
|
|
516
|
-
*/
|
|
517
|
-
wakeup(input) {
|
|
518
|
-
if (!this.actor) {
|
|
519
|
-
return;
|
|
520
|
-
}
|
|
521
|
-
for (const id of this.getActiveChildren(null)) {
|
|
522
|
-
if (this.processState(id, input)) {
|
|
523
|
-
break;
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
// functions called in conditions may have changed outputs as well
|
|
527
|
-
this.writeChangedOutputs();
|
|
528
|
-
this.persist();
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
/**
|
|
532
|
-
* Processes an active state and its active children, see the class description
|
|
533
|
-
*
|
|
534
|
-
* @param {string} id
|
|
535
|
-
* @param {string | null} input Name of the input which caused the wakeup, null for timers
|
|
536
|
-
* @returns {boolean} true if a transition was taken
|
|
537
|
-
*/
|
|
538
|
-
processState(id, input) {
|
|
539
|
-
if (this.takeFirstValid(this.outerTransitions.get(id), input)) {
|
|
540
|
-
return true;
|
|
541
|
-
}
|
|
542
|
-
const state = this.statesById.get(id);
|
|
543
|
-
if (state?.during) {
|
|
544
|
-
this.runAction(state.during, `${state.name}/during`);
|
|
545
|
-
}
|
|
546
|
-
if (this.takeFirstValid(this.innerTransitions.get(id), input)) {
|
|
547
|
-
return true;
|
|
548
|
-
}
|
|
549
|
-
for (const child of this.getActiveChildren(id)) {
|
|
550
|
-
if (this.processState(child, input)) {
|
|
551
|
-
return true;
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
return false;
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
/**
|
|
558
|
-
* @param {string | null} parent null for the top level
|
|
559
|
-
* @returns {string[]}
|
|
560
|
-
*/
|
|
561
|
-
getActiveChildren(parent) {
|
|
562
|
-
const children = new Set(this.structure.getChildren(parent).map(s => s.id));
|
|
563
|
-
return this.activeIds.filter(id => children.has(id));
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
/**
|
|
567
|
-
* Takes the first transition whose event and condition match
|
|
568
|
-
*
|
|
569
|
-
* @param {CheckedTransition[] | undefined} transitions
|
|
570
|
-
* @param {string | null} input Name of the input which caused the wakeup, null for timers
|
|
571
|
-
* @returns {boolean} true if a transition was taken
|
|
572
|
-
*/
|
|
573
|
-
takeFirstValid(transitions, input) {
|
|
574
|
-
for (const transition of transitions || []) {
|
|
575
|
-
if (transition.event && transition.event !== input) {
|
|
576
|
-
continue;
|
|
577
|
-
}
|
|
578
|
-
if (transition.afterMs !== undefined && !this.isExpired(transition)) {
|
|
579
|
-
continue;
|
|
580
|
-
}
|
|
581
|
-
if (transition.condition && !this.run(transition.condition, `${transition.where}/condition`)) {
|
|
582
|
-
continue;
|
|
583
|
-
}
|
|
584
|
-
this.actor?.send({ type: `T.${transition.id}` });
|
|
585
|
-
return true;
|
|
586
|
-
}
|
|
587
|
-
return false;
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
/**
|
|
591
|
-
* @param {CheckedTransition} transition A temporal transition
|
|
592
|
-
* @returns {boolean} true if its source state is active for at least its delay
|
|
593
|
-
*/
|
|
594
|
-
isExpired(transition) {
|
|
595
|
-
if (this.expired.has(transition.id)) {
|
|
596
|
-
return true;
|
|
597
|
-
}
|
|
598
|
-
const entered = this.enteredAt.get(transition.source);
|
|
599
|
-
return entered !== undefined && this.now() - entered >= /** @type {number} */ (transition.afterMs);
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
buildMachineConfig() {
|
|
603
|
-
const structure = this.structure;
|
|
604
|
-
|
|
605
|
-
/**
|
|
606
|
-
* @param {ChartState} state
|
|
607
|
-
* @returns {any}
|
|
608
|
-
*/
|
|
609
|
-
const buildState = state => {
|
|
610
|
-
/** @type {any} */
|
|
611
|
-
const node = {
|
|
612
|
-
id: state.id,
|
|
613
|
-
entry: [
|
|
614
|
-
() => this.onEnter(state.id),
|
|
615
|
-
...(state.entry ? [() => this.runAction(state.entry, `${state.name}/entry`)] : []),
|
|
616
|
-
],
|
|
617
|
-
exit: [
|
|
618
|
-
...(state.exit ? [() => this.runAction(state.exit, `${state.name}/exit`)] : []),
|
|
619
|
-
() => this.onExit(state.id),
|
|
620
|
-
],
|
|
621
|
-
on: {},
|
|
622
|
-
};
|
|
623
|
-
|
|
624
|
-
/** @type {CheckedTransition[]} */
|
|
625
|
-
const outer = [];
|
|
626
|
-
/** @type {CheckedTransition[]} */
|
|
627
|
-
const inner = [];
|
|
628
|
-
for (const transition of structure.getTransitions(state.id)) {
|
|
629
|
-
const built = this.buildTransition(transition, state);
|
|
630
|
-
if (!built) {
|
|
631
|
-
continue;
|
|
632
|
-
}
|
|
633
|
-
// all transitions are selected by processState(), XState only executes them
|
|
634
|
-
node.on[`T.${transition.id}`] = built.config;
|
|
635
|
-
(built.inner ? inner : outer).push(built.checked);
|
|
636
|
-
}
|
|
637
|
-
this.outerTransitions.set(state.id, outer);
|
|
638
|
-
this.innerTransitions.set(state.id, inner);
|
|
639
|
-
this.temporalTransitions.set(
|
|
640
|
-
state.id,
|
|
641
|
-
[...outer, ...inner].filter(t => t.afterMs !== undefined),
|
|
642
|
-
);
|
|
643
|
-
|
|
644
|
-
const subStates = structure.getChildren(state.id);
|
|
645
|
-
if (subStates.length) {
|
|
646
|
-
node.initial = structure.getInitialChild(state.id);
|
|
647
|
-
node.states = Object.fromEntries(subStates.map(s => [s.id, buildState(s)]));
|
|
648
|
-
}
|
|
649
|
-
return node;
|
|
650
|
-
};
|
|
651
|
-
|
|
652
|
-
const topLevel = structure.getChildren(null);
|
|
653
|
-
if (!topLevel.length) {
|
|
654
|
-
throw new Error("The chart has no states");
|
|
655
|
-
}
|
|
656
|
-
return {
|
|
657
|
-
id: `chart_${this.chartId}`,
|
|
658
|
-
initial: structure.getInitialChild(null),
|
|
659
|
-
states: Object.fromEntries(topLevel.map(s => [s.id, buildState(s)])),
|
|
660
|
-
};
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
/**
|
|
664
|
-
* @param {ChartTransition} transition
|
|
665
|
-
* @param {ChartState} source
|
|
666
|
-
* @returns {{ inner: boolean; config: any; checked: CheckedTransition } | null}
|
|
667
|
-
*/
|
|
668
|
-
buildTransition(transition, source) {
|
|
669
|
-
const target = this.statesById.get(transition.target);
|
|
670
|
-
const where = `${source.name} -> ${target?.name ?? transition.target}`;
|
|
671
|
-
if (!target) {
|
|
672
|
-
this.host.log.warn(`[${this.chart.name}] Transition ${where}: unknown target, ignored`);
|
|
673
|
-
return null;
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
let label;
|
|
677
|
-
try {
|
|
678
|
-
label = parseTransitionLabel(transition.label);
|
|
679
|
-
} catch (err) {
|
|
680
|
-
this.host.log.error(`[${this.chart.name}] Transition ${where}: ${err.message}, ignored`);
|
|
681
|
-
return null;
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
const condition = label.condition ? this.compile(`(${label.condition})`, `${where}/condition`) : null;
|
|
685
|
-
const { inner, reenter } = this.structure.getTransitionKind(transition);
|
|
686
|
-
const config = {
|
|
687
|
-
target: `#${target.id}`,
|
|
688
|
-
reenter,
|
|
689
|
-
actions: label.action ? [() => this.runAction(label.action, `${where}/action`)] : [],
|
|
690
|
-
};
|
|
691
|
-
return {
|
|
692
|
-
inner,
|
|
693
|
-
config,
|
|
694
|
-
checked: {
|
|
695
|
-
id: transition.id,
|
|
696
|
-
source: source.id,
|
|
697
|
-
where,
|
|
698
|
-
event: label.event,
|
|
699
|
-
afterMs: label.afterMs,
|
|
700
|
-
condition,
|
|
701
|
-
},
|
|
702
|
-
};
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
/**
|
|
706
|
-
* @param {string} code
|
|
707
|
-
* @param {string} where Used in error messages
|
|
708
|
-
* @returns {vm.Script | null}
|
|
709
|
-
*/
|
|
710
|
-
compile(code, where) {
|
|
711
|
-
try {
|
|
712
|
-
return new vm.Script(code, { filename: `${this.chart.name}/${where}` });
|
|
713
|
-
} catch (err) {
|
|
714
|
-
this.host.log.error(`[${this.chart.name}] ${where}: ${err.message}`);
|
|
715
|
-
return null;
|
|
716
|
-
}
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
/**
|
|
720
|
-
* @param {vm.Script | null} script
|
|
721
|
-
* @param {string} where Used in error messages
|
|
722
|
-
* @returns {any}
|
|
723
|
-
*/
|
|
724
|
-
run(script, where) {
|
|
725
|
-
if (!script) {
|
|
726
|
-
return undefined;
|
|
727
|
-
}
|
|
728
|
-
try {
|
|
729
|
-
return script.runInContext(this.sandbox, { timeout: SCRIPT_TIMEOUT_MS });
|
|
730
|
-
} catch (err) {
|
|
731
|
-
this.host.log.warn(`[${this.chart.name}] ${where}: ${err.message}`);
|
|
732
|
-
return undefined;
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
/**
|
|
737
|
-
* Executes an action and writes all changed outputs
|
|
738
|
-
*
|
|
739
|
-
* @param {string | undefined} code
|
|
740
|
-
* @param {string} where Used in error messages
|
|
741
|
-
*/
|
|
742
|
-
runAction(code, where) {
|
|
743
|
-
// the block keeps "let" and "const" declarations local to this action
|
|
744
|
-
this.run(this.compile(`{\n${code}\n}`, where), where);
|
|
745
|
-
this.writeChangedOutputs();
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
writeChangedOutputs() {
|
|
749
|
-
for (const symbol of this.chart.symbols) {
|
|
750
|
-
if (symbol.scope !== "output" || !symbol.stateId) {
|
|
751
|
-
continue;
|
|
752
|
-
}
|
|
753
|
-
const val = this.sandbox[symbol.name];
|
|
754
|
-
if (val !== this.outputValues.get(symbol.name)) {
|
|
755
|
-
this.outputValues.set(symbol.name, val);
|
|
756
|
-
this.host
|
|
757
|
-
.writeState(symbol.stateId, val)
|
|
758
|
-
.catch(err => this.host.log.warn(`[${this.chart.name}] Cannot write ${symbol.stateId}: ${err}`));
|
|
759
|
-
}
|
|
760
|
-
}
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
/**
|
|
764
|
-
* @param {any} value XState state value
|
|
765
|
-
*/
|
|
766
|
-
publishActiveStates(value) {
|
|
767
|
-
/** @type {string[]} all active states, parents before children */
|
|
768
|
-
const ids = [];
|
|
769
|
-
/** @type {string[]} name paths of the active leaf states */
|
|
770
|
-
const names = [];
|
|
771
|
-
|
|
772
|
-
/**
|
|
773
|
-
* @param {any} val
|
|
774
|
-
* @param {string[]} path
|
|
775
|
-
*/
|
|
776
|
-
const walk = (val, path) => {
|
|
777
|
-
const keys = typeof val === "string" ? [val] : Object.keys(val || {});
|
|
778
|
-
for (const key of keys) {
|
|
779
|
-
const name = [...path, this.statesById.get(key)?.name ?? key];
|
|
780
|
-
ids.push(key);
|
|
781
|
-
if (typeof val === "string" || !val[key] || !Object.keys(val[key]).length) {
|
|
782
|
-
names.push(name.join("."));
|
|
783
|
-
} else {
|
|
784
|
-
walk(val[key], name);
|
|
785
|
-
}
|
|
786
|
-
}
|
|
787
|
-
};
|
|
788
|
-
walk(value, []);
|
|
789
|
-
|
|
790
|
-
if (ids.join() !== this.activeIds.join()) {
|
|
791
|
-
this.activeIds = ids;
|
|
792
|
-
this.host.onActiveChange({ names: names.join(", "), ids });
|
|
793
|
-
}
|
|
794
|
-
}
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
module.exports = { ChartRuntime, getChartHash };
|