iobroker.statechart 0.1.1 → 0.2.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.
@@ -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 };