iobroker.statechart 0.2.0 → 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.
@@ -43,19 +43,22 @@ var import_chart_structure = require("./chart-structure");
43
43
  var import_chart_validation = require("./chart-validation");
44
44
  const SCRIPT_TIMEOUT_MS = 1e3;
45
45
  const MAX_TIMEOUT_MS = 2 ** 31 - 1;
46
+ const DAY_MS = 24 * 60 * 60 * 1e3;
47
+ const MAX_ADVANCE_TIMERS = 1e4;
46
48
  const PERSISTED_VERSION = 1;
47
49
  function getChartHash(chart) {
48
50
  const logic = {
49
51
  initial: chart.initial,
50
52
  symbols: chart.symbols,
51
- // comments and notes are only documentation
52
- states: chart.states.map(({ x: _x, y: _y, width: _w, height: _h, comment: _c, ...state }) => state),
53
+ // comments, notes and colors are only documentation
54
+ states: chart.states.map(({ x: _x, y: _y, width: _w, height: _h, comment: _c, color: _co, ...state }) => state),
53
55
  transitions: chart.transitions.map(
54
56
  ({
55
57
  sourceHandle: _s,
56
58
  targetHandle: _t,
57
59
  sourceAnchor: _sa,
58
60
  targetAnchor: _ta,
61
+ labelOffset: _lo,
59
62
  comment: _c,
60
63
  ...transition
61
64
  }) => transition
@@ -64,14 +67,25 @@ function getChartHash(chart) {
64
67
  };
65
68
  return import_node_crypto.default.createHash("sha1").update(JSON.stringify(logic)).digest("hex");
66
69
  }
70
+ function toSnapshot(state) {
71
+ return state ? { val: state.val, ack: !!state.ack, ts: state.ts, lc: state.lc } : null;
72
+ }
67
73
  class ChartRuntime {
68
- constructor(chartId, chart, host) {
74
+ /**
75
+ * @param chartId ID of the chart object
76
+ * @param chart The chart
77
+ * @param host The connection to the outside world
78
+ * @param simulated Simulation: the inputs are only changed by simulateInput(), outputs, setState() etc. are not
79
+ * written but recorded in the history, nothing is persisted
80
+ */
81
+ constructor(chartId, chart, host, simulated = false) {
69
82
  this.chartId = chartId;
70
83
  this.chart = chart;
71
84
  this.host = host;
85
+ this.simulated = simulated;
72
86
  this.structure = new import_chart_structure.ChartStructure(chart);
73
87
  this.statesById = this.structure.statesById;
74
- this.sandbox = import_node_vm.default.createContext(this.createHelpers());
88
+ this.sandbox = import_node_vm.default.createContext({ ...this.createHelpers(), ...import_chart_model.TIME_UNITS });
75
89
  this.hash = getChartHash(chart);
76
90
  this.prepareAllCode();
77
91
  }
@@ -93,10 +107,26 @@ class ChartRuntime {
93
107
  activeIds = [];
94
108
  /** time each active state was entered */
95
109
  enteredAt = /* @__PURE__ */ new Map();
110
+ /** how often each state was entered, to notice that a parallel state was left and entered again */
111
+ entryCount = /* @__PURE__ */ new Map();
96
112
  /** timers of the temporal transitions by state */
97
113
  timers = /* @__PURE__ */ new Map();
98
- /** temporal transitions whose time has come */
114
+ /** after(n, unit) transitions whose time has come */
99
115
  expired = /* @__PURE__ */ new Set();
116
+ /** at() and every() transitions whose timer caused the current wakeup */
117
+ firedTimers = /* @__PURE__ */ new Set();
118
+ /** wakeups by each input since each active state was entered, by state */
119
+ inputCounts = /* @__PURE__ */ new Map();
120
+ /** since when the condition of each after(n, unit, condition) of the active states is true, by transition */
121
+ trueSince = /* @__PURE__ */ new Map();
122
+ /** timers of after(n, unit, condition), by transition */
123
+ durationTimers = /* @__PURE__ */ new Map();
124
+ /** for duration(): since when the value of each call is true, by call and state */
125
+ durationCalls = /* @__PURE__ */ new Map();
126
+ /** number of duration() calls found in the code, to give each of them an ID */
127
+ durationCallCount = 0;
128
+ /** the state whose condition or action is executed, used by elapsed() and duration() */
129
+ context = null;
100
130
  /** timers of inputs with debounce, by variable name */
101
131
  debounceTimers = /* @__PURE__ */ new Map();
102
132
  /** JSON of the last persisted data, to persist only changes */
@@ -115,14 +145,36 @@ class ChartRuntime {
115
145
  preparedCode = /* @__PURE__ */ new Map();
116
146
  /** the variables used by hasChanged() etc. */
117
147
  changeVariables = /* @__PURE__ */ new Set();
148
+ /** the IDs of the states read by getState(), they are read at the start and then kept up to date */
149
+ readStateIds = /* @__PURE__ */ new Set();
150
+ /** the states read by getState(), null if a state does not exist */
151
+ stateCache = /* @__PURE__ */ new Map();
118
152
  /** their values at the start of the previous wakeup, null before the second wakeup */
119
153
  previousValues = null;
120
154
  /** their values at the start of the current wakeup, null before the first one */
121
155
  currentValues = null;
122
156
  hash;
123
157
  actor = null;
124
- /** the current time, can be replaced in tests */
125
- now = () => Date.now();
158
+ /** true while a wakeup is processed, effects outside of wakeups are published right away */
159
+ wakingUp = false;
160
+ /** all running timers, to fire them early when the time of a simulation is advanced */
161
+ scheduled = /* @__PURE__ */ new Set();
162
+ /** simulation: added to the real time, see advanceTime() */
163
+ timeOffset = 0;
164
+ /** simulation: paused, the wakeups wait in the queue */
165
+ paused = false;
166
+ /** simulation: the wakeups which wait while paused */
167
+ queue = [];
168
+ /** simulation: IDs of the states and transitions with a breakpoint */
169
+ breakpoints = /* @__PURE__ */ new Set();
170
+ /** simulation: set when a breakpoint is reached during a wakeup, it pauses after the step */
171
+ pendingBreak = null;
172
+ /** simulation: where it stopped at a breakpoint, until it continues */
173
+ breakpointHit = null;
174
+ /** JSON of the last status passed to host.onSimulationStatus() */
175
+ lastStatus = "";
176
+ /** the current time, can be replaced in tests. A simulation can be ahead of the real time. */
177
+ now = () => Date.now() + this.timeOffset;
126
178
  /**
127
179
  * Prepares all code of the chart for the change detection, so its variables are known from the first wakeup on
128
180
  */
@@ -135,6 +187,7 @@ class ChartRuntime {
135
187
  for (const transition of this.chart.transitions) {
136
188
  try {
137
189
  const label = (0, import_chart_model.parseTransitionLabel)(transition.label);
190
+ this.prepareCode(label.temporal?.condition, "expression");
138
191
  this.prepareCode(label.condition, "expression");
139
192
  this.prepareCode(label.action, "statements");
140
193
  } catch {
@@ -147,7 +200,8 @@ class ChartRuntime {
147
200
  /**
148
201
  * @param code A condition, action or function block
149
202
  * @param kind Expression or statements
150
- * @returns the code with the names of the variables in hasChanged() etc., see quoteChangeArguments()
203
+ * @returns the code with the names of the variables in hasChanged() etc. and the IDs of the duration() calls,
204
+ * see prepareHelperCalls()
151
205
  */
152
206
  prepareCode(code, kind) {
153
207
  if (!code) {
@@ -156,11 +210,14 @@ class ChartRuntime {
156
210
  const key = `${kind}:${code}`;
157
211
  let prepared = this.preparedCode.get(key);
158
212
  if (prepared === void 0) {
159
- const quoted = (0, import_chart_validation.quoteChangeArguments)(code, kind);
213
+ const quoted = (0, import_chart_validation.prepareHelperCalls)(code, kind, () => `d${++this.durationCallCount}`);
160
214
  prepared = quoted.code;
161
215
  for (const name of quoted.names) {
162
216
  this.changeVariables.add(name);
163
217
  }
218
+ for (const id of quoted.stateIds) {
219
+ this.readStateIds.add(id);
220
+ }
164
221
  this.preparedCode.set(key, prepared);
165
222
  }
166
223
  return prepared;
@@ -171,12 +228,34 @@ class ChartRuntime {
171
228
  get inputStateIds() {
172
229
  return this.chart.symbols.filter((s) => s.scope === "input" && s.stateId).map((s) => s.stateId);
173
230
  }
231
+ /**
232
+ * IDs of all states whose changes must be passed to handleStateChange(): the inputs and the states read by
233
+ * getState()
234
+ */
235
+ get subscribedStateIds() {
236
+ return [.../* @__PURE__ */ new Set([...this.simulated ? [] : this.inputStateIds, ...this.readStateIds])];
237
+ }
174
238
  /**
175
239
  * @param options Start options
176
240
  * @param options.restore Continue with the persisted state, if the chart was not changed since
177
241
  */
178
242
  async start(options = {}) {
179
243
  this.logProblems();
244
+ for (const id of this.readStateIds) {
245
+ try {
246
+ let state;
247
+ if (this.host.getState) {
248
+ state = await this.host.getState(id);
249
+ } else {
250
+ const val = await this.host.readState(id);
251
+ state = val === void 0 ? null : { val, ack: true, ts: this.now(), lc: this.now() };
252
+ }
253
+ this.stateCache.set(id, toSnapshot(state));
254
+ } catch (err) {
255
+ this.host.log.warn(`[${this.chart.name}] Cannot read ${id}: ${err.message}`);
256
+ this.stateCache.set(id, null);
257
+ }
258
+ }
180
259
  for (const symbol of this.chart.symbols) {
181
260
  if (symbol.scope === "local") {
182
261
  this.sandbox[symbol.name] = structuredClone(symbol.initial);
@@ -193,6 +272,7 @@ class ChartRuntime {
193
272
  this.run(this.compile(this.prepareCode(fn.code, "statements"), where), where);
194
273
  }
195
274
  this.writeChangedOutputs();
275
+ this.wakingUp = true;
196
276
  const machine = (0, import_xstate.createMachine)(this.buildMachineConfig());
197
277
  const persisted = options.restore && this.chart.restoreState !== false ? await this.loadPersisted() : null;
198
278
  let restored = false;
@@ -209,7 +289,8 @@ class ChartRuntime {
209
289
  } else {
210
290
  this.startActor(machine);
211
291
  }
212
- this.pendingHistory.push({ ts: this.now(), kind: "start", restored, active: this.activeNames });
292
+ this.pendingHistory.unshift({ ts: this.now(), kind: "start", restored, active: this.activeNames });
293
+ this.wakingUp = false;
213
294
  this.wakeup(null, "start");
214
295
  }
215
296
  /**
@@ -243,9 +324,19 @@ class ChartRuntime {
243
324
  this.publishActiveStates(this.actor.getSnapshot().value);
244
325
  for (const id of this.activeIds) {
245
326
  this.enteredAt.set(id, persisted.entered[id] ?? this.now());
327
+ this.inputCounts.set(id, new Map(Object.entries(persisted.counts?.[id] ?? {})));
328
+ for (const transition of this.temporalTransitions.get(id) || []) {
329
+ const since = persisted.trueSince?.[transition.id];
330
+ if (since !== void 0) {
331
+ this.trueSince.set(transition.id, since);
332
+ }
333
+ }
246
334
  this.startTimers(id);
247
335
  }
248
336
  }
337
+ /**
338
+ * Publishes the active states after each transition, and logs errors of the state machine
339
+ */
249
340
  subscribe() {
250
341
  this.actor?.subscribe({
251
342
  next: (snapshot) => this.publishActiveStates(snapshot.value),
@@ -270,13 +361,19 @@ class ChartRuntime {
270
361
  this.host.log.info(`[${this.chart.name}] The chart was changed, starting in the initial state`);
271
362
  return null;
272
363
  }
273
- return { ...persisted, entered: persisted.entered || {}, locals: persisted.locals || {} };
364
+ return {
365
+ ...persisted,
366
+ entered: persisted.entered || {},
367
+ locals: persisted.locals || {},
368
+ counts: persisted.counts || {},
369
+ trueSince: persisted.trueSince || {}
370
+ };
274
371
  }
275
372
  /**
276
373
  * Stores the current state of the chart, if it changed since the last time
277
374
  */
278
375
  persist() {
279
- if (!this.actor || !this.host.persist) {
376
+ if (!this.actor || !this.host.persist || this.simulated) {
280
377
  return;
281
378
  }
282
379
  const data = {
@@ -286,7 +383,11 @@ class ChartRuntime {
286
383
  entered: Object.fromEntries(this.enteredAt),
287
384
  locals: Object.fromEntries(
288
385
  this.chart.symbols.filter((s) => s.scope === "local").map((s) => [s.name, this.sandbox[s.name]])
289
- )
386
+ ),
387
+ counts: Object.fromEntries(
388
+ [...this.inputCounts].filter(([, counts]) => counts.size).map(([id, counts]) => [id, Object.fromEntries(counts)])
389
+ ),
390
+ trueSince: Object.fromEntries(this.trueSince)
290
391
  };
291
392
  let json;
292
393
  try {
@@ -306,7 +407,12 @@ class ChartRuntime {
306
407
  * @param id ID of the state
307
408
  */
308
409
  onEnter(id) {
410
+ if (this.simulated && this.breakpoints.has(id)) {
411
+ this.pendingBreak ??= this.statesById.get(id)?.name ?? id;
412
+ }
309
413
  this.enteredAt.set(id, this.now());
414
+ this.entryCount.set(id, (this.entryCount.get(id) ?? 0) + 1);
415
+ this.inputCounts.set(id, /* @__PURE__ */ new Map());
310
416
  this.startTimers(id);
311
417
  }
312
418
  /**
@@ -317,13 +423,109 @@ class ChartRuntime {
317
423
  onExit(id) {
318
424
  this.enteredAt.delete(id);
319
425
  for (const timer of this.timers.get(id) || []) {
320
- clearTimeout(timer.handle);
426
+ this.cancelTimer(timer);
321
427
  }
322
428
  this.timers.delete(id);
429
+ this.inputCounts.delete(id);
323
430
  for (const transition of this.temporalTransitions.get(id) || []) {
324
431
  this.expired.delete(transition.id);
432
+ this.stopDuration(transition.id);
433
+ }
434
+ for (const [key, call] of this.durationCalls) {
435
+ if (call.state === id) {
436
+ this.durationCalls.delete(key);
437
+ }
438
+ }
439
+ }
440
+ /**
441
+ * Stops measuring how long the condition of an after(n, unit, condition) is true
442
+ *
443
+ * @param id ID of the transition
444
+ */
445
+ stopDuration(id) {
446
+ this.trueSince.delete(id);
447
+ this.cancelTimer(this.durationTimers.get(id));
448
+ this.durationTimers.delete(id);
449
+ }
450
+ /**
451
+ * Starts a timer with the functions of the host, so the adapter can clear it when it is unloaded
452
+ *
453
+ * @param callback Called when the time has come
454
+ * @param ms The delay
455
+ * @returns the handle to stop it
456
+ */
457
+ startTimeout(callback, ms) {
458
+ return this.host.setTimeout ? this.host.setTimeout(callback, ms) : globalThis.setTimeout(callback, ms);
459
+ }
460
+ /**
461
+ * @param handle The handle of a timer started by startTimeout(), may be undefined
462
+ */
463
+ stopTimeout(handle) {
464
+ if (handle === void 0) {
465
+ return;
466
+ }
467
+ if (this.host.clearTimeout) {
468
+ this.host.clearTimeout(handle);
469
+ } else {
470
+ globalThis.clearTimeout(handle);
471
+ }
472
+ }
473
+ /**
474
+ * @param timer The timer to stop, may be undefined
475
+ */
476
+ cancelTimer(timer) {
477
+ if (timer) {
478
+ this.stopTimeout(timer.handle);
479
+ this.scheduled.delete(timer);
325
480
  }
326
481
  }
482
+ /**
483
+ * Wakes up the chart for a timer. A paused simulation queues the wakeup.
484
+ *
485
+ * @param fired ID of the at() or every() transition whose timer fired, they are only valid in this wakeup
486
+ */
487
+ timerWakeup(fired) {
488
+ if (this.simulated && this.paused) {
489
+ this.queue.push({ kind: "timer", fired });
490
+ this.publishLive();
491
+ return;
492
+ }
493
+ if (fired) {
494
+ this.firedTimers.add(fired);
495
+ }
496
+ try {
497
+ this.wakeup(null);
498
+ } finally {
499
+ if (fired) {
500
+ this.firedTimers.delete(fired);
501
+ }
502
+ }
503
+ }
504
+ /**
505
+ * Calls the callback at the given time, also after delays which are too long for setTimeout()
506
+ *
507
+ * @param due The time (ms since 1970), the callback is called right away if it has passed
508
+ * @param timer Gets the handle of the timeout, to clear it
509
+ * @param callback Called at the time
510
+ */
511
+ scheduleAt(due, timer, callback) {
512
+ this.stopTimeout(timer.handle);
513
+ timer.due = due;
514
+ timer.callback = callback;
515
+ this.scheduled.add(timer);
516
+ const remaining = due - this.now();
517
+ timer.handle = this.startTimeout(
518
+ () => {
519
+ if (remaining > MAX_TIMEOUT_MS && due - this.now() > 0) {
520
+ this.scheduleAt(due, timer, callback);
521
+ } else {
522
+ this.scheduled.delete(timer);
523
+ callback();
524
+ }
525
+ },
526
+ Math.min(Math.max(remaining, 0), MAX_TIMEOUT_MS)
527
+ );
528
+ }
327
529
  /**
328
530
  * Starts a timer for every temporal transition of a state, which wakes up the chart when its time has come
329
531
  *
@@ -332,27 +534,95 @@ class ChartRuntime {
332
534
  startTimers(id) {
333
535
  const entered = this.enteredAt.get(id) ?? this.now();
334
536
  const timers = [];
537
+ this.timers.set(id, timers);
335
538
  for (const transition of this.temporalTransitions.get(id) || []) {
336
- const timer = {};
337
- const due = entered + transition.afterMs;
338
- const wait = () => {
339
- const remaining = due - this.now();
340
- timer.handle = setTimeout(
341
- () => {
342
- if (due - this.now() > 0 && remaining > MAX_TIMEOUT_MS) {
343
- wait();
344
- return;
345
- }
346
- this.expired.add(transition.id);
347
- this.wakeup(null);
348
- },
349
- Math.min(Math.max(remaining, 0), MAX_TIMEOUT_MS)
350
- );
351
- };
352
- wait();
539
+ const { operator, ms, condition, time } = transition.temporal;
540
+ if (time !== void 0) {
541
+ const timer2 = { label: transition.where };
542
+ timers.push(timer2);
543
+ this.scheduleDaily(id, transition, timer2, this.now());
544
+ continue;
545
+ }
546
+ if (ms === void 0 || operator === "before" || condition !== void 0) {
547
+ continue;
548
+ }
549
+ const timer = { label: transition.where };
353
550
  timers.push(timer);
551
+ const fire = (due2) => {
552
+ if (operator === "after") {
553
+ this.expired.add(transition.id);
554
+ this.timerWakeup(null);
555
+ return;
556
+ }
557
+ this.timerWakeup(transition.id);
558
+ if (operator === "every" && this.timers.get(id)?.includes(timer)) {
559
+ this.scheduleAt(due2 + ms, timer, () => fire(due2 + ms));
560
+ }
561
+ };
562
+ let due = entered + ms;
563
+ if (operator === "every" && due < this.now()) {
564
+ due = entered + Math.ceil((this.now() - entered) / ms) * ms;
565
+ }
566
+ this.scheduleAt(due, timer, () => fire(due));
354
567
  }
355
- this.timers.set(id, timers);
568
+ }
569
+ /**
570
+ * Starts the timer of at("sunset", offset) or at("22:30") for the next time, which wakes up the chart
571
+ *
572
+ * @param stateId ID of the source state
573
+ * @param transition The transition
574
+ * @param timer Gets the handle of the timeout
575
+ * @param after The timer is started for the next time after this one (ms since 1970)
576
+ */
577
+ scheduleDaily(stateId, transition, timer, after) {
578
+ let due;
579
+ try {
580
+ due = this.getNextDailyTime(transition.temporal, after);
581
+ } catch (err) {
582
+ this.host.log.warn(`[${this.chart.name}] ${transition.where}: ${err.message}`);
583
+ return;
584
+ }
585
+ if (due === null) {
586
+ this.scheduleAt(
587
+ after + DAY_MS,
588
+ timer,
589
+ () => this.scheduleDaily(stateId, transition, timer, after + DAY_MS)
590
+ );
591
+ return;
592
+ }
593
+ this.scheduleAt(due, timer, () => {
594
+ this.timerWakeup(transition.id);
595
+ if (this.timers.get(stateId)?.includes(timer)) {
596
+ this.scheduleDaily(stateId, transition, timer, Math.max(due, this.now()));
597
+ }
598
+ });
599
+ }
600
+ /**
601
+ * @param temporal at("sunset", offset) or at("22:30")
602
+ * @param after The time is searched after this one (ms since 1970)
603
+ * @returns the next time (ms since 1970), null if there is none in the next week (astro events in polar regions)
604
+ * @throws {Error} for astro events if no location is configured
605
+ */
606
+ getNextDailyTime(temporal, after) {
607
+ const time = temporal.time;
608
+ const offset = (temporal.offsetMinutes ?? 0) * 6e4;
609
+ const day = new Date(after);
610
+ day.setDate(day.getDate() - 1);
611
+ for (let i = 0; i < 9; i++) {
612
+ let at;
613
+ if (import_chart_model.TIME_OF_DAY.test(time)) {
614
+ const [hours, minutes, seconds = 0] = time.split(":").map(Number);
615
+ at = new Date(day).setHours(hours, minutes, seconds, 0);
616
+ } else {
617
+ const event = this.getSunTimes(new Date(new Date(day).setHours(12, 0, 0, 0)))[time];
618
+ at = event instanceof Date ? event.getTime() : null;
619
+ }
620
+ if (at !== null && at + offset > after) {
621
+ return at + offset;
622
+ }
623
+ day.setDate(day.getDate() + 1);
624
+ }
625
+ return null;
356
626
  }
357
627
  /**
358
628
  * Logs the problems found by the validation, the chart is started anyway
@@ -361,7 +631,9 @@ class ChartRuntime {
361
631
  for (const { kind, id, field, diagnostic } of (0, import_chart_validation.validateChart)(this.chart)) {
362
632
  let where;
363
633
  if (kind === "state") {
364
- where = `${this.statesById.get(id)?.name ?? id}/${field}`;
634
+ where = `${this.statesById.get(id)?.name ?? id}${field ? `/${field}` : ""}`;
635
+ } else if (kind === "symbol") {
636
+ where = `Variable ${id}`;
365
637
  } else if (kind === "function") {
366
638
  const fn = this.chart.functions?.find((f) => f.id === id);
367
639
  where = `Function block ${(0, import_chart_validation.getDeclarations)(fn?.code).map((d) => d.name).join(", ")}`;
@@ -371,27 +643,27 @@ class ChartRuntime {
371
643
  const target = this.statesById.get(transition?.target ?? "")?.name;
372
644
  where = `Transition ${source} -> ${target}`;
373
645
  }
374
- const log = diagnostic.severity === "error" ? this.host.log.error : this.host.log.warn;
646
+ const log = { error: this.host.log.error, warning: this.host.log.warn, info: this.host.log.debug }[diagnostic.severity];
375
647
  log(`[${this.chart.name}] ${where}: ${diagnostic.message}`);
376
648
  }
377
649
  }
650
+ /**
651
+ * Stops the state machine and all timers, delayed writes are discarded
652
+ */
378
653
  stop() {
379
654
  this.actor?.stop();
380
655
  this.actor = null;
381
- for (const timers of this.timers.values()) {
382
- for (const timer of timers) {
383
- clearTimeout(timer.handle);
384
- }
656
+ for (const timer of this.scheduled) {
657
+ this.stopTimeout(timer.handle);
385
658
  }
659
+ this.scheduled.clear();
386
660
  this.timers.clear();
387
661
  this.expired.clear();
662
+ this.durationTimers.clear();
388
663
  for (const timer of this.debounceTimers.values()) {
389
- clearTimeout(timer);
664
+ this.stopTimeout(timer);
390
665
  }
391
666
  this.debounceTimers.clear();
392
- for (const timer of this.delayedWrites) {
393
- clearTimeout(timer);
394
- }
395
667
  this.delayedWrites.clear();
396
668
  }
397
669
  /**
@@ -400,25 +672,36 @@ class ChartRuntime {
400
672
  createHelpers() {
401
673
  const prefix = `[${this.chart.name}]`;
402
674
  const write = (id, value, ack) => this.host.writeState(String(id), value, !!ack).catch((err) => this.host.log.warn(`${prefix} Cannot write ${id}: ${err}`));
675
+ const describe = (name, args) => `${name}(${args.map((arg) => arg === void 0 ? "undefined" : JSON.stringify(arg)).join(", ")})`;
403
676
  return {
404
677
  log: (message, level = "info") => {
405
678
  const method = ["debug", "info", "warn", "error"].includes(level) ? level : "info";
406
679
  this.host.log[method](`${prefix} ${message}`);
407
680
  },
408
681
  setState: (id, value, ack = false) => {
682
+ if (this.simulated) {
683
+ this.recordEffect(describe("setState", [id, value, ...ack ? [true] : []]));
684
+ return;
685
+ }
409
686
  void write(id, value, ack);
410
687
  },
411
688
  setStateDelayed: (id, value, delayMs, ack = false) => {
412
- const timer = setTimeout(
413
- () => {
414
- this.delayedWrites.delete(timer);
415
- void write(id, value, ack);
416
- },
417
- Math.min(Math.max(Number(delayMs) || 0, 0), MAX_TIMEOUT_MS)
418
- );
689
+ const timer = { label: describe("setStateDelayed", [id, value]) };
419
690
  this.delayedWrites.add(timer);
691
+ this.scheduleAt(this.now() + Math.max(Number(delayMs) || 0, 0), timer, () => {
692
+ this.delayedWrites.delete(timer);
693
+ if (this.simulated) {
694
+ this.recordEffect(describe("setState", [id, value, ...ack ? [true] : []]));
695
+ return;
696
+ }
697
+ void write(id, value, ack);
698
+ });
420
699
  },
421
700
  sendTo: (instance, command, message) => {
701
+ if (this.simulated) {
702
+ this.recordEffect(describe("sendTo", [instance, command, message]));
703
+ return;
704
+ }
422
705
  if (!this.host.sendTo) {
423
706
  throw new Error("sendTo() is not available");
424
707
  }
@@ -444,6 +727,33 @@ class ChartRuntime {
444
727
  }
445
728
  return new Date(time.getTime() + (Number(offsetMinutes) || 0) * 6e4);
446
729
  },
730
+ getState: (id) => {
731
+ if (!this.stateCache.has(id)) {
732
+ throw new Error(
733
+ `getState() can only read states whose ID is written as text, e.g. getState("${id}")`
734
+ );
735
+ }
736
+ const state = this.stateCache.get(id);
737
+ return state ? { ...state } : null;
738
+ },
739
+ elapsed: (unit = import_chart_model.TIME_UNITS.sec) => {
740
+ const entered = this.context === null ? void 0 : this.enteredAt.get(this.context);
741
+ if (entered === void 0) {
742
+ throw new Error("elapsed() can only be used in conditions and in the actions of states");
743
+ }
744
+ return (this.now() - entered) / unit;
745
+ },
746
+ // the ID of the call is added by prepareHelperCalls()
747
+ duration: (call, value, unit = import_chart_model.TIME_UNITS.sec) => {
748
+ const key = `${call}@${this.context}`;
749
+ if (!value) {
750
+ this.durationCalls.delete(key);
751
+ return 0;
752
+ }
753
+ const since = this.durationCalls.get(key)?.since ?? this.now();
754
+ this.durationCalls.set(key, { since, state: this.context });
755
+ return (this.now() - since) / unit;
756
+ },
447
757
  hasChanged: (name) => this.getChange(name, "hasChanged").changed,
448
758
  hasChangedFrom: (name, value) => {
449
759
  const { changed, previous } = this.getChange(name, "hasChangedFrom");
@@ -467,6 +777,163 @@ class ChartRuntime {
467
777
  }
468
778
  return import_suncalc.default.getTimes(date, location.latitude, location.longitude);
469
779
  }
780
+ /**
781
+ * Must be called when one of the subscribedStateIds is updated
782
+ *
783
+ * @param stateId ID of the updated state
784
+ * @param state The new state
785
+ */
786
+ handleStateChange(stateId, state) {
787
+ if (this.readStateIds.has(stateId)) {
788
+ this.stateCache.set(stateId, toSnapshot(state));
789
+ }
790
+ if (!this.simulated) {
791
+ this.handleInput(stateId, state.val, { ack: state.ack });
792
+ }
793
+ }
794
+ /**
795
+ * Simulation: sets the value of an input and wakes up the chart, like a change of the input
796
+ *
797
+ * @param name Name of the input
798
+ * @param val The new value
799
+ * @returns false if the chart has no input with this name
800
+ */
801
+ simulateInput(name, val) {
802
+ const symbol = this.chart.symbols.find((s) => s.scope === "input" && s.name === name);
803
+ if (!symbol || !this.actor) {
804
+ return false;
805
+ }
806
+ if (this.paused) {
807
+ this.queue.push({ kind: "input", name, value: val });
808
+ this.publishLive();
809
+ return true;
810
+ }
811
+ this.applyInput(symbol, val);
812
+ return true;
813
+ }
814
+ /**
815
+ * Simulation: pauses, the following wakeups wait in a queue
816
+ */
817
+ pauseSimulation() {
818
+ this.paused = true;
819
+ this.publishLive();
820
+ }
821
+ /**
822
+ * Simulation: processes the queued wakeups until a breakpoint is reached, then continues normally
823
+ */
824
+ resumeSimulation() {
825
+ this.paused = false;
826
+ this.breakpointHit = null;
827
+ while (!this.paused && this.queue.length) {
828
+ this.processQueued(this.queue.shift());
829
+ }
830
+ this.publishLive();
831
+ }
832
+ /**
833
+ * Simulation: processes the next queued wakeup, and stays paused
834
+ *
835
+ * @returns false if no wakeup was queued
836
+ */
837
+ stepSimulation() {
838
+ const next = this.queue.shift();
839
+ if (!next) {
840
+ return false;
841
+ }
842
+ this.breakpointHit = null;
843
+ this.paused = false;
844
+ try {
845
+ this.processQueued(next);
846
+ } finally {
847
+ this.paused = true;
848
+ }
849
+ this.publishLive();
850
+ return true;
851
+ }
852
+ /**
853
+ * @param wakeup A wakeup which waited while the simulation was paused
854
+ */
855
+ processQueued(wakeup) {
856
+ if (wakeup.kind === "input") {
857
+ const symbol = this.chart.symbols.find((s) => s.scope === "input" && s.name === wakeup.name);
858
+ if (symbol) {
859
+ this.applyInput(symbol, wakeup.value);
860
+ }
861
+ } else if (wakeup.kind === "event") {
862
+ this.wakeup(wakeup.name);
863
+ } else if (wakeup.kind === "timer") {
864
+ this.timerWakeup(wakeup.fired);
865
+ } else {
866
+ this.wakeup(null);
867
+ }
868
+ }
869
+ /**
870
+ * Simulation: lets time pass. The timers which are due meanwhile fire in the order of their time, a paused
871
+ * simulation queues their wakeups.
872
+ *
873
+ * @param ms The time to advance
874
+ */
875
+ advanceTime(ms) {
876
+ const target = this.now() + Math.max(0, ms);
877
+ for (let fired = 0; ; fired++) {
878
+ let next;
879
+ for (const timer of this.scheduled) {
880
+ if (timer.due <= target && (!next || timer.due < next.due)) {
881
+ next = timer;
882
+ }
883
+ }
884
+ if (!next) {
885
+ break;
886
+ }
887
+ if (fired >= MAX_ADVANCE_TIMERS) {
888
+ this.host.log.warn(`[${this.chart.name}] Stopped advancing the time after ${fired} timers`);
889
+ return;
890
+ }
891
+ this.timeOffset += Math.max(0, next.due - this.now());
892
+ this.cancelTimer(next);
893
+ next.callback();
894
+ }
895
+ this.timeOffset += Math.max(0, target - this.now());
896
+ for (const timer of [...this.scheduled]) {
897
+ this.scheduleAt(timer.due, timer, timer.callback);
898
+ }
899
+ this.publishLive();
900
+ }
901
+ /**
902
+ * Simulation: advances the time to the next timer, see advanceTime()
903
+ *
904
+ * @returns false if there is no timer
905
+ */
906
+ advanceToNextTimer() {
907
+ const next = this.getNextTimer();
908
+ if (!next) {
909
+ return false;
910
+ }
911
+ this.advanceTime(next.due - this.now());
912
+ return true;
913
+ }
914
+ /**
915
+ * @returns the timer which fires next, null if there is none
916
+ */
917
+ getNextTimer() {
918
+ let next;
919
+ for (const timer of this.scheduled) {
920
+ if (!next || timer.due < next.due) {
921
+ next = timer;
922
+ }
923
+ }
924
+ return next ? { due: next.due, label: next.label ?? "" } : null;
925
+ }
926
+ /**
927
+ * Simulation: records something which was not executed, shown in the history
928
+ *
929
+ * @param text What was not executed
930
+ */
931
+ recordEffect(text) {
932
+ this.pendingHistory.push({ ts: this.now(), kind: "effect", text, active: this.activeNames });
933
+ if (!this.wakingUp) {
934
+ this.publishLive();
935
+ }
936
+ }
470
937
  /**
471
938
  * Must be called when an input state is updated
472
939
  *
@@ -488,10 +955,10 @@ class ChartRuntime {
488
955
  }
489
956
  const debounceMs = symbol.debounceMs || 0;
490
957
  if (debounceMs > 0) {
491
- clearTimeout(this.debounceTimers.get(symbol.name));
958
+ this.stopTimeout(this.debounceTimers.get(symbol.name));
492
959
  this.debounceTimers.set(
493
960
  symbol.name,
494
- setTimeout(() => {
961
+ this.startTimeout(() => {
495
962
  this.debounceTimers.delete(symbol.name);
496
963
  this.applyInput(symbol, val);
497
964
  }, debounceMs)
@@ -501,6 +968,25 @@ class ChartRuntime {
501
968
  }
502
969
  }
503
970
  }
971
+ /**
972
+ * Wakes up the chart with the event of an input, without changing the value of the input
973
+ * (state charts.<chart>.event)
974
+ *
975
+ * @param name Name of the input
976
+ * @returns false if the chart has no input with this name
977
+ */
978
+ handleEvent(name) {
979
+ if (!this.chart.symbols.some((s) => s.scope === "input" && s.name === name)) {
980
+ return false;
981
+ }
982
+ if (this.simulated && this.paused) {
983
+ this.queue.push({ kind: "event", name });
984
+ this.publishLive();
985
+ } else {
986
+ this.wakeup(name);
987
+ }
988
+ return true;
989
+ }
504
990
  /**
505
991
  * Sets the variable of an input and wakes up the chart
506
992
  *
@@ -524,7 +1010,40 @@ class ChartRuntime {
524
1010
  }
525
1011
  this.wakeupCause = cause;
526
1012
  this.wakeupInput = input;
1013
+ this.wakingUp = true;
1014
+ try {
1015
+ this.processWakeup(input);
1016
+ this.writeChangedOutputs();
1017
+ } finally {
1018
+ this.wakingUp = false;
1019
+ }
1020
+ if (this.pendingBreak) {
1021
+ this.paused = true;
1022
+ this.breakpointHit = this.pendingBreak;
1023
+ this.pendingHistory.push({
1024
+ ts: this.now(),
1025
+ kind: "break",
1026
+ text: this.pendingBreak,
1027
+ active: this.activeNames
1028
+ });
1029
+ this.pendingBreak = null;
1030
+ }
1031
+ this.persist();
1032
+ this.publishLive();
1033
+ }
1034
+ /**
1035
+ * @param input Name of the input which caused the wakeup, null for timers and the start
1036
+ */
1037
+ processWakeup(input) {
527
1038
  this.rememberValues();
1039
+ if (input !== null) {
1040
+ for (const [id, counts] of this.inputCounts) {
1041
+ if (this.temporalTransitions.get(id)?.some((t) => t.temporal?.event === input)) {
1042
+ counts.set(input, (counts.get(input) ?? 0) + 1);
1043
+ }
1044
+ }
1045
+ }
1046
+ this.updateDurations();
528
1047
  if (!this.chart.superStep) {
529
1048
  this.step(input);
530
1049
  } else {
@@ -533,6 +1052,10 @@ class ChartRuntime {
533
1052
  while (steps < import_chart_model.MAX_SUPER_STEPS && this.step(stepInput)) {
534
1053
  steps++;
535
1054
  stepInput = null;
1055
+ if (this.pendingBreak) {
1056
+ this.queue.unshift({ kind: "continue" });
1057
+ break;
1058
+ }
536
1059
  }
537
1060
  if (steps >= import_chart_model.MAX_SUPER_STEPS) {
538
1061
  this.host.log.warn(
@@ -540,9 +1063,35 @@ class ChartRuntime {
540
1063
  );
541
1064
  }
542
1065
  }
543
- this.writeChangedOutputs();
544
- this.persist();
545
- this.publishLive();
1066
+ this.updateDurations();
1067
+ }
1068
+ /**
1069
+ * Checks the conditions of after(n, unit, condition) of the active states, and starts a timer which wakes up the
1070
+ * chart when a condition has been true for the time
1071
+ */
1072
+ updateDurations() {
1073
+ for (const id of this.activeIds) {
1074
+ for (const transition of this.temporalTransitions.get(id) || []) {
1075
+ const temporal = transition.temporal;
1076
+ if (temporal.condition === void 0) {
1077
+ continue;
1078
+ }
1079
+ if (!this.run(transition.temporalCondition, `${transition.where}/after condition`, id)) {
1080
+ this.stopDuration(transition.id);
1081
+ continue;
1082
+ }
1083
+ const since = this.trueSince.get(transition.id) ?? this.now();
1084
+ this.trueSince.set(transition.id, since);
1085
+ if (!this.durationTimers.has(transition.id)) {
1086
+ const timer = { label: transition.where };
1087
+ this.durationTimers.set(transition.id, timer);
1088
+ this.scheduleAt(since + temporal.ms, timer, () => {
1089
+ this.durationTimers.delete(transition.id);
1090
+ this.timerWakeup(null);
1091
+ });
1092
+ }
1093
+ }
1094
+ }
546
1095
  }
547
1096
  /**
548
1097
  * Stores the values of the variables used by the change detection at the start of a wakeup
@@ -588,22 +1137,31 @@ class ChartRuntime {
588
1137
  * @returns true if a transition was taken
589
1138
  */
590
1139
  step(input) {
591
- for (const id of this.getActiveChildren(null)) {
592
- if (this.processState(id, input)) {
593
- return true;
594
- }
595
- }
596
- return false;
1140
+ return this.processChildren(null, input);
597
1141
  }
598
1142
  /**
599
1143
  * Passes the events since the last call and the changed values of the variables to the host
600
1144
  */
601
1145
  publishLive() {
602
1146
  if (this.pendingHistory.length) {
603
- const entries = this.pendingHistory;
1147
+ const entries = this.simulated ? this.pendingHistory.map((entry) => ({ ...entry, simulated: true })) : this.pendingHistory;
604
1148
  this.pendingHistory = [];
605
1149
  this.host.onHistory?.(entries);
606
1150
  }
1151
+ if (this.simulated && this.host.onSimulationStatus) {
1152
+ const status = {
1153
+ paused: this.paused,
1154
+ timeOffset: this.timeOffset,
1155
+ queued: this.queue.length,
1156
+ nextTimer: this.getNextTimer(),
1157
+ breakpoint: this.breakpointHit
1158
+ };
1159
+ const json2 = JSON.stringify(status);
1160
+ if (json2 !== this.lastStatus) {
1161
+ this.lastStatus = json2;
1162
+ this.host.onSimulationStatus(status);
1163
+ }
1164
+ }
607
1165
  if (!this.host.onValues) {
608
1166
  return;
609
1167
  }
@@ -639,17 +1197,40 @@ class ChartRuntime {
639
1197
  }
640
1198
  const state = this.statesById.get(id);
641
1199
  if (state?.during) {
642
- this.runAction(state.during, `${state.name}/during`);
1200
+ this.runAction(state.during, `${state.name}/during`, id);
643
1201
  }
644
1202
  if (this.takeFirstValid(this.innerTransitions.get(id), input)) {
645
1203
  return true;
646
1204
  }
647
- for (const child of this.getActiveChildren(id)) {
648
- if (this.processState(child, input)) {
649
- return true;
1205
+ return this.processChildren(id, input);
1206
+ }
1207
+ /**
1208
+ * Processes the active child of a state, or all children of a parallel state, see the class description
1209
+ *
1210
+ * @param parent ID of the state, null for the top level
1211
+ * @param input Name of the input which caused the wakeup, null for timers
1212
+ * @returns true if a transition was taken
1213
+ */
1214
+ processChildren(parent, input) {
1215
+ if (!this.structure.isParallel(parent)) {
1216
+ for (const child of this.getActiveChildren(parent)) {
1217
+ if (this.processState(child, input)) {
1218
+ return true;
1219
+ }
650
1220
  }
1221
+ return false;
651
1222
  }
652
- return false;
1223
+ const entries = parent && this.entryCount.get(parent);
1224
+ let taken = false;
1225
+ for (const child of this.structure.getChildren(parent)) {
1226
+ if (parent && (!this.activeIds.includes(parent) || this.entryCount.get(parent) !== entries)) {
1227
+ break;
1228
+ }
1229
+ if (this.activeIds.includes(child.id) && this.processState(child.id, input)) {
1230
+ taken = true;
1231
+ }
1232
+ }
1233
+ return taken;
653
1234
  }
654
1235
  /**
655
1236
  * @param parent null for the top level
@@ -667,17 +1248,18 @@ class ChartRuntime {
667
1248
  */
668
1249
  takeFirstValid(transitions, input) {
669
1250
  for (const transition of transitions || []) {
670
- if (transition.event && transition.event !== input) {
1251
+ if (!this.isTriggered(transition, input)) {
671
1252
  continue;
672
1253
  }
673
- if (transition.afterMs !== void 0 && !this.isExpired(transition)) {
1254
+ if (transition.condition && !this.run(transition.condition, `${transition.where}/condition`, transition.source)) {
674
1255
  continue;
675
1256
  }
676
- if (transition.condition && !this.run(transition.condition, `${transition.where}/condition`)) {
677
- continue;
1257
+ const at = this.pendingHistory.length;
1258
+ if (this.simulated && this.breakpoints.has(transition.id)) {
1259
+ this.pendingBreak ??= `${transition.from} \u2192 ${transition.to}`;
678
1260
  }
679
1261
  this.actor?.send({ type: `T.${transition.id}` });
680
- this.pendingHistory.push({
1262
+ this.pendingHistory.splice(at, 0, {
681
1263
  ts: this.now(),
682
1264
  kind: "transition",
683
1265
  transition: transition.id,
@@ -693,16 +1275,54 @@ class ChartRuntime {
693
1275
  return false;
694
1276
  }
695
1277
  /**
696
- * @param transition A temporal transition
697
- * @returns true if its source state is active for at least its delay
1278
+ * @param transition The transition
1279
+ * @param input Name of the input which caused the wakeup, null for timers
1280
+ * @returns true if its event and its temporal expression allow it in this wakeup (the condition is not checked)
698
1281
  */
699
- isExpired(transition) {
700
- if (this.expired.has(transition.id)) {
1282
+ isTriggered(transition, input) {
1283
+ if (transition.event && transition.event !== input) {
1284
+ return false;
1285
+ }
1286
+ const temporal = transition.temporal;
1287
+ if (!temporal) {
701
1288
  return true;
702
1289
  }
1290
+ if (temporal.event !== void 0) {
1291
+ if (temporal.event !== input) {
1292
+ return false;
1293
+ }
1294
+ const count = this.inputCounts.get(transition.source)?.get(temporal.event) ?? 0;
1295
+ const n = temporal.count;
1296
+ switch (temporal.operator) {
1297
+ case "after":
1298
+ return count >= n;
1299
+ case "before":
1300
+ return count < n;
1301
+ case "at":
1302
+ return count === n;
1303
+ case "every":
1304
+ return count % n === 0;
1305
+ }
1306
+ }
1307
+ const ms = temporal.ms;
1308
+ if (temporal.condition !== void 0) {
1309
+ const since = this.trueSince.get(transition.id);
1310
+ return since !== void 0 && this.now() - since >= ms;
1311
+ }
703
1312
  const entered = this.enteredAt.get(transition.source);
704
- return entered !== void 0 && this.now() - entered >= transition.afterMs;
1313
+ switch (temporal.operator) {
1314
+ case "after":
1315
+ return this.expired.has(transition.id) || entered !== void 0 && this.now() - entered >= ms;
1316
+ case "before":
1317
+ return entered !== void 0 && this.now() - entered < ms;
1318
+ default:
1319
+ return this.firedTimers.has(transition.id);
1320
+ }
705
1321
  }
1322
+ /**
1323
+ * @returns the configuration of the XState machine: the states with their entry and exit actions, the
1324
+ * transitions as guarded events
1325
+ */
706
1326
  buildMachineConfig() {
707
1327
  const structure = this.structure;
708
1328
  const buildState = (state) => {
@@ -710,10 +1330,10 @@ class ChartRuntime {
710
1330
  id: state.id,
711
1331
  entry: [
712
1332
  () => this.onEnter(state.id),
713
- ...state.entry ? [() => this.runAction(state.entry, `${state.name}/entry`)] : []
1333
+ ...state.entry ? [() => this.runAction(state.entry, `${state.name}/entry`, state.id)] : []
714
1334
  ],
715
1335
  exit: [
716
- ...state.exit ? [() => this.runAction(state.exit, `${state.name}/exit`)] : [],
1336
+ ...state.exit ? [() => this.runAction(state.exit, `${state.name}/exit`, state.id)] : [],
717
1337
  () => this.onExit(state.id)
718
1338
  ],
719
1339
  on: {}
@@ -732,12 +1352,19 @@ class ChartRuntime {
732
1352
  this.innerTransitions.set(state.id, inner);
733
1353
  this.temporalTransitions.set(
734
1354
  state.id,
735
- [...outer, ...inner].filter((t) => t.afterMs !== void 0)
1355
+ [...outer, ...inner].filter((t) => t.temporal !== void 0)
736
1356
  );
737
1357
  const subStates = structure.getChildren(state.id);
738
1358
  if (subStates.length) {
739
- node.initial = structure.getInitialChild(state.id);
1359
+ if (structure.isParallel(state.id)) {
1360
+ node.type = "parallel";
1361
+ } else {
1362
+ node.initial = structure.getInitialChild(state.id);
1363
+ }
740
1364
  node.states = Object.fromEntries(subStates.map((s) => [s.id, buildState(s)]));
1365
+ if (structure.hasHistory(state.id)) {
1366
+ node.states[import_chart_structure.HISTORY_SUFFIX] = { id: `${state.id}${import_chart_structure.HISTORY_SUFFIX}`, type: "history" };
1367
+ }
741
1368
  }
742
1369
  return node;
743
1370
  };
@@ -747,7 +1374,7 @@ class ChartRuntime {
747
1374
  }
748
1375
  return {
749
1376
  id: `chart_${this.chartId}`,
750
- initial: structure.getInitialChild(null),
1377
+ ...structure.isParallel(null) ? { type: "parallel" } : { initial: structure.getInitialChild(null) },
751
1378
  states: Object.fromEntries(topLevel.map((s) => [s.id, buildState(s)]))
752
1379
  };
753
1380
  }
@@ -762,6 +1389,9 @@ class ChartRuntime {
762
1389
  this.host.log.warn(`[${this.chart.name}] Transition ${where}: unknown target, ignored`);
763
1390
  return null;
764
1391
  }
1392
+ if (this.structure.getCrossedParallel(transition) !== void 0) {
1393
+ return null;
1394
+ }
765
1395
  let label;
766
1396
  try {
767
1397
  label = (0, import_chart_model.parseTransitionLabel)(transition.label);
@@ -770,11 +1400,12 @@ class ChartRuntime {
770
1400
  return null;
771
1401
  }
772
1402
  const condition = label.condition ? this.compile(`(${this.prepareCode(label.condition, "expression")})`, `${where}/condition`) : null;
1403
+ const temporalCondition = label.temporal?.condition ? this.compile(`(${this.prepareCode(label.temporal.condition, "expression")})`, `${where}/after condition`) : null;
773
1404
  const { inner, reenter } = this.structure.getTransitionKind(transition);
774
1405
  const config = {
775
- target: `#${target.id}`,
1406
+ target: `#${this.structure.getTargetId(transition)}`,
776
1407
  reenter,
777
- actions: label.action ? [() => this.runAction(label.action, `${where}/action`)] : []
1408
+ actions: label.action ? [() => this.runAction(label.action, `${where}/action`, source.id)] : []
778
1409
  };
779
1410
  return {
780
1411
  inner,
@@ -786,7 +1417,8 @@ class ChartRuntime {
786
1417
  from: source.name,
787
1418
  to: target.name,
788
1419
  event: label.event,
789
- afterMs: label.afterMs,
1420
+ temporal: label.temporal,
1421
+ temporalCondition,
790
1422
  condition
791
1423
  }
792
1424
  };
@@ -806,16 +1438,21 @@ class ChartRuntime {
806
1438
  /**
807
1439
  * @param script The compiled code
808
1440
  * @param where Used in error messages
1441
+ * @param context The state whose condition or action it is, for elapsed() and duration()
809
1442
  */
810
- run(script, where) {
1443
+ run(script, where, context = this.context) {
811
1444
  if (!script) {
812
1445
  return void 0;
813
1446
  }
1447
+ const previous = this.context;
1448
+ this.context = context;
814
1449
  try {
815
1450
  return script.runInContext(this.sandbox, { timeout: SCRIPT_TIMEOUT_MS });
816
1451
  } catch (err) {
817
1452
  this.host.log.warn(`[${this.chart.name}] ${where}: ${err.message}`);
818
1453
  return void 0;
1454
+ } finally {
1455
+ this.context = previous;
819
1456
  }
820
1457
  }
821
1458
  /**
@@ -823,13 +1460,17 @@ class ChartRuntime {
823
1460
  *
824
1461
  * @param code The action
825
1462
  * @param where Used in error messages
1463
+ * @param context The state whose action it is (the source state for transition actions)
826
1464
  */
827
- runAction(code, where) {
1465
+ runAction(code, where, context) {
828
1466
  this.run(this.compile(`{
829
1467
  ${this.prepareCode(code, "statements")}
830
- }`, where), where);
1468
+ }`, where), where, context);
831
1469
  this.writeChangedOutputs();
832
1470
  }
1471
+ /**
1472
+ * Writes the outputs whose values were changed by actions to their states, in a simulation only records them
1473
+ */
833
1474
  writeChangedOutputs() {
834
1475
  for (const symbol of this.chart.symbols) {
835
1476
  if (symbol.scope !== "output" || !symbol.stateId) {
@@ -838,6 +1479,10 @@ ${this.prepareCode(code, "statements")}
838
1479
  const val = this.sandbox[symbol.name];
839
1480
  if (val !== this.outputValues.get(symbol.name)) {
840
1481
  this.outputValues.set(symbol.name, val);
1482
+ if (this.simulated) {
1483
+ this.recordEffect(`${symbol.name} = ${val === void 0 ? "undefined" : JSON.stringify(val)}`);
1484
+ continue;
1485
+ }
841
1486
  this.host.writeState(symbol.stateId, val).catch((err) => this.host.log.warn(`[${this.chart.name}] Cannot write ${symbol.stateId}: ${err}`));
842
1487
  }
843
1488
  }