@yarkivaev/scada 2.3.89 → 2.3.112

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.
Files changed (35) hide show
  1. package/README.md +6 -1
  2. package/charts/scada/Chart.yaml +6 -0
  3. package/charts/scada/templates/_helpers.tpl +63 -0
  4. package/charts/scada/templates/clickhouse.yaml +57 -0
  5. package/charts/scada/templates/collector.yaml +115 -0
  6. package/charts/scada/templates/postgres.yaml +70 -0
  7. package/charts/scada/templates/rabbitmq.yaml +150 -0
  8. package/charts/scada/templates/site-server.yaml +110 -0
  9. package/charts/scada/templates/supervisor-sink.yaml +64 -0
  10. package/charts/scada/values.yaml +30 -0
  11. package/index.js +10 -0
  12. package/package.json +4 -2
  13. package/src/application/deploy/appReleases.js +50 -0
  14. package/src/application/deploy/applyIncludes.js +31 -0
  15. package/src/application/deploy/expand.js +49 -0
  16. package/src/application/deploy/expandDevices.js +135 -0
  17. package/src/application/deploy/expandFlows.js +281 -0
  18. package/src/application/deploy/expandNamespace.js +193 -0
  19. package/src/application/deploy/expandPattern.js +207 -0
  20. package/src/application/deploy/expandProfiles.js +70 -0
  21. package/src/application/deploy/frozen.js +23 -0
  22. package/src/application/deploy/mergeDocuments.js +98 -0
  23. package/src/application/deploy/paths.js +86 -0
  24. package/src/application/deploy/pointPaths.js +20 -0
  25. package/src/application/deploy/readDocument.js +169 -0
  26. package/src/application/deploy/releaseEdges.js +185 -0
  27. package/src/application/deploy/releaseNodes.js +118 -0
  28. package/src/application/deploy/releaseValues.js +200 -0
  29. package/src/application/deploy/releases.js +115 -0
  30. package/src/application/deploy/withCollectors.js +28 -0
  31. package/src/bin/modbus-to-mqtt.js +57 -0
  32. package/src/infrastructure/ingest/mqtt/addressWindow.js +79 -0
  33. package/src/infrastructure/ingest/mqtt/modbusPublish.js +277 -0
  34. package/src/infrastructure/ingest/mqtt/registerPoint.js +90 -0
  35. package/src/infrastructure/ingest/mqtt/registerPublish.js +84 -0
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Rejects a patch that names one id twice.
3
+ *
4
+ * @param {object[]} patch - patch entries
5
+ * @returns {void}
6
+ * @example
7
+ * repeated([{ id: 'voltage' }]);
8
+ */
9
+ function repeated(patch) {
10
+ const ids = patch.map((entry) => {
11
+ return entry.id;
12
+ });
13
+ ids.forEach((id, index) => {
14
+ if (ids.indexOf(id) !== index) {
15
+ throw new Error(`system document patch repeats ${id}`);
16
+ }
17
+ });
18
+ }
19
+
20
+ /**
21
+ * Copies patch fields onto one staying node.
22
+ *
23
+ * @param {object} node - template node
24
+ * @param {object} entry - patch entry
25
+ * @param {object} templates - reusable node lists
26
+ * @param {Function} apply - patch function
27
+ * @param {Function} expand - node function
28
+ * @returns {object} patched node
29
+ * @example
30
+ * revise({ id: 'electrical' }, { id: 'electrical' }, {}, () => { return []; }, () => { return {}; });
31
+ */
32
+ function revise(node, entry, templates, apply, expand) {
33
+ const extra = {};
34
+ Object.keys(entry).forEach((key) => {
35
+ if (key !== 'enabled' && key !== 'id' && key !== 'nodes' && key !== 'template') {
36
+ extra[key] = entry[key];
37
+ }
38
+ });
39
+ const merged = { ...node, ...extra, id: node.id };
40
+ if (entry.nodes) {
41
+ merged.nodes = apply(node.nodes || [], entry.nodes, templates, expand);
42
+ }
43
+ if (!merged.nodes || merged.nodes.length === 0) {
44
+ delete merged.nodes;
45
+ }
46
+ return merged;
47
+ }
48
+
49
+ /**
50
+ * Applies one patch entry to an expanded child list.
51
+ *
52
+ * @param {object[]} current - children so far
53
+ * @param {object} entry - one patch entry
54
+ * @param {object} templates - reusable node lists
55
+ * @param {Function} apply - patch function
56
+ * @param {Function} expand - node function
57
+ * @returns {object[]} next children
58
+ * @example
59
+ * mergeOne([{ id: 'step' }], { enabled: false, id: 'step' }, {}, () => { return []; }, () => { return {}; });
60
+ */
61
+ function mergeOne(current, entry, templates, apply, expand) {
62
+ const index = current.findIndex((node) => {
63
+ return node.id === entry.id;
64
+ });
65
+ if (index < 0) {
66
+ if (entry.enabled === false) {
67
+ return current;
68
+ }
69
+ return current.concat([expand(entry, templates)]);
70
+ }
71
+ if (entry.enabled === false) {
72
+ return current.filter((node) => {
73
+ return node.id !== entry.id;
74
+ });
75
+ }
76
+ const next = current.slice();
77
+ next[index] = revise(current[index], entry, templates, apply, expand);
78
+ return next;
79
+ }
80
+
81
+ /**
82
+ * Patches a template child list.
83
+ *
84
+ * An id the template lacks is added. An id the patch disables is removed.
85
+ * A staying entry may patch those children the same way.
86
+ *
87
+ * @param {object[]} seeded - template children
88
+ * @param {object[]} patch - node patch
89
+ * @param {object} templates - reusable node lists
90
+ * @param {Function} expand - node function
91
+ * @returns {object[]} patched children
92
+ * @example
93
+ * applyPatch([{ id: 'step' }], [{ enabled: false, id: 'step' }], {}, () => { return {}; });
94
+ */
95
+ function applyPatch(seeded, patch, templates, expand) {
96
+ repeated(patch);
97
+ return patch.reduce((current, entry) => {
98
+ return mergeOne(current, entry, templates, applyPatch, expand);
99
+ }, seeded);
100
+ }
101
+
102
+ /**
103
+ * Expands the children of one namespace node.
104
+ *
105
+ * @param {object} node - namespace node
106
+ * @param {object} templates - reusable node lists
107
+ * @param {Function} expand - node function
108
+ * @returns {object[]} expanded children
109
+ * @example
110
+ * childrenOf({ id: 'unit', template: 'unit' }, { unit: [] }, () => { return {}; });
111
+ */
112
+ function childrenOf(node, templates, expand) {
113
+ if (!node.template) {
114
+ return (node.nodes || []).map((child) => {
115
+ return expand(child, templates);
116
+ });
117
+ }
118
+ const base = templates[node.template];
119
+ if (!Array.isArray(base)) {
120
+ throw new Error(`system document has no template ${node.template}`);
121
+ }
122
+ const seeded = base.map((child) => {
123
+ return expand(child, templates);
124
+ });
125
+ return applyPatch(seeded, node.nodes || [], templates, expand);
126
+ }
127
+
128
+ /**
129
+ * Expands one namespace node, applying its template and patch.
130
+ *
131
+ * @param {object} node - namespace node
132
+ * @param {object} templates - reusable node lists
133
+ * @returns {object} expanded node
134
+ * @example
135
+ * expandNode({ id: 'mill' }, {});
136
+ */
137
+ function expandNode(node, templates) {
138
+ if (!node || typeof node.id !== 'string' || node.id.length === 0) {
139
+ throw new Error('system document namespace id is empty');
140
+ }
141
+ const nodes = childrenOf(node, templates, expandNode);
142
+ const copy = { id: node.id };
143
+ Object.keys(node).forEach((key) => {
144
+ if (key !== 'enabled' && key !== 'id' && key !== 'nodes' && key !== 'template') {
145
+ copy[key] = node[key];
146
+ }
147
+ });
148
+ if (nodes.length > 0) {
149
+ copy.nodes = nodes;
150
+ }
151
+ return copy;
152
+ }
153
+
154
+ /**
155
+ * Expands a namespace tree.
156
+ *
157
+ * @param {object[]|undefined} nodes - root nodes
158
+ * @param {object} templates - reusable node lists
159
+ * @returns {object[]} expanded roots
160
+ * @example
161
+ * expandTree([{ id: 'mill' }], {});
162
+ */
163
+ export function expandTree(nodes, templates) {
164
+ return (nodes || []).map((node) => {
165
+ return expandNode(node, templates);
166
+ });
167
+ }
168
+
169
+ /**
170
+ * Lists every node path from the root.
171
+ *
172
+ * @param {object[]|undefined} nodes - sibling nodes
173
+ * @param {string} [prefix] - parent path
174
+ * @returns {string[]} paths
175
+ * @example
176
+ * treePaths([{ id: 'mill', nodes: [{ id: 'hall' }] }]);
177
+ */
178
+ export function treePaths(nodes, prefix = '') {
179
+ const found = [];
180
+ (nodes || []).forEach((node) => {
181
+ const path = prefix.length > 0 ? `${prefix}/${node.id}` : node.id;
182
+ if (path.split('/').some((part) => {
183
+ return part.length === 0;
184
+ })) {
185
+ throw new Error(`system document path ${path} has an empty segment`);
186
+ }
187
+ found.push(path);
188
+ treePaths(node.nodes, path).forEach((child) => {
189
+ found.push(child);
190
+ });
191
+ });
192
+ return found;
193
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Reports whether a token is a device address.
3
+ *
4
+ * @param {string} value - flow source
5
+ * @returns {boolean} true when the token carries a scheme
6
+ * @example
7
+ * isAddress('modbus://192.0.2.10:502');
8
+ */
9
+ export function isAddress(value) {
10
+ return typeof value === 'string' && value.includes('://');
11
+ }
12
+
13
+ /**
14
+ * Rejects an empty path segment.
15
+ *
16
+ * @param {string} path - path or pattern
17
+ * @returns {void}
18
+ * @example
19
+ * assertSegments('mill/hall');
20
+ */
21
+ function assertSegments(path) {
22
+ if (path.split('/').some((part) => {
23
+ return part.length === 0;
24
+ })) {
25
+ throw new Error(`system document path ${path} has an empty segment`);
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Lists brace groups in one path.
31
+ *
32
+ * @param {string} text - path text
33
+ * @returns {{body: string, end: number, start: number}[]} groups
34
+ * @example
35
+ * groups('mill/{a,b}');
36
+ */
37
+ function groups(text) {
38
+ return [...text.matchAll(/\{(?<body>[^{}]*)\}/gu)].map((match) => {
39
+ return {
40
+ body: match.groups.body,
41
+ end: match.index + match[0].length,
42
+ start: match.index
43
+ };
44
+ });
45
+ }
46
+
47
+ /**
48
+ * Splits one brace body into segment alternatives.
49
+ *
50
+ * @param {string} body - text inside braces
51
+ * @returns {string[]} alternatives
52
+ * @example
53
+ * alternatives('east,west');
54
+ */
55
+ function alternatives(body) {
56
+ const parts = body.split(',').map((part) => {
57
+ return part.trim();
58
+ });
59
+ if (parts.some((part) => {
60
+ return part.length === 0 || part.includes('/');
61
+ })) {
62
+ throw new Error(`system document brace ${body} is not one segment`);
63
+ }
64
+ return parts;
65
+ }
66
+
67
+ /**
68
+ * Substitutes the index-th alternative of every brace group.
69
+ *
70
+ * @param {string} text - original path
71
+ * @param {{end: number, start: number}[]} marks - brace groups
72
+ * @param {string[][]} choices - alternatives per group
73
+ * @param {number} index - shared alternative index
74
+ * @returns {string} one concrete path
75
+ * @example
76
+ * fill('mill/{a,b}', [], [], 0);
77
+ */
78
+ function fill(text, marks, choices, index) {
79
+ let built = '';
80
+ let cursor = 0;
81
+ marks.forEach((group, place) => {
82
+ built += text.slice(cursor, group.start);
83
+ built += choices[place][index];
84
+ cursor = group.end;
85
+ });
86
+ return built + text.slice(cursor);
87
+ }
88
+
89
+ /**
90
+ * Builds one zipped row per shared brace index.
91
+ *
92
+ * @param {string} text - original path
93
+ * @param {{body: string, end: number, start: number}[]} marks - brace groups
94
+ * @returns {string[]} concrete paths
95
+ * @example
96
+ * rowsFrom('mill/{a,b}/{c,d}', []);
97
+ */
98
+ function rowsFrom(text, marks) {
99
+ const choices = marks.map((group) => {
100
+ return alternatives(group.body);
101
+ });
102
+ const count = choices[0].length;
103
+ if (choices.some((choice) => {
104
+ return choice.length !== count;
105
+ })) {
106
+ throw new Error(`system document braces in ${text} differ in length`);
107
+ }
108
+ const rows = [];
109
+ for (let index = 0; index < count; index += 1) {
110
+ rows.push(fill(text, marks, choices, index));
111
+ }
112
+ return rows;
113
+ }
114
+
115
+ /**
116
+ * Expands `{a,b}` groups in one path together.
117
+ *
118
+ * A fixed string is returned as a single-element list. Brace groups share an
119
+ * index, so `{a,b}/{c,d}` becomes `a/c` and `b/d`.
120
+ *
121
+ * @param {string} text - path, server id, or address
122
+ * @returns {string[]} expanded texts
123
+ * @example
124
+ * zipped('mill/{east,west}/{alpha,beta}');
125
+ */
126
+ export function zipped(text) {
127
+ const marks = groups(text);
128
+ if (marks.length === 0) {
129
+ return [text];
130
+ }
131
+ return rowsFrom(text, marks);
132
+ }
133
+
134
+ /**
135
+ * Reports whether a value matches a star pattern.
136
+ *
137
+ * `*` matches any remainder, including `/`.
138
+ *
139
+ * @param {string} pattern - pattern that may contain `*`
140
+ * @param {string} value - concrete text
141
+ * @returns {boolean} true when the value fits
142
+ * @example
143
+ * fits('mill/*', 'mill/hall/unit');
144
+ */
145
+ export function fits(pattern, value) {
146
+ const body = pattern.split('*').map((part) => {
147
+ return part.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
148
+ }).join('.*');
149
+ return new RegExp(`^${body}$`, 'u').test(value);
150
+ }
151
+
152
+ /**
153
+ * Collects pool entries that fit one concrete pattern.
154
+ *
155
+ * @param {string} item - pattern without braces
156
+ * @param {string[]} pool - existing values
157
+ * @param {string} noun - path or server
158
+ * @param {boolean} strict - whether a miss is an error
159
+ * @param {string[]} matched - accumulator
160
+ * @returns {void}
161
+ * @example
162
+ * collect('mill/*', ['mill/hall'], 'path', true, []);
163
+ */
164
+ function collect(item, pool, noun, strict, matched) {
165
+ if (item.includes('*')) {
166
+ pool.forEach((entry) => {
167
+ if (fits(item, entry) && !matched.includes(entry)) {
168
+ matched.push(entry);
169
+ }
170
+ });
171
+ return;
172
+ }
173
+ if (!pool.includes(item)) {
174
+ if (strict) {
175
+ throw new Error(`system document has no ${noun} ${item}`);
176
+ }
177
+ return;
178
+ }
179
+ if (!matched.includes(item)) {
180
+ matched.push(item);
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Expands braces and `*` against values that already exist.
186
+ *
187
+ * @param {string} pattern - path or server pattern
188
+ * @param {string[]} pool - existing values
189
+ * @param {string} noun - path or server
190
+ * @param {boolean} strict - whether a miss is an error
191
+ * @returns {string[]} matches
192
+ * @example
193
+ * picked('mill/*', ['mill/hall/unit'], 'path', true);
194
+ */
195
+ export function picked(pattern, pool, noun, strict) {
196
+ assertSegments(pattern);
197
+ const concrete = zipped(pattern);
198
+ const matched = [];
199
+ concrete.forEach((item) => {
200
+ assertSegments(item);
201
+ collect(item, pool, noun, strict, matched);
202
+ });
203
+ if (matched.length === 0 && strict) {
204
+ throw new Error(`system document has no ${noun} ${pattern}`);
205
+ }
206
+ return matched;
207
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Overlays a child profile on its parent.
3
+ *
4
+ * The child keeps the parent's modules, command, variables, and apps.
5
+ * A child app id replaces that one app. A child command replaces the command.
6
+ *
7
+ * @param {object} parent - resolved parent profile
8
+ * @param {object} child - extending profile
9
+ * @returns {object} merged profile
10
+ * @example
11
+ * overlay({ command: ['node'] }, { extends: 'cell' });
12
+ */
13
+ function overlay(parent, child) {
14
+ const merged = {
15
+ ...parent,
16
+ ...child,
17
+ apps: { ...(parent.apps || {}), ...(child.apps || {}) },
18
+ modules: { ...(parent.modules || {}), ...(child.modules || {}) },
19
+ variables: { ...(parent.variables || {}), ...(child.variables || {}) }
20
+ };
21
+ delete merged.extends;
22
+ return merged;
23
+ }
24
+
25
+ /**
26
+ * Resolves one profile, following `extends`.
27
+ *
28
+ * @param {string} name - profile id
29
+ * @param {object} profiles - declared profiles
30
+ * @param {object} done - profiles already resolved
31
+ * @param {string[]} stack - profiles on the current chain
32
+ * @returns {object} resolved profile
33
+ * @example
34
+ * resolve('cell', { cell: {} }, {}, []);
35
+ */
36
+ function resolve(name, profiles, done, stack) {
37
+ if (Object.hasOwn(done, name)) {
38
+ return done[name];
39
+ }
40
+ const profile = profiles[name];
41
+ if (!profile) {
42
+ throw new Error(`system document has no profile ${name}`);
43
+ }
44
+ if (stack.includes(name)) {
45
+ throw new Error(`system document profile ${name} extends itself`);
46
+ }
47
+ if (!profile.extends) {
48
+ done[name] = { ...profile };
49
+ return done[name];
50
+ }
51
+ const parent = resolve(profile.extends, profiles, done, stack.concat(name));
52
+ done[name] = overlay(parent, profile);
53
+ return done[name];
54
+ }
55
+
56
+ /**
57
+ * Merges every profile with the profile it extends.
58
+ *
59
+ * @param {object} profiles - declared profiles
60
+ * @returns {object} profiles with `extends` applied
61
+ * @example
62
+ * expandProfiles({ cell: {}, external: { extends: 'cell' } });
63
+ */
64
+ export function expandProfiles(profiles) {
65
+ const done = {};
66
+ Object.keys(profiles).forEach((name) => {
67
+ resolve(name, profiles, done, []);
68
+ });
69
+ return done;
70
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Deep-freezes a document value.
3
+ *
4
+ * @param {unknown} value - any JSON-like value
5
+ * @returns {unknown} frozen value
6
+ * @example
7
+ * frozen({ id: 'mill' });
8
+ */
9
+ export default function frozen(value) {
10
+ if (value === null || typeof value !== 'object' || Object.isFrozen(value)) {
11
+ return value;
12
+ }
13
+ if (Array.isArray(value)) {
14
+ return Object.freeze(value.map((item) => {
15
+ return frozen(item);
16
+ }));
17
+ }
18
+ const copy = {};
19
+ Object.keys(value).forEach((key) => {
20
+ copy[key] = frozen(value[key]);
21
+ });
22
+ return Object.freeze(copy);
23
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Reports whether a value is a plain map.
3
+ *
4
+ * @param {*} value - candidate
5
+ * @returns {boolean} true for a non-null object that is not an array
6
+ * @example
7
+ * isMap({ id: 'cell' });
8
+ */
9
+ function isMap(value) {
10
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
11
+ }
12
+
13
+ /**
14
+ * Deep-merges two maps. A null value removes that key.
15
+ *
16
+ * @param {object|undefined} left - base map
17
+ * @param {object|undefined} right - overlay
18
+ * @returns {object} merged map
19
+ * @example
20
+ * mergeMaps({ secret: 'pg' }, { image: 'plant:1' });
21
+ */
22
+ function mergeMaps(left, right) {
23
+ const value = { ...(left || {}) };
24
+ Object.keys(right || {}).forEach((key) => {
25
+ const next = right[key];
26
+ if (next === null) {
27
+ delete value[key];
28
+ return;
29
+ }
30
+ if (isMap(value[key]) && isMap(next)) {
31
+ value[key] = mergeMaps(value[key], next);
32
+ return;
33
+ }
34
+ value[key] = next;
35
+ });
36
+ return value;
37
+ }
38
+
39
+ /**
40
+ * Merges namespace trees by node id.
41
+ *
42
+ * @param {object[]|undefined} left - base nodes
43
+ * @param {object[]|undefined} right - overlay nodes
44
+ * @returns {object[]} merged nodes
45
+ * @example
46
+ * mergeNodes([{ id: 'mill', nodes: [{ id: 'a' }] }], [{ id: 'mill', nodes: [{ id: 'b' }] }]);
47
+ */
48
+ function mergeNodes(left, right) {
49
+ const value = new Map();
50
+ (left || []).forEach((node) => {
51
+ value.set(node.id, node);
52
+ });
53
+ (right || []).forEach((node) => {
54
+ const prior = value.get(node.id);
55
+ if (!prior) {
56
+ value.set(node.id, node);
57
+ return;
58
+ }
59
+ const next = { ...prior, ...node };
60
+ const nodes = mergeNodes(prior.nodes, node.nodes);
61
+ if (nodes.length > 0) {
62
+ next.nodes = nodes;
63
+ } else {
64
+ delete next.nodes;
65
+ }
66
+ value.set(node.id, next);
67
+ });
68
+ return Array.from(value.values());
69
+ }
70
+
71
+ /**
72
+ * Overlays one system document on another.
73
+ *
74
+ * Maps and namespace trees merge. Listed servers replace. A `null`
75
+ * app drops that app. Omitted keys stay from the catalog.
76
+ *
77
+ * @param {object} base - catalog document
78
+ * @param {object} extra - contour overlay
79
+ * @returns {object} combined document
80
+ * @example
81
+ * mergeDocuments({ servers: [{ id: 'cell' }] }, { servers: [{ id: 'lab' }] });
82
+ */
83
+ export default function mergeDocuments(base, extra) {
84
+ const document = { ...base, ...extra };
85
+ if (base.templates || extra.templates) {
86
+ document.templates = { ...(base.templates || {}), ...(extra.templates || {}) };
87
+ }
88
+ if (base.mappings || extra.mappings) {
89
+ document.mappings = mergeMaps(base.mappings, extra.mappings);
90
+ }
91
+ if (base.profiles || extra.profiles) {
92
+ document.profiles = mergeMaps(base.profiles, extra.profiles);
93
+ }
94
+ if (base.namespace || extra.namespace) {
95
+ document.namespace = mergeNodes(base.namespace, extra.namespace);
96
+ }
97
+ return document;
98
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Joins a child id onto a parent path.
3
+ *
4
+ * @param {string} prefix - parent path
5
+ * @param {string} id - child id
6
+ * @returns {string} path
7
+ * @example
8
+ * joined('mill', 'unit');
9
+ */
10
+ function joined(prefix, id) {
11
+ if (prefix.length === 0) {
12
+ return id;
13
+ }
14
+ return `${prefix}/${id}`;
15
+ }
16
+
17
+ /**
18
+ * Patch entries the template does not already name.
19
+ *
20
+ * @param {object[]} base - template children
21
+ * @param {object[]|undefined} patch - node patch
22
+ * @returns {object[]} added nodes
23
+ * @example
24
+ * extras([{ id: 'voltage' }], [{ id: 'rotation_on' }]);
25
+ */
26
+ function extras(base, patch) {
27
+ return (patch || []).filter((entry) => {
28
+ return base.every((child) => {
29
+ return child.id !== entry.id;
30
+ });
31
+ });
32
+ }
33
+
34
+ /**
35
+ * Children of one namespace node, expanding its template.
36
+ *
37
+ * @param {object} node - namespace node
38
+ * @param {object} templates - reusable node lists
39
+ * @returns {object[]} children
40
+ * @example
41
+ * children({ id: 'unit', template: 'furnace' }, { furnace: [] });
42
+ */
43
+ function children(node, templates) {
44
+ if (!node.template) {
45
+ return node.nodes || [];
46
+ }
47
+ const base = templates[node.template];
48
+ if (!Array.isArray(base)) {
49
+ throw new Error(`system document has no template ${node.template}`);
50
+ }
51
+ return base.concat(extras(base, node.nodes));
52
+ }
53
+
54
+ /**
55
+ * Walks a namespace and records every path.
56
+ *
57
+ * @param {object[]|undefined} nodes - sibling nodes
58
+ * @param {string} prefix - parent path
59
+ * @param {object} templates - reusable node lists
60
+ * @param {string[]} found - accumulator
61
+ * @returns {void}
62
+ * @example
63
+ * walk([{ id: 'mill' }], '', {}, []);
64
+ */
65
+ function walk(nodes, prefix, templates, found) {
66
+ (nodes || []).forEach((node) => {
67
+ const next = joined(prefix, node.id);
68
+ found.push(next);
69
+ walk(children(node, templates), next, templates, found);
70
+ });
71
+ }
72
+
73
+ /**
74
+ * Lists namespace paths, expanding templates and added patch ids.
75
+ *
76
+ * @example
77
+ * paths({ namespace: [{ id: 'mill' }], templates: {} });
78
+ *
79
+ * @param {object} document - system document
80
+ * @returns {string[]} paths from the root
81
+ */
82
+ export default function paths(document) {
83
+ const found = [];
84
+ walk(document.namespace, '', document.templates || {}, found);
85
+ return found;
86
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Document path of each device point in a map.
3
+ *
4
+ * A point map key is the parameter. The published name is the flow path
5
+ * plus that parameter. The device point stays the map value.
6
+ *
7
+ * @example
8
+ * pointPaths('mill/electrical', { voltage: 'holding:4000:cdab' });
9
+ *
10
+ * @param {string} name - flow path
11
+ * @param {object} mapping - parameter id to device point
12
+ * @returns {object} device point to document path
13
+ */
14
+ export default function pointPaths(name, mapping) {
15
+ const paths = {};
16
+ Object.keys(mapping || {}).forEach((key) => {
17
+ paths[String(mapping[key])] = `${name}/${key}`;
18
+ });
19
+ return paths;
20
+ }