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-codegen.js
DELETED
|
@@ -1,436 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
|
|
3
|
-
/*
|
|
4
|
-
* Generates readable JavaScript from a chart. It shows how the runtime executes the chart:
|
|
5
|
-
* the XState machine, and the functions which select the transitions in Stateflow order.
|
|
6
|
-
* The code is only shown to the user, the runtime does not execute it.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
const { HELPERS } = require("./chart-helpers");
|
|
10
|
-
const { parseTransitionLabel } = require("./chart-model");
|
|
11
|
-
const { ChartStructure } = require("./chart-structure");
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* @typedef {import("./chart-model").Chart} Chart
|
|
15
|
-
* @typedef {import("./chart-model").ChartState} ChartState
|
|
16
|
-
* @typedef {import("./chart-model").ChartTransition} ChartTransition
|
|
17
|
-
* @typedef {import("./chart-model").TransitionLabel} TransitionLabel
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
const INDENT = " ";
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* @param {string} text
|
|
24
|
-
* @param {number} level
|
|
25
|
-
* @returns {string}
|
|
26
|
-
*/
|
|
27
|
-
function indent(text, level) {
|
|
28
|
-
const prefix = INDENT.repeat(level);
|
|
29
|
-
return text
|
|
30
|
-
.split("\n")
|
|
31
|
-
.map(line => (line.trim() ? prefix + line : ""))
|
|
32
|
-
.join("\n");
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Text for a // comment, on one line
|
|
37
|
-
*
|
|
38
|
-
* @param {string | undefined} text
|
|
39
|
-
* @returns {string}
|
|
40
|
-
*/
|
|
41
|
-
function oneLine(text) {
|
|
42
|
-
return (text || "").replace(/\s+/g, " ").trim();
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* A comment or note as // lines
|
|
47
|
-
*
|
|
48
|
-
* @param {string | undefined} text
|
|
49
|
-
* @param {string} prefix Indentation and optional text before the first line, e.g. "Note: "
|
|
50
|
-
* @param {string} [indentation]
|
|
51
|
-
* @returns {string[]} no lines for an empty text
|
|
52
|
-
*/
|
|
53
|
-
function commentLines(text, prefix = "", indentation = "") {
|
|
54
|
-
if (!text?.trim()) {
|
|
55
|
-
return [];
|
|
56
|
-
}
|
|
57
|
-
return text
|
|
58
|
-
.trim()
|
|
59
|
-
.split("\n")
|
|
60
|
-
.map((line, i) => `${indentation}// ${i ? " ".repeat(prefix.length) : prefix}${line.trimEnd()}`.trimEnd());
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Converts a state ID to a valid identifier for the names of the generated functions
|
|
65
|
-
*
|
|
66
|
-
* @param {string} id
|
|
67
|
-
* @returns {string}
|
|
68
|
-
*/
|
|
69
|
-
function identifier(id) {
|
|
70
|
-
const name = id.replace(/[^\w$]/g, "_");
|
|
71
|
-
return /^\d/.test(name) ? `_${name}` : name;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* @param {string} key
|
|
76
|
-
* @returns {string} the key for an object literal, in quotes only if necessary
|
|
77
|
-
*/
|
|
78
|
-
function propertyKey(key) {
|
|
79
|
-
return /^[A-Za-z_$][\w$]*$/.test(key) ? key : JSON.stringify(key);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Removes the indentation all lines have in common, so the code can be indented again
|
|
84
|
-
*
|
|
85
|
-
* @param {string} code
|
|
86
|
-
* @returns {string}
|
|
87
|
-
*/
|
|
88
|
-
function dedent(code) {
|
|
89
|
-
const lines = code
|
|
90
|
-
.replace(/\s+$/, "")
|
|
91
|
-
.replace(/^\s*\n/, "")
|
|
92
|
-
.split("\n");
|
|
93
|
-
const common = Math.min(...lines.filter(l => l.trim()).map(l => /^\s*/.exec(l)?.[0].length ?? 0));
|
|
94
|
-
return lines.map(l => l.substring(Number.isFinite(common) ? common : 0)).join("\n");
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* An arrow function executing an action, or null if there is no action
|
|
99
|
-
*
|
|
100
|
-
* @param {string | undefined} code
|
|
101
|
-
* @param {number} level Indentation level of the line the function starts in
|
|
102
|
-
* @returns {string | null}
|
|
103
|
-
*/
|
|
104
|
-
function actionFunction(code, level) {
|
|
105
|
-
if (!code?.trim()) {
|
|
106
|
-
return null;
|
|
107
|
-
}
|
|
108
|
-
return `() => {\n${indent(dedent(code), level + 1)}\n${INDENT.repeat(level)}}`;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Generates the code of a chart
|
|
113
|
-
*
|
|
114
|
-
* @param {Chart} chart
|
|
115
|
-
* @param {string} chartId ID of the chart object, used for the machine ID like in the runtime
|
|
116
|
-
* @returns {string}
|
|
117
|
-
*/
|
|
118
|
-
function generateChartCode(chart, chartId) {
|
|
119
|
-
const structure = new ChartStructure(chart);
|
|
120
|
-
/** @param {string} id */
|
|
121
|
-
const stateName = id => structure.statesById.get(id)?.name ?? id;
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* Parses a label, null if it is invalid or the target is unknown (the runtime ignores these transitions)
|
|
125
|
-
*
|
|
126
|
-
* @param {ChartTransition} transition
|
|
127
|
-
* @returns {{ label: TransitionLabel } | { error: string }}
|
|
128
|
-
*/
|
|
129
|
-
const parse = transition => {
|
|
130
|
-
if (!structure.statesById.has(transition.target)) {
|
|
131
|
-
return { error: "unknown target" };
|
|
132
|
-
}
|
|
133
|
-
try {
|
|
134
|
-
return { label: parseTransitionLabel(transition.label) };
|
|
135
|
-
} catch (err) {
|
|
136
|
-
return { error: err.message };
|
|
137
|
-
}
|
|
138
|
-
};
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* @param {ChartTransition} transition
|
|
142
|
-
* @returns {string} e.g. "Off -> On: motion[motion]"
|
|
143
|
-
*/
|
|
144
|
-
const describe = transition =>
|
|
145
|
-
`${stateName(transition.source)} -> ${stateName(transition.target)}${
|
|
146
|
-
transition.label?.trim() ? `: ${oneLine(transition.label)}` : ""
|
|
147
|
-
}`;
|
|
148
|
-
|
|
149
|
-
const lines = [
|
|
150
|
-
`// Code generated from the chart "${oneLine(chart.name)}" by the statechart adapter.`,
|
|
151
|
-
"// It shows how the adapter executes the chart. It is not executed itself, changes here have no effect.",
|
|
152
|
-
"",
|
|
153
|
-
...(chart.comment?.trim() ? [...commentLines(chart.comment), ""] : []),
|
|
154
|
-
...(chart.notes || []).flatMap(note => [...commentLines(note.text, "Note: "), ""]),
|
|
155
|
-
'const { createActor, createMachine } = require("xstate");',
|
|
156
|
-
"",
|
|
157
|
-
];
|
|
158
|
-
|
|
159
|
-
// variables
|
|
160
|
-
lines.push("// ---------- Variables ----------", "");
|
|
161
|
-
const outputs = chart.symbols.filter(s => s.scope === "output" && s.stateId);
|
|
162
|
-
if (!chart.symbols.length) {
|
|
163
|
-
lines.push("// no variables");
|
|
164
|
-
}
|
|
165
|
-
for (const symbol of chart.symbols) {
|
|
166
|
-
if (symbol.scope === "local") {
|
|
167
|
-
lines.push(`let ${symbol.name} = ${JSON.stringify(symbol.initial) ?? "undefined"}; // local`);
|
|
168
|
-
} else if (!symbol.stateId) {
|
|
169
|
-
lines.push(`let ${symbol.name}; // ${symbol.scope} without state`);
|
|
170
|
-
} else if (symbol.scope === "input") {
|
|
171
|
-
const trigger = [
|
|
172
|
-
symbol.trigger === "change" ? "on value changes" : "on every update",
|
|
173
|
-
symbol.ack === "ack" ? "only confirmed values (ack = true)" : "",
|
|
174
|
-
symbol.ack === "command" ? "only commands (ack = false)" : "",
|
|
175
|
-
symbol.debounceMs ? `after ${symbol.debounceMs} ms without updates` : "",
|
|
176
|
-
]
|
|
177
|
-
.filter(Boolean)
|
|
178
|
-
.join(", ");
|
|
179
|
-
lines.push(
|
|
180
|
-
`let ${symbol.name} = getState(${JSON.stringify(symbol.stateId)}); // input, wakes up the chart ${trigger}`,
|
|
181
|
-
);
|
|
182
|
-
} else {
|
|
183
|
-
lines.push(
|
|
184
|
-
`let ${symbol.name} = getState(${JSON.stringify(symbol.stateId)}); // output, see writeChangedOutputs()`,
|
|
185
|
-
);
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
if (outputs.length) {
|
|
189
|
-
lines.push("", "// the last written value of each output");
|
|
190
|
-
lines.push(`const written = { ${outputs.map(s => s.name).join(", ")} };`);
|
|
191
|
-
}
|
|
192
|
-
lines.push("");
|
|
193
|
-
|
|
194
|
-
// function blocks
|
|
195
|
-
for (const fn of chart.functions || []) {
|
|
196
|
-
if (fn.code.trim()) {
|
|
197
|
-
lines.push("// ---------- Function block ----------", "", ...commentLines(fn.comment), dedent(fn.code), "");
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// machine
|
|
202
|
-
lines.push(
|
|
203
|
-
"// ---------- State machine ----------",
|
|
204
|
-
"",
|
|
205
|
-
"// The transitions are selected by the process functions below (Stateflow order),",
|
|
206
|
-
"// XState only executes them: exit actions, transition action, entry actions.",
|
|
207
|
-
"const machine = createMachine({",
|
|
208
|
-
`${INDENT}id: ${JSON.stringify(`chart_${chartId}`)},`,
|
|
209
|
-
);
|
|
210
|
-
|
|
211
|
-
/**
|
|
212
|
-
* @param {ChartTransition} transition
|
|
213
|
-
* @param {{ inner: boolean; reenter: boolean }} kind
|
|
214
|
-
* @param {TransitionLabel} label
|
|
215
|
-
* @param {number} level
|
|
216
|
-
* @returns {string}
|
|
217
|
-
*/
|
|
218
|
-
const transitionConfig = (transition, kind, label, level) => {
|
|
219
|
-
const parts = [`target: ${JSON.stringify(`#${transition.target}`)}`];
|
|
220
|
-
if (kind.reenter) {
|
|
221
|
-
parts.push("reenter: true");
|
|
222
|
-
}
|
|
223
|
-
const action = actionFunction(label.action, level);
|
|
224
|
-
if (action) {
|
|
225
|
-
parts.push(`actions: ${action}`);
|
|
226
|
-
}
|
|
227
|
-
return `{ ${parts.join(", ")} }`;
|
|
228
|
-
};
|
|
229
|
-
|
|
230
|
-
/**
|
|
231
|
-
* @param {ChartState} state
|
|
232
|
-
* @param {number} level
|
|
233
|
-
*/
|
|
234
|
-
const buildState = (state, level) => {
|
|
235
|
-
const i1 = INDENT.repeat(level);
|
|
236
|
-
const i2 = INDENT.repeat(level + 1);
|
|
237
|
-
const i3 = INDENT.repeat(level + 2);
|
|
238
|
-
lines.push(
|
|
239
|
-
`${i1}// ${oneLine(state.name)}`,
|
|
240
|
-
...commentLines(state.comment, "", i1),
|
|
241
|
-
`${i1}${propertyKey(state.id)}: {`,
|
|
242
|
-
);
|
|
243
|
-
lines.push(`${i2}id: ${JSON.stringify(state.id)},`);
|
|
244
|
-
for (const field of /** @type {const} */ (["entry", "exit"])) {
|
|
245
|
-
const action = actionFunction(state[field], level + 1);
|
|
246
|
-
if (action) {
|
|
247
|
-
lines.push(`${i2}${field}: ${action},`);
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
const on = [];
|
|
252
|
-
for (const transition of structure.getTransitions(state.id)) {
|
|
253
|
-
const parsed = parse(transition);
|
|
254
|
-
if ("error" in parsed) {
|
|
255
|
-
on.push(`${i3}// ${describe(transition)}: ignored, ${parsed.error}`);
|
|
256
|
-
continue;
|
|
257
|
-
}
|
|
258
|
-
const config = transitionConfig(
|
|
259
|
-
transition,
|
|
260
|
-
structure.getTransitionKind(transition),
|
|
261
|
-
parsed.label,
|
|
262
|
-
level + 2,
|
|
263
|
-
);
|
|
264
|
-
on.push(
|
|
265
|
-
`${i3}// ${describe(transition)}`,
|
|
266
|
-
...commentLines(transition.comment, "", i3),
|
|
267
|
-
`${i3}${JSON.stringify(`T.${transition.id}`)}: ${config},`,
|
|
268
|
-
);
|
|
269
|
-
}
|
|
270
|
-
if (on.length) {
|
|
271
|
-
lines.push(`${i2}on: {`, ...on, `${i2}},`);
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
const children = structure.getChildren(state.id);
|
|
275
|
-
if (children.length) {
|
|
276
|
-
lines.push(`${i2}initial: ${JSON.stringify(structure.getInitialChild(state.id))},`, `${i2}states: {`);
|
|
277
|
-
for (const child of children) {
|
|
278
|
-
buildState(child, level + 2);
|
|
279
|
-
}
|
|
280
|
-
lines.push(`${i2}},`);
|
|
281
|
-
}
|
|
282
|
-
lines.push(`${i1}},`);
|
|
283
|
-
};
|
|
284
|
-
|
|
285
|
-
const topLevel = structure.getChildren(null);
|
|
286
|
-
if (topLevel.length) {
|
|
287
|
-
lines.push(`${INDENT}initial: ${JSON.stringify(structure.getInitialChild(null))},`, `${INDENT}states: {`);
|
|
288
|
-
for (const state of topLevel) {
|
|
289
|
-
buildState(state, 2);
|
|
290
|
-
}
|
|
291
|
-
lines.push(`${INDENT}},`);
|
|
292
|
-
}
|
|
293
|
-
lines.push("});", "", "const actor = createActor(machine);", "actor.start();", "");
|
|
294
|
-
|
|
295
|
-
// wakeup and process functions
|
|
296
|
-
/**
|
|
297
|
-
* @param {ChartState[]} states
|
|
298
|
-
* @param {number} level
|
|
299
|
-
* @param {string} prefix "return " inside process functions
|
|
300
|
-
* @returns {string[]}
|
|
301
|
-
*/
|
|
302
|
-
const callActive = (states, level, prefix) =>
|
|
303
|
-
states.map(
|
|
304
|
-
s =>
|
|
305
|
-
`${INDENT.repeat(level)}if (isActive(${JSON.stringify(s.id)})) {\n` +
|
|
306
|
-
`${INDENT.repeat(level + 1)}${prefix}process_${identifier(s.id)}(input);\n` +
|
|
307
|
-
`${INDENT.repeat(level)}}`,
|
|
308
|
-
);
|
|
309
|
-
|
|
310
|
-
lines.push(
|
|
311
|
-
"// ---------- Wakeup ----------",
|
|
312
|
-
"",
|
|
313
|
-
"/**",
|
|
314
|
-
" * Called once for every change of an input, after its variable got the new value,",
|
|
315
|
-
" * and when the time of an after(...) of an active state has come (input is null then).",
|
|
316
|
-
" * Starting with the outermost active state, each active state is processed, see process_...().",
|
|
317
|
-
" *",
|
|
318
|
-
" * @param {string | null} input name of the changed input",
|
|
319
|
-
" */",
|
|
320
|
-
"function wakeup(input) {",
|
|
321
|
-
// only one state of a level is active: "else if", so a state entered by a transition is not processed too
|
|
322
|
-
...topLevel.flatMap((s, i) => [
|
|
323
|
-
`${INDENT}${i ? "} else " : ""}if (isActive(${JSON.stringify(s.id)})) {`,
|
|
324
|
-
`${INDENT}${INDENT}process_${identifier(s.id)}(input);`,
|
|
325
|
-
]),
|
|
326
|
-
...(topLevel.length ? [`${INDENT}}`] : []),
|
|
327
|
-
`${INDENT}writeChangedOutputs();`,
|
|
328
|
-
"}",
|
|
329
|
-
"",
|
|
330
|
-
);
|
|
331
|
-
|
|
332
|
-
/**
|
|
333
|
-
* A check of a transition in a process function
|
|
334
|
-
*
|
|
335
|
-
* @param {ChartTransition} transition
|
|
336
|
-
* @param {TransitionLabel} label
|
|
337
|
-
* @returns {string[]}
|
|
338
|
-
*/
|
|
339
|
-
const check = (transition, label) => {
|
|
340
|
-
const conditions = [];
|
|
341
|
-
if (label.event) {
|
|
342
|
-
conditions.push(`input === ${JSON.stringify(label.event)}`);
|
|
343
|
-
}
|
|
344
|
-
if (label.afterMs !== undefined) {
|
|
345
|
-
// valid from the moment on, not only when the timer expires
|
|
346
|
-
conditions.push(`elapsed(${JSON.stringify(transition.source)}) >= ${label.afterMs}`);
|
|
347
|
-
}
|
|
348
|
-
if (label.condition) {
|
|
349
|
-
conditions.push(`(${label.condition})`);
|
|
350
|
-
}
|
|
351
|
-
return [
|
|
352
|
-
`${INDENT}// ${describe(transition)}`,
|
|
353
|
-
...commentLines(transition.comment, "", INDENT),
|
|
354
|
-
`${INDENT}if (${conditions.join(" && ") || "true"}) {`,
|
|
355
|
-
`${INDENT}${INDENT}actor.send({ type: ${JSON.stringify(`T.${transition.id}`)} });`,
|
|
356
|
-
`${INDENT}${INDENT}return true;`,
|
|
357
|
-
`${INDENT}}`,
|
|
358
|
-
];
|
|
359
|
-
};
|
|
360
|
-
|
|
361
|
-
for (const state of chart.states) {
|
|
362
|
-
const outer = [];
|
|
363
|
-
const inner = [];
|
|
364
|
-
for (const transition of structure.getTransitions(state.id)) {
|
|
365
|
-
const parsed = parse(transition);
|
|
366
|
-
if ("error" in parsed) {
|
|
367
|
-
continue;
|
|
368
|
-
}
|
|
369
|
-
(structure.getTransitionKind(transition).inner ? inner : outer).push(...check(transition, parsed.label));
|
|
370
|
-
}
|
|
371
|
-
const children = structure.getChildren(state.id);
|
|
372
|
-
lines.push(
|
|
373
|
-
"/**",
|
|
374
|
-
` * ${oneLine(state.name)}`,
|
|
375
|
-
...(state.comment?.trim()
|
|
376
|
-
? [
|
|
377
|
-
" *",
|
|
378
|
-
// "*/" would end the comment
|
|
379
|
-
...state.comment
|
|
380
|
-
.trim()
|
|
381
|
-
.split("\n")
|
|
382
|
-
.map(line => ` * ${line.trimEnd().replace(/\*\//g, "* /")}`.trimEnd()),
|
|
383
|
-
]
|
|
384
|
-
: []),
|
|
385
|
-
" *",
|
|
386
|
-
" * @param {string | null} input name of the changed input, null for timers",
|
|
387
|
-
" * @returns {boolean} true if a transition was taken",
|
|
388
|
-
" */",
|
|
389
|
-
`function process_${identifier(state.id)}(input) {`,
|
|
390
|
-
`${INDENT}// 1. transitions leaving the state`,
|
|
391
|
-
...(outer.length ? outer : [`${INDENT}// none`]),
|
|
392
|
-
`${INDENT}// 2. during action`,
|
|
393
|
-
state.during?.trim() ? `${INDENT}{\n${indent(dedent(state.during), 2)}\n${INDENT}}` : `${INDENT}// none`,
|
|
394
|
-
`${INDENT}// 3. inner transitions (to own sub states, the state stays active)`,
|
|
395
|
-
...(inner.length ? inner : [`${INDENT}// none`]),
|
|
396
|
-
`${INDENT}// 4. active sub state`,
|
|
397
|
-
...(children.length ? callActive(children, 1, "return ") : [`${INDENT}// none`]),
|
|
398
|
-
`${INDENT}return false;`,
|
|
399
|
-
"}",
|
|
400
|
-
"",
|
|
401
|
-
);
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
// helpers
|
|
405
|
-
lines.push(
|
|
406
|
-
"// ---------- Helpers ----------",
|
|
407
|
-
"",
|
|
408
|
-
"/** Writes every output whose value was changed by an action */",
|
|
409
|
-
"function writeChangedOutputs() {",
|
|
410
|
-
...outputs.map(
|
|
411
|
-
s =>
|
|
412
|
-
`${INDENT}if (${s.name} !== written.${s.name}) {\n` +
|
|
413
|
-
`${INDENT}${INDENT}written.${s.name} = ${s.name};\n` +
|
|
414
|
-
`${INDENT}${INDENT}setState(${JSON.stringify(s.stateId)}, ${s.name});\n` +
|
|
415
|
-
`${INDENT}}`,
|
|
416
|
-
),
|
|
417
|
-
"}",
|
|
418
|
-
"",
|
|
419
|
-
"// Provided by the adapter:",
|
|
420
|
-
"// getState(id) current value of an ioBroker state",
|
|
421
|
-
"// isActive(stateId) true if the state of the chart is active",
|
|
422
|
-
"// elapsed(stateId) milliseconds since the state was entered; when it reaches the delay of an",
|
|
423
|
-
"// after(...) of the state, a timer calls wakeup(null)",
|
|
424
|
-
"//",
|
|
425
|
-
"// Helper functions for conditions, actions and function blocks:",
|
|
426
|
-
...Object.values(HELPERS).map(helper => `// ${helper.signature}\n// ${helper.description}`),
|
|
427
|
-
"//",
|
|
428
|
-
"// The adapter stores the active states, the times they were entered and the locals in the state",
|
|
429
|
-
"// charts.<chart>.snapshot. After a restart of the adapter it continues with them, without executing the",
|
|
430
|
-
"// entry actions again, if the chart was not changed meanwhile.",
|
|
431
|
-
"",
|
|
432
|
-
);
|
|
433
|
-
return lines.join("\n");
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
module.exports = { generateChartCode };
|
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
|
|
3
|
-
const { expect } = require("chai");
|
|
4
|
-
const acorn = require("acorn");
|
|
5
|
-
const { generateChartCode } = require("./chart-codegen");
|
|
6
|
-
|
|
7
|
-
/** @returns {import("./chart-model").Chart} */
|
|
8
|
-
function createChart() {
|
|
9
|
-
return {
|
|
10
|
-
version: 1,
|
|
11
|
-
name: "Heating",
|
|
12
|
-
enabled: true,
|
|
13
|
-
initial: "auto",
|
|
14
|
-
symbols: [
|
|
15
|
-
{ name: "temp", scope: "input", stateId: "test.0.temp" },
|
|
16
|
-
{ name: "manual", scope: "input", stateId: "test.0.manual" },
|
|
17
|
-
{ name: "heating", scope: "output", stateId: "test.0.heating" },
|
|
18
|
-
{ name: "count", scope: "local", initial: 0 },
|
|
19
|
-
],
|
|
20
|
-
states: [
|
|
21
|
-
{ id: "auto", name: "Auto", initial: "heat", during: "count++;", x: 0, y: 0 },
|
|
22
|
-
{ id: "idle", name: "Idle", parent: "auto", entry: "heating = false;", x: 0, y: 0 },
|
|
23
|
-
{ id: "heat", name: "Heat", parent: "auto", entry: "heating = true;\nlog('heating');", x: 0, y: 0 },
|
|
24
|
-
{ id: "manualState", name: "Manual", x: 0, y: 0 },
|
|
25
|
-
],
|
|
26
|
-
transitions: [
|
|
27
|
-
{ id: "toManual", source: "auto", target: "manualState", label: "manual[manual]", priority: 1 },
|
|
28
|
-
{ id: "toAuto", source: "manualState", target: "auto", label: "after(5, min)[!manual]" },
|
|
29
|
-
{ id: "start", source: "idle", target: "heat", label: "[temp < 20]{count = 0;}" },
|
|
30
|
-
{ id: "restart", source: "auto", target: "idle", label: "[temp > 30]" },
|
|
31
|
-
{ id: "broken", source: "heat", target: "idle", label: "[temp > 22" },
|
|
32
|
-
],
|
|
33
|
-
functions: [{ id: "f1", code: "function tooWarm(t) {\n return t > 22;\n}", x: 0, y: 0 }],
|
|
34
|
-
};
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
describe("chart-codegen => generateChartCode", () => {
|
|
38
|
-
it("generates valid JavaScript", () => {
|
|
39
|
-
const code = generateChartCode(createChart(), "heating");
|
|
40
|
-
expect(() => acorn.parse(code, { ecmaVersion: "latest", sourceType: "script" })).not.to.throw();
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it("contains variables, function blocks and the machine", () => {
|
|
44
|
-
const code = generateChartCode(createChart(), "heating");
|
|
45
|
-
expect(code).to.include('let temp = getState("test.0.temp"); // input, wakes up the chart on every update');
|
|
46
|
-
expect(code).to.include("let count = 0; // local");
|
|
47
|
-
expect(code).to.include("const written = { heating };");
|
|
48
|
-
expect(code).to.include("function tooWarm(t) {\n return t > 22;\n}");
|
|
49
|
-
expect(code).to.include('id: "chart_heating"');
|
|
50
|
-
expect(code).to.include('initial: "heat"');
|
|
51
|
-
// temporal transitions are checked like the others, valid from the moment their time has come
|
|
52
|
-
expect(code).to.include('if (elapsed("manualState") >= 300000 && (!manual)) {');
|
|
53
|
-
expect(code).to.include('"T.toAuto": { target: "#auto" }');
|
|
54
|
-
expect(code).not.to.include("after: {");
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it("selects the transitions in Stateflow order", () => {
|
|
58
|
-
const code = generateChartCode(createChart(), "heating");
|
|
59
|
-
const auto = code.substring(code.indexOf("function process_auto("), code.indexOf("function process_idle("));
|
|
60
|
-
const order = [
|
|
61
|
-
'input === "manual" && (manual)',
|
|
62
|
-
"count++;",
|
|
63
|
-
"(temp > 30)",
|
|
64
|
-
'isActive("idle")',
|
|
65
|
-
'isActive("heat")',
|
|
66
|
-
].map(part => auto.indexOf(part));
|
|
67
|
-
expect(order.every(i => i >= 0)).to.equal(true, auto);
|
|
68
|
-
expect([...order].sort((a, b) => a - b)).to.deep.equal(order);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it("marks inner transitions as not re-entering and ignores invalid transitions like the runtime", () => {
|
|
72
|
-
const code = generateChartCode(createChart(), "heating");
|
|
73
|
-
expect(code).to.include('"T.restart": { target: "#idle" }');
|
|
74
|
-
expect(code).to.include('// Heat -> Idle: [temp > 22: ignored, Missing "]"');
|
|
75
|
-
expect(code).not.to.include('"T.broken"');
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it("processes only the state which was active when the chart woke up", () => {
|
|
79
|
-
const code = generateChartCode(createChart(), "heating");
|
|
80
|
-
expect(code).to.include(
|
|
81
|
-
' if (isActive("auto")) {\n process_auto(input);\n } else if (isActive("manualState")) {\n',
|
|
82
|
-
);
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it("indents multi line actions", () => {
|
|
86
|
-
const code = generateChartCode(createChart(), "heating");
|
|
87
|
-
expect(code).to.include(
|
|
88
|
-
"entry: () => {\n heating = true;\n log('heating');\n",
|
|
89
|
-
);
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it("includes comments and notes as comments", () => {
|
|
93
|
-
const chart = createChart();
|
|
94
|
-
chart.comment = "Heating of the\nliving room";
|
|
95
|
-
chart.notes = [{ id: "n1", text: "Check the\nthermostat", x: 0, y: 0 }];
|
|
96
|
-
chart.states[0].comment = "Automatic mode */ with tricky text";
|
|
97
|
-
chart.transitions[0].comment = "Manual has priority";
|
|
98
|
-
/** @type {any[]} */ (chart.functions)[0].comment = "Helper";
|
|
99
|
-
const code = generateChartCode(chart, "heating");
|
|
100
|
-
expect(() => acorn.parse(code, { ecmaVersion: "latest" })).not.to.throw();
|
|
101
|
-
expect(code).to.include("// Heating of the\n// living room\n");
|
|
102
|
-
expect(code).to.include("// Note: Check the\n// thermostat\n");
|
|
103
|
-
expect(code).to.include("// Helper\nfunction tooWarm(t)");
|
|
104
|
-
expect(code).to.include(" // Auto\n // Automatic mode */ with tricky text\n auto: {");
|
|
105
|
-
expect(code).to.include(" * Automatic mode * / with tricky text");
|
|
106
|
-
// in the machine and in the process function
|
|
107
|
-
expect(code).to.include(
|
|
108
|
-
" // Auto -> Manual: manual[manual]\n // Manual has priority\n",
|
|
109
|
-
);
|
|
110
|
-
expect(code).to.include("\n // Auto -> Manual: manual[manual]\n // Manual has priority\n");
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it("describes the triggers of inputs", () => {
|
|
114
|
-
const chart = createChart();
|
|
115
|
-
Object.assign(chart.symbols[0], { trigger: "change", ack: "ack", debounceMs: 500 });
|
|
116
|
-
Object.assign(chart.symbols[1], { ack: "command" });
|
|
117
|
-
const code = generateChartCode(chart, "heating");
|
|
118
|
-
expect(code).to.include(
|
|
119
|
-
"// input, wakes up the chart on value changes, only confirmed values (ack = true), after 500 ms without updates",
|
|
120
|
-
);
|
|
121
|
-
expect(code).to.include("// input, wakes up the chart on every update, only commands (ack = false)");
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
it("handles empty charts", () => {
|
|
125
|
-
const code = generateChartCode(
|
|
126
|
-
{ version: 1, name: "Empty", enabled: true, symbols: [], states: [], transitions: [] },
|
|
127
|
-
"empty",
|
|
128
|
-
);
|
|
129
|
-
expect(() => acorn.parse(code, { ecmaVersion: "latest" })).not.to.throw();
|
|
130
|
-
expect(code).to.include("// no variables");
|
|
131
|
-
});
|
|
132
|
-
});
|
package/lib/chart-helpers.js
DELETED
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
|
|
3
|
-
/*
|
|
4
|
-
* Helper functions which can be used in conditions, actions and function blocks.
|
|
5
|
-
* The descriptions are shared by the editor (completion), the validation and the code generator,
|
|
6
|
-
* the runtime implements them (see ChartRuntime.createHelpers()).
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* @typedef {object} HelperDescription
|
|
11
|
-
* @property {string} signature
|
|
12
|
-
* @property {string} description
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
/** @type {Record<string, HelperDescription>} */
|
|
16
|
-
const HELPERS = {
|
|
17
|
-
log: {
|
|
18
|
-
signature: 'log(message, level = "info")',
|
|
19
|
-
description: 'Writes a message to the ioBroker log, level "debug", "info", "warn" or "error"',
|
|
20
|
-
},
|
|
21
|
-
setState: {
|
|
22
|
-
signature: "setState(id, value, ack = false)",
|
|
23
|
-
description: "Writes a value to any ioBroker state",
|
|
24
|
-
},
|
|
25
|
-
setStateDelayed: {
|
|
26
|
-
signature: "setStateDelayed(id, value, delayMs, ack = false)",
|
|
27
|
-
description: "Writes a value after a delay, cancelled when the chart stops",
|
|
28
|
-
},
|
|
29
|
-
sendTo: {
|
|
30
|
-
signature: "sendTo(instance, command, message)",
|
|
31
|
-
description: 'Sends a message to an adapter, e.g. sendTo("telegram.0", "send", { text: "Hello" })',
|
|
32
|
-
},
|
|
33
|
-
isTimeBetween: {
|
|
34
|
-
signature: 'isTimeBetween("22:00", "06:00")',
|
|
35
|
-
description: "true if the current time is in the range, also over midnight",
|
|
36
|
-
},
|
|
37
|
-
isDaytime: {
|
|
38
|
-
signature: "isDaytime()",
|
|
39
|
-
description: "true between sunrise and sunset, at the location of the ioBroker system settings",
|
|
40
|
-
},
|
|
41
|
-
astroTime: {
|
|
42
|
-
signature: 'astroTime("sunset", offsetMinutes = 0)',
|
|
43
|
-
description:
|
|
44
|
-
"Time of an astro event today: sunrise, sunset, dawn, dusk, solarNoon, nauticalDawn, nauticalDusk, goldenHour, goldenHourEnd, night, nightEnd",
|
|
45
|
-
},
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* @param {string} time "HH:mm" or "HH:mm:ss"
|
|
50
|
-
* @returns {number} seconds since midnight
|
|
51
|
-
* @throws {Error} if the time is invalid
|
|
52
|
-
*/
|
|
53
|
-
function parseTime(time) {
|
|
54
|
-
const match = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/.exec(String(time).trim());
|
|
55
|
-
if (!match || Number(match[1]) > 23 || Number(match[2]) > 59 || Number(match[3] ?? 0) > 59) {
|
|
56
|
-
throw new Error(`Invalid time "${time}", expected e.g. "22:30"`);
|
|
57
|
-
}
|
|
58
|
-
return Number(match[1]) * 3600 + Number(match[2]) * 60 + Number(match[3] ?? 0);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* @param {string} start "HH:mm", inclusive
|
|
63
|
-
* @param {string} end "HH:mm", exclusive. A range over midnight ends on the next day.
|
|
64
|
-
* @param {Date} [now]
|
|
65
|
-
* @returns {boolean}
|
|
66
|
-
*/
|
|
67
|
-
function isTimeBetween(start, end, now = new Date()) {
|
|
68
|
-
const from = parseTime(start);
|
|
69
|
-
const to = parseTime(end);
|
|
70
|
-
const current = now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds();
|
|
71
|
-
return from <= to ? current >= from && current < to : current >= from || current < to;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
module.exports = { HELPERS, isTimeBetween, parseTime };
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
|
|
3
|
-
const { expect } = require("chai");
|
|
4
|
-
const { isTimeBetween, parseTime } = require("./chart-helpers");
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* @param {string} time "HH:mm"
|
|
8
|
-
* @returns {Date} today at this time
|
|
9
|
-
*/
|
|
10
|
-
const at = time => {
|
|
11
|
-
const [h, m] = time.split(":").map(Number);
|
|
12
|
-
const date = new Date();
|
|
13
|
-
date.setHours(h, m, 0, 0);
|
|
14
|
-
return date;
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
describe("chart-helpers => isTimeBetween", () => {
|
|
18
|
-
it("checks ranges within a day, the end is exclusive", () => {
|
|
19
|
-
expect(isTimeBetween("08:00", "17:30", at("08:00"))).to.equal(true);
|
|
20
|
-
expect(isTimeBetween("08:00", "17:30", at("12:00"))).to.equal(true);
|
|
21
|
-
expect(isTimeBetween("08:00", "17:30", at("17:30"))).to.equal(false);
|
|
22
|
-
expect(isTimeBetween("08:00", "17:30", at("07:59"))).to.equal(false);
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
it("checks ranges over midnight", () => {
|
|
26
|
-
expect(isTimeBetween("22:00", "06:00", at("23:30"))).to.equal(true);
|
|
27
|
-
expect(isTimeBetween("22:00", "06:00", at("00:00"))).to.equal(true);
|
|
28
|
-
expect(isTimeBetween("22:00", "06:00", at("05:59"))).to.equal(true);
|
|
29
|
-
expect(isTimeBetween("22:00", "06:00", at("06:00"))).to.equal(false);
|
|
30
|
-
expect(isTimeBetween("22:00", "06:00", at("12:00"))).to.equal(false);
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it("rejects invalid times", () => {
|
|
34
|
-
expect(() => parseTime("25:00")).to.throw(/Invalid time "25:00"/);
|
|
35
|
-
expect(() => parseTime("8 Uhr")).to.throw(/Invalid time/);
|
|
36
|
-
expect(parseTime("7:05:30")).to.equal(7 * 3600 + 5 * 60 + 30);
|
|
37
|
-
});
|
|
38
|
-
});
|