node-red-contrib-my-tools 1.0.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.
Files changed (67) hide show
  1. package/README.txt +69 -0
  2. package/node-red-contrib-mysql-extended-test/mysql-extended.html +443 -0
  3. package/node-red-contrib-mysql-extended-test/mysql-extended.js +355 -0
  4. package/node-red-contrib-rfid-helper/rfid-lazy.html +153 -0
  5. package/node-red-contrib-rfid-helper/rfid-lazy.js +361 -0
  6. package/node-red-contrib-s7-plus/LICENSE +235 -0
  7. package/node-red-contrib-s7-plus/README.md +141 -0
  8. package/node-red-contrib-s7-plus/examples/read-multiple-values-flow.json +346 -0
  9. package/node-red-contrib-s7-plus/examples/read-write-test-flow.json +172 -0
  10. package/node-red-contrib-s7-plus/examples/write-single-values-flow.json +5583 -0
  11. package/node-red-contrib-s7-plus/lib/s7plus/browse/README.md +72 -0
  12. package/node-red-contrib-s7-plus/lib/s7plus/browse/areas.js +36 -0
  13. package/node-red-contrib-s7-plus/lib/s7plus/browse/datatypes.js +91 -0
  14. package/node-red-contrib-s7-plus/lib/s7plus/browse/flat-browser.js +433 -0
  15. package/node-red-contrib-s7-plus/lib/s7plus/browse/index.js +22 -0
  16. package/node-red-contrib-s7-plus/lib/s7plus/browse/lazy.js +397 -0
  17. package/node-red-contrib-s7-plus/lib/s7plus/browse/node-id.js +22 -0
  18. package/node-red-contrib-s7-plus/lib/s7plus/browse/resolve-symbolic.js +181 -0
  19. package/node-red-contrib-s7-plus/lib/s7plus/browse/vte-helpers.js +49 -0
  20. package/node-red-contrib-s7-plus/lib/s7plus/buffer-stream.js +96 -0
  21. package/node-red-contrib-s7-plus/lib/s7plus/client.js +1238 -0
  22. package/node-red-contrib-s7-plus/lib/s7plus/constants.js +155 -0
  23. package/node-red-contrib-s7-plus/lib/s7plus/crc/index.js +3 -0
  24. package/node-red-contrib-s7-plus/lib/s7plus/crc/s7-crc32.js +70 -0
  25. package/node-red-contrib-s7-plus/lib/s7plus/crc/symbol-crc.js +271 -0
  26. package/node-red-contrib-s7-plus/lib/s7plus/debug.js +88 -0
  27. package/node-red-contrib-s7-plus/lib/s7plus/explore-result.js +41 -0
  28. package/node-red-contrib-s7-plus/lib/s7plus/item-address.js +55 -0
  29. package/node-red-contrib-s7-plus/lib/s7plus/pdu-explore.js +77 -0
  30. package/node-red-contrib-s7-plus/lib/s7plus/pdu-messages.js +359 -0
  31. package/node-red-contrib-s7-plus/lib/s7plus/pobject.js +197 -0
  32. package/node-red-contrib-s7-plus/lib/s7plus/pvalue-codec.js +435 -0
  33. package/node-red-contrib-s7-plus/lib/s7plus/pvalue.js +653 -0
  34. package/node-red-contrib-s7-plus/lib/s7plus/pvar-lists.js +336 -0
  35. package/node-red-contrib-s7-plus/lib/s7plus/read-result.js +91 -0
  36. package/node-red-contrib-s7-plus/lib/s7plus/s7p.js +266 -0
  37. package/node-red-contrib-s7-plus/lib/s7plus/tag-routing.js +30 -0
  38. package/node-red-contrib-s7-plus/lib/s7plus/transport/cotp-stream.js +335 -0
  39. package/node-red-contrib-s7-plus/lib/s7plus/transport/s7-transport.js +335 -0
  40. package/node-red-contrib-s7-plus/nodes/icons/s7complus.png +0 -0
  41. package/node-red-contrib-s7-plus/nodes/icons/s7complus.svg +17 -0
  42. package/node-red-contrib-s7-plus/nodes/s7complus-endpoint.js +825 -0
  43. package/node-red-contrib-s7-plus/nodes/s7complus-explore.js +91 -0
  44. package/node-red-contrib-s7-plus/nodes/s7complus-in.js +255 -0
  45. package/node-red-contrib-s7-plus/nodes/s7complus-out.js +182 -0
  46. package/node-red-contrib-s7-plus/nodes/s7complus.html +1541 -0
  47. package/node-red-contrib-s7-plus/nodes/s7complus.js +10 -0
  48. package/node-red-contrib-s7-plus/package.json +50 -0
  49. package/node-red-contrib-s7-plus/plc/s7-1500/DB_Arrays.db +17 -0
  50. package/node-red-contrib-s7-plus/plc/s7-1500/DB_Binary.db +16 -0
  51. package/node-red-contrib-s7-plus/plc/s7-1500/DB_BitStrings.db +36 -0
  52. package/node-red-contrib-s7-plus/plc/s7-1500/DB_CharacterStrings.db +42 -0
  53. package/node-red-contrib-s7-plus/plc/s7-1500/DB_DateAndTime.db +70 -0
  54. package/node-red-contrib-s7-plus/plc/s7-1500/DB_FloatingPoint.db +42 -0
  55. package/node-red-contrib-s7-plus/plc/s7-1500/DB_Integers.db +72 -0
  56. package/node-red-contrib-s7-plus/plc/s7-1500/DB_Timers.db +40 -0
  57. package/node-red-contrib-s7-plus/scripts/example-flow-shared.js +39 -0
  58. package/node-red-contrib-s7-plus/scripts/generate-read-multiple-flow.js +331 -0
  59. package/node-red-contrib-s7-plus/scripts/generate-rw-test-flow.js +690 -0
  60. package/node-red-contrib-s7-plus/scripts/generate-write-flow.js +476 -0
  61. package/node-red-contrib-s7-plus/scripts/layout-write-flow.js +156 -0
  62. package/node-red-contrib-s7-plus/scripts/run-tests.js +16 -0
  63. package/node-red-flowgobal-change/variable-change.html +425 -0
  64. package/node-red-flowgobal-change/variable-change.js +92 -0
  65. package/node-red-ui_media_viewer/ui_media_viewer.html +87 -0
  66. package/node-red-ui_media_viewer/ui_media_viewer.js +136 -0
  67. package/package.json +12 -0
@@ -0,0 +1,476 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { EP, DEFAULT_OUTPUT_FORMAT, endpointNode, globalConfigNode } = require('./example-flow-shared');
6
+
7
+ const TAB = 'write_single_tab';
8
+
9
+ // All coordinates are multiples of 20 so the nodes snap onto the Node-RED
10
+ // editor grid.
11
+ const ROW_H = 40; // vertical step between stacked inject rows
12
+ const GROUP_GAP = 40; // gap below a finished write group
13
+ const SECTION_GAP = 40; // extra gap between sections
14
+ const COMMENT_GAP = 40; // gap below a section header comment
15
+
16
+ // Grid-aligned LEFT edge of the three node columns: inject | write/read | debug.
17
+ // Node-RED centers a node on its stored x, so each node's x is derived as
18
+ // leftEdge + nodeWidth/2 (see leftAlignedX) to line up the left edges exactly.
19
+ const LEFT_INJECT = 80;
20
+ const LEFT_MID = 520;
21
+ const LEFT_DEBUG = 860;
22
+
23
+ // Scalar datatype sections. One write node per *_write symbol; the inject
24
+ // values are derived from every constant of that datatype defined in the DB.
25
+ // Hardware and system datatypes are intentionally excluded.
26
+ const SECTIONS = [
27
+ {
28
+ title: 'Binary',
29
+ db: 'DB_Binary',
30
+ items: [{ var: 'Bool_write', dt: 'Bool' }]
31
+ },
32
+ {
33
+ title: 'BitStrings',
34
+ db: 'DB_BitStrings',
35
+ items: [
36
+ { var: 'Byte_write', dt: 'Byte' },
37
+ { var: 'Word_write', dt: 'Word' },
38
+ { var: 'DWord_write', dt: 'DWord' },
39
+ { var: 'LWord_write', dt: 'LWord' }
40
+ ]
41
+ },
42
+ {
43
+ title: 'CharacterStrings',
44
+ db: 'DB_CharacterStrings',
45
+ items: [
46
+ { var: 'Char_write', dt: 'Char' },
47
+ { var: 'String_write', dt: 'String' },
48
+ { var: 'WChar_write', dt: 'WChar' },
49
+ { var: 'WString_write', dt: 'WString' }
50
+ ]
51
+ },
52
+ {
53
+ title: 'Integers',
54
+ db: 'DB_Integers',
55
+ items: [
56
+ { var: 'SInt_write', dt: 'SInt' },
57
+ { var: 'Int_write', dt: 'Int' },
58
+ { var: 'DInt_write', dt: 'DInt' },
59
+ { var: 'USInt_write', dt: 'USInt' },
60
+ { var: 'UInt_write', dt: 'UInt' },
61
+ { var: 'UDInt_write', dt: 'UDInt' },
62
+ { var: 'LInt_write', dt: 'LInt' },
63
+ { var: 'ULInt_write', dt: 'ULInt' }
64
+ ]
65
+ },
66
+ {
67
+ title: 'FloatingPoint',
68
+ db: 'DB_FloatingPoint',
69
+ items: [
70
+ { var: 'Real_write', dt: 'Real' },
71
+ { var: 'LReal_write', dt: 'LReal' }
72
+ ]
73
+ },
74
+ {
75
+ title: 'DateAndTime',
76
+ db: 'DB_DateAndTime',
77
+ items: [
78
+ { var: 'Date_write', dt: 'Date' },
79
+ { var: 'Time_Of_Day_write', dt: 'TimeOfDay' },
80
+ { var: 'Date_And_Time_write', dt: 'DateAndTime' },
81
+ { var: 'LTOD_write', dt: 'LTod' },
82
+ { var: 'LDT_write', dt: 'Ldt' },
83
+ { var: 'DTL_write', dt: 'Dtl' }
84
+ ]
85
+ },
86
+ {
87
+ title: 'Timers',
88
+ db: 'DB_Timers',
89
+ items: [
90
+ { var: 'Time_write', dt: 'Time' },
91
+ { var: 'S5Time_write', dt: 'S5Time' },
92
+ { var: 'LTime_write', dt: 'LTime' }
93
+ ]
94
+ }
95
+ ];
96
+
97
+ // Maps PLC datatype keywords (as written in the .db source) to the node datatype.
98
+ const PLC_TO_DT = {
99
+ Bool: 'Bool',
100
+ Byte: 'Byte', Word: 'Word', DWord: 'DWord', LWord: 'LWord',
101
+ Char: 'Char', String: 'String', WChar: 'WChar', WString: 'WString',
102
+ SInt: 'SInt', Int: 'Int', DInt: 'DInt',
103
+ USInt: 'USInt', UInt: 'UInt', UDInt: 'UDInt', LInt: 'LInt', ULInt: 'ULInt',
104
+ Real: 'Real', LReal: 'LReal',
105
+ Date: 'Date', Time_Of_Day: 'TimeOfDay', Date_And_Time: 'DateAndTime',
106
+ LTime_Of_Day: 'LTod', LDT: 'Ldt', DTL: 'Dtl',
107
+ Time: 'Time', S5Time: 'S5Time', LTime: 'LTime'
108
+ };
109
+
110
+ function slug(s) {
111
+ return s.replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_|_$/g, '').toLowerCase();
112
+ }
113
+
114
+ // ── Node-RED node-width model ────────────────────────────────────────────
115
+ // Node-RED measures the label in the editor font and sizes the node as
116
+ // w = max(100, 20 * ceil((labelPx + 50 + (hasInput ? 7 : 0)) / 20))
117
+ // then centers the node on its stored x (see editor-client view.js). The
118
+ // label font stack is "Helvetica Neue, Arial, Helvetica, sans-serif" at 14px;
119
+ // on Windows the first faces are absent, so labels render in Arial 14px.
120
+ // Replicating Arial's advance widths lets us compute the exact rendered width
121
+ // and align every column on a shared, grid-aligned LEFT edge.
122
+ const ARIAL_ADVANCE_1000 = {
123
+ ' ': 278, '!': 278, '"': 355, '#': 556, '$': 556, '%': 889, '&': 667, "'": 191,
124
+ '(': 333, ')': 333, '*': 389, '+': 584, ',': 278, '-': 333, '.': 278, '/': 278,
125
+ '0': 556, '1': 556, '2': 556, '3': 556, '4': 556, '5': 556, '6': 556, '7': 556,
126
+ '8': 556, '9': 556, ':': 278, ';': 278, '<': 584, '=': 584, '>': 584, '?': 556,
127
+ '@': 1015, 'A': 667, 'B': 667, 'C': 722, 'D': 722, 'E': 667, 'F': 611, 'G': 778,
128
+ 'H': 722, 'I': 278, 'J': 500, 'K': 667, 'L': 556, 'M': 833, 'N': 722, 'O': 778,
129
+ 'P': 667, 'Q': 778, 'R': 722, 'S': 667, 'T': 611, 'U': 722, 'V': 667, 'W': 944,
130
+ 'X': 667, 'Y': 667, 'Z': 611, '[': 278, '\\': 278, ']': 278, '^': 469, '_': 556,
131
+ '`': 333, 'a': 556, 'b': 556, 'c': 500, 'd': 556, 'e': 556, 'f': 278, 'g': 556,
132
+ 'h': 556, 'i': 222, 'j': 222, 'k': 500, 'l': 222, 'm': 833, 'n': 556, 'o': 556,
133
+ 'p': 556, 'q': 556, 'r': 333, 's': 500, 't': 278, 'u': 556, 'v': 500, 'w': 722,
134
+ 'x': 500, 'y': 500, 'z': 500, '{': 334, '|': 260, '}': 334, '~': 584
135
+ };
136
+
137
+ function labelPx(label) {
138
+ let units = 0;
139
+ for (const ch of String(label || '')) {
140
+ units += ARIAL_ADVANCE_1000[ch] != null ? ARIAL_ADVANCE_1000[ch] : 556;
141
+ }
142
+ return Math.round((units * 14) / 1000);
143
+ }
144
+
145
+ function nodeWidth(label, hasInput) {
146
+ return Math.max(100, 20 * Math.ceil((labelPx(label) + 50 + (hasInput ? 7 : 0)) / 20));
147
+ }
148
+
149
+ // Node-RED stores the node center in x; to align the LEFT edge of a column we
150
+ // offset each node by half its (grid-quantized) width.
151
+ function leftAlignedX(leftEdge, label, hasInput) {
152
+ return leftEdge + nodeWidth(label, hasInput) / 2;
153
+ }
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // S7 literal conversion: turn a DB constant value into a Node-RED inject
157
+ // payload that, when written, reproduces the value on the PLC.
158
+ // ---------------------------------------------------------------------------
159
+
160
+ function quoted(lit) {
161
+ const m = lit.match(/'([^']*)'/);
162
+ return m ? m[1] : '';
163
+ }
164
+
165
+ function isoDate(lit) {
166
+ const m = lit.match(/(\d{4})-(\d{2})-(\d{2})/);
167
+ return `${m[1]}-${m[2]}-${m[3]}T00:00:00.000Z`;
168
+ }
169
+
170
+ function isoDateTime(lit) {
171
+ const m = lit.match(/(\d{4})-(\d{2})-(\d{2})-(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?/);
172
+ const ms = (m[7] || '').padEnd(3, '0').slice(0, 3) || '000';
173
+ return `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}.${ms}Z`;
174
+ }
175
+
176
+ function todMs(lit) {
177
+ const m = lit.match(/(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?/);
178
+ const ms = m[4] ? Number((m[4] + '000').slice(0, 3)) : 0;
179
+ return String(((+m[1] * 3600) + (+m[2] * 60) + (+m[3])) * 1000 + ms);
180
+ }
181
+
182
+ function ltodNs(lit) {
183
+ const m = lit.match(/(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?/);
184
+ const ns = m[4] ? BigInt((m[4] + '000000000').slice(0, 9)) : 0n;
185
+ const secs = BigInt((+m[1]) * 3600 + (+m[2]) * 60 + (+m[3]));
186
+ return (secs * 1000000000n + ns).toString();
187
+ }
188
+
189
+ function durationMs(lit) {
190
+ const body = lit.replace(/^[A-Za-z0-9]+#/, '');
191
+ const neg = body.startsWith('-');
192
+ const s = body.replace(/^-/, '').replace(/_/g, '');
193
+ const units = { D: 86400000, H: 3600000, M: 60000, S: 1000, MS: 1 };
194
+ let ms = 0;
195
+ const re = /(\d+)(MS|D|H|M|S)/g;
196
+ let m;
197
+ while ((m = re.exec(s))) ms += (+m[1]) * units[m[2]];
198
+ return String(neg ? -ms : ms);
199
+ }
200
+
201
+ function durationNs(lit) {
202
+ const body = lit.replace(/^[A-Za-z0-9]+#/, '');
203
+ const neg = body.startsWith('-');
204
+ const s = body.replace(/^-/, '').replace(/_/g, '');
205
+ const units = {
206
+ D: 86400000000000n, H: 3600000000000n, M: 60000000000n, S: 1000000000n,
207
+ MS: 1000000n, US: 1000n, NS: 1n
208
+ };
209
+ let ns = 0n;
210
+ const re = /(\d+)(MS|US|NS|D|H|M|S)/g;
211
+ let m;
212
+ while ((m = re.exec(s))) ns += BigInt(m[1]) * units[m[2]];
213
+ return (neg ? -ns : ns).toString();
214
+ }
215
+
216
+ // Default payload for a constant that has no assignment in the DB BEGIN block
217
+ // (i.e. it keeps the datatype's start value).
218
+ const DEFAULTS = {
219
+ Bool: { p: 'false', t: 'bool' },
220
+ Byte: { p: '0', t: 'num' }, Word: { p: '0', t: 'num' },
221
+ DWord: { p: '0', t: 'num' }, LWord: { p: '0x0', t: 'str' },
222
+ Char: { p: ' ', t: 'str' }, WChar: { p: ' ', t: 'str' },
223
+ String: { p: '', t: 'str' }, WString: { p: '', t: 'str' },
224
+ SInt: { p: '0', t: 'num' }, Int: { p: '0', t: 'num' }, DInt: { p: '0', t: 'num' },
225
+ USInt: { p: '0', t: 'num' }, UInt: { p: '0', t: 'num' }, UDInt: { p: '0', t: 'num' },
226
+ LInt: { p: '0', t: 'str' }, ULInt: { p: '0', t: 'str' },
227
+ Real: { p: '0', t: 'num' }, LReal: { p: '0', t: 'num' },
228
+ Date: { p: '1990-01-01T00:00:00.000Z', t: 'str' },
229
+ DateAndTime: { p: '1990-01-01T00:00:00.000Z', t: 'str' },
230
+ Ldt: { p: '1970-01-01T00:00:00.000Z', t: 'str' },
231
+ Dtl: { p: '1970-01-01T00:00:00.000Z', t: 'str' },
232
+ TimeOfDay: { p: '0', t: 'num' }, LTod: { p: '0', t: 'str' },
233
+ Time: { p: '0', t: 'num' }, S5Time: { p: '0', t: 'num' }, LTime: { p: '0', t: 'str' }
234
+ };
235
+
236
+ // Converts a raw S7 literal (RHS of a := assignment) to an inject payload.
237
+ function literalToPayload(dt, lit) {
238
+ switch (dt) {
239
+ case 'Bool':
240
+ return { p: /true/i.test(lit) ? 'true' : 'false', t: 'bool' };
241
+ case 'Byte': case 'Word': case 'DWord':
242
+ return { p: '0x' + lit.replace(/^16#/i, '').replace(/_/g, '').toUpperCase(), t: 'num' };
243
+ // LWord is 64-bit: keep it a string so values > 2^53 survive intact
244
+ // (a JS "num" inject would round them via double precision).
245
+ case 'LWord':
246
+ return { p: '0x' + lit.replace(/^16#/i, '').replace(/_/g, '').toUpperCase(), t: 'str' };
247
+ case 'SInt': case 'Int': case 'DInt':
248
+ case 'USInt': case 'UInt': case 'UDInt':
249
+ return { p: lit.replace(/_/g, ''), t: 'num' };
250
+ // 64-bit integers: string transport to avoid double-precision loss.
251
+ case 'LInt': case 'ULInt':
252
+ return { p: lit.replace(/_/g, ''), t: 'str' };
253
+ case 'Real': case 'LReal':
254
+ return { p: lit.replace(/_/g, ''), t: 'num' };
255
+ case 'Char': case 'WChar': case 'String': case 'WString':
256
+ return { p: quoted(lit), t: 'str' };
257
+ case 'Date':
258
+ return { p: isoDate(lit), t: 'str' };
259
+ case 'DateAndTime': case 'Ldt': case 'Dtl':
260
+ return { p: isoDateTime(lit), t: 'str' };
261
+ case 'TimeOfDay':
262
+ return { p: todMs(lit), t: 'num' };
263
+ case 'LTod':
264
+ return { p: ltodNs(lit), t: 'str' };
265
+ case 'Time': case 'S5Time':
266
+ return { p: durationMs(lit), t: 'num' };
267
+ case 'LTime':
268
+ return { p: durationNs(lit), t: 'str' };
269
+ default:
270
+ throw new Error(`No literal converter for datatype ${dt}`);
271
+ }
272
+ }
273
+
274
+ // Reads a .db source and returns its constant presets (everything except the
275
+ // *_write test symbol) with the inject payload for each, in source order.
276
+ function parseConstants(db) {
277
+ const file = path.join(__dirname, '..', 'plc', 's7-1500', `${db}.db`);
278
+ const text = fs.readFileSync(file, 'utf8');
279
+
280
+ const varStart = text.search(/\n\s*VAR\b/);
281
+ const varEnd = text.indexOf('END_VAR');
282
+ const beginStart = text.indexOf('BEGIN');
283
+ if (varStart === -1 || varEnd === -1) {
284
+ throw new Error(`Cannot locate VAR block in ${db}.db`);
285
+ }
286
+
287
+ // Assigned values from the BEGIN block (skip struct-field assignments like
288
+ // "DTL_x.YEAR := ..."; the scalar "DTL_x := DTL#..." line wins).
289
+ const assigned = {};
290
+ if (beginStart !== -1) {
291
+ const beginBody = text.slice(beginStart);
292
+ for (const line of beginBody.split(/\r?\n/)) {
293
+ const m = line.match(/^\s*([A-Za-z_]\w*)\s*:=\s*(.+?);\s*$/);
294
+ if (m) assigned[m[1]] = m[2].trim();
295
+ }
296
+ }
297
+
298
+ const out = [];
299
+ for (const line of text.slice(varStart, varEnd).split(/\r?\n/)) {
300
+ const m = line.match(/^\s*([A-Za-z_]\w*)\s*(?:\{[^}]*\})?\s*:\s*([A-Za-z_]\w*)/);
301
+ if (!m) continue;
302
+ const name = m[1];
303
+ const plcType = m[2];
304
+ if (name === 'VAR' || /_write$/i.test(name)) continue;
305
+ const dt = PLC_TO_DT[plcType];
306
+ if (!dt) {
307
+ console.warn(`Skipping ${db}.${name}: unmapped PLC type ${plcType}`);
308
+ continue;
309
+ }
310
+ const lit = assigned[name];
311
+ const payload = lit != null ? literalToPayload(dt, lit) : DEFAULTS[dt];
312
+ out.push({ name, dt, payload: payload.p, payloadType: payload.t });
313
+ }
314
+ return out;
315
+ }
316
+
317
+ function injectNode(id, name, y, writeId, payload, payloadType) {
318
+ return {
319
+ id,
320
+ type: 'inject',
321
+ z: TAB,
322
+ name,
323
+ props: [{ p: 'payload' }],
324
+ repeat: '',
325
+ crontab: '',
326
+ once: false,
327
+ onceDelay: 0.1,
328
+ topic: '',
329
+ payload,
330
+ payloadType,
331
+ x: leftAlignedX(LEFT_INJECT, name, false),
332
+ y,
333
+ wires: [[writeId]]
334
+ };
335
+ }
336
+
337
+ function writeNode(id, name, y, debugId, symbol, datatype) {
338
+ return {
339
+ id,
340
+ type: 's7-plus write',
341
+ z: TAB,
342
+ name,
343
+ endpoint: EP,
344
+ symbols: [{ name: symbol, address: symbol, datatype }],
345
+ outputFormat: DEFAULT_OUTPUT_FORMAT,
346
+ x: leftAlignedX(LEFT_MID, name, true),
347
+ y,
348
+ wires: [[debugId]]
349
+ };
350
+ }
351
+
352
+ function readNode(id, name, y, debugId, symbol, datatype) {
353
+ return {
354
+ id,
355
+ type: 's7-plus read',
356
+ z: TAB,
357
+ name,
358
+ endpoint: EP,
359
+ symbols: [{ name: symbol, address: symbol, datatype }],
360
+ outputFormat: DEFAULT_OUTPUT_FORMAT,
361
+ x: leftAlignedX(LEFT_MID, name, true),
362
+ y,
363
+ wires: [[debugId]]
364
+ };
365
+ }
366
+
367
+ function debugNode(id, name, y, symbol) {
368
+ return {
369
+ id,
370
+ type: 'debug',
371
+ z: TAB,
372
+ name,
373
+ active: true,
374
+ tosidebar: true,
375
+ console: false,
376
+ tostatus: true,
377
+ complete: 'false',
378
+ statusVal: `payload["${symbol}"].value`,
379
+ statusType: 'msg',
380
+ x: leftAlignedX(LEFT_DEBUG, name, true),
381
+ y,
382
+ wires: []
383
+ };
384
+ }
385
+
386
+ function commentNode(id, name, y, info) {
387
+ return {
388
+ id,
389
+ type: 'comment',
390
+ z: TAB,
391
+ name,
392
+ info: info || '',
393
+ x: leftAlignedX(LEFT_INJECT, name, false),
394
+ y,
395
+ wires: []
396
+ };
397
+ }
398
+
399
+ const nodes = [];
400
+ let y = 60;
401
+
402
+ nodes.push({
403
+ id: TAB,
404
+ type: 'tab',
405
+ label: 'Write Single Values',
406
+ disabled: false,
407
+ info: 'Write tests for all scalar datatypes in plc/s7-1500.\n'
408
+ + 'For every datatype there is one s7-plus write node plus one inject per\n'
409
+ + 'constant defined in the matching DB; each inject writes that constant\n'
410
+ + 'value into the *_write symbol.\n'
411
+ + 'Below each write group a read-back row (trigger inject + s7-plus read +\n'
412
+ + 'debug) reads the same *_write tag back from the PLC for verification.\n'
413
+ + 'Hardware and system datatypes are intentionally excluded.',
414
+ env: []
415
+ });
416
+
417
+ nodes.push(commentNode('flow_hdr', 'Write single values — every plc/s7-1500 scalar constant', y));
418
+ y += ROW_H + SECTION_GAP;
419
+
420
+ for (const section of SECTIONS) {
421
+ nodes.push(commentNode(`sec_${slug(section.title)}`, section.title, y, section.db));
422
+ y += COMMENT_GAP;
423
+
424
+ const constants = parseConstants(section.db);
425
+ const groups = new Map();
426
+ for (const c of constants) {
427
+ if (!groups.has(c.dt)) groups.set(c.dt, []);
428
+ groups.get(c.dt).push(c);
429
+ }
430
+
431
+ for (const item of section.items) {
432
+ const members = groups.get(item.dt) || [];
433
+ if (!members.length) {
434
+ throw new Error(`No constants for ${section.db} datatype ${item.dt}`);
435
+ }
436
+
437
+ const symbol = `${section.db}.${item.var}`;
438
+ const writeId = `wr_${slug(section.title)}_${slug(item.var)}`;
439
+ const debugId = `db_${slug(section.title)}_${slug(item.var)}`;
440
+ const blockTop = y;
441
+
442
+ // Template layout: write + debug aligned to the first inject row.
443
+ nodes.push(writeNode(writeId, item.var, blockTop, debugId, symbol, item.dt));
444
+ nodes.push(debugNode(debugId, `${item.dt} result`, blockTop, symbol));
445
+
446
+ for (let i = 0; i < members.length; i++) {
447
+ const c = members[i];
448
+ const injId = `inj_${slug(section.title)}_${slug(c.name)}`;
449
+ nodes.push(injectNode(injId, c.name, blockTop + i * ROW_H, writeId, c.payload, c.payloadType));
450
+ }
451
+
452
+ y = blockTop + members.length * ROW_H + GROUP_GAP;
453
+
454
+ // Read-back block: a manual trigger inject reads the same *_write tag
455
+ // back from the PLC so the written value can be verified.
456
+ const readTop = y;
457
+ const readId = `rd_${slug(section.title)}_${slug(item.var)}`;
458
+ const readDebugId = `dbr_${slug(section.title)}_${slug(item.var)}`;
459
+ const trigId = `trg_${slug(section.title)}_${slug(item.var)}`;
460
+
461
+ nodes.push(injectNode(trigId, `Read ${item.var}`, readTop, readId, '', 'date'));
462
+ nodes.push(readNode(readId, item.var, readTop, readDebugId, symbol, item.dt));
463
+ nodes.push(debugNode(readDebugId, `${item.dt} read`, readTop, symbol));
464
+
465
+ y = readTop + ROW_H + GROUP_GAP;
466
+ }
467
+
468
+ y += SECTION_GAP;
469
+ }
470
+
471
+ nodes.push(endpointNode());
472
+ nodes.push(globalConfigNode('0.0.1'));
473
+
474
+ const out = path.join(__dirname, '..', 'examples', 'write-single-values-flow.json');
475
+ fs.writeFileSync(out, JSON.stringify(nodes, null, 4) + '\n');
476
+ console.log(`Wrote ${nodes.length} nodes to ${out}`);
@@ -0,0 +1,156 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Re-layout write-single-values-flow.json into three left-aligned columns:
5
+ * inject (left) | s7-plus write / s7-plus read (center) | debug (right)
6
+ *
7
+ * Node-RED stores the node center in x, so columns are left-aligned by
8
+ * offsetting each node by half its estimated width.
9
+ *
10
+ * Usage: node scripts/layout-write-flow.js [path-to-flow.json]
11
+ */
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+
16
+ const FLOW_PATH = process.argv[2]
17
+ || path.join(__dirname, '..', 'examples', 'write-single-values-flow.json');
18
+
19
+ const TAB = 'write_single_tab';
20
+
21
+ // All coordinates are multiples of 20 to snap onto the Node-RED editor grid.
22
+ const ROW_H = 40; // vertical step between stacked inject rows
23
+ const GROUP_GAP = 40; // gap below a finished write group
24
+ const SECTION_GAP = 40; // gap below the flow header
25
+ const COMMENT_GAP = 40; // gap below a section header comment
26
+
27
+ // Grid-aligned LEFT edge of the three node columns: inject | write/read | debug.
28
+ // Node-RED centers a node on its stored x, so each node's x is derived as
29
+ // leftEdge + nodeWidth/2 to line up the left edges exactly.
30
+ const LEFT_INJECT = 80;
31
+ const LEFT_MID = 520;
32
+ const LEFT_DEBUG = 860;
33
+
34
+ const ACTION_TYPES = new Set(['s7-plus write', 's7-plus read']);
35
+
36
+ // Node-RED node-width model (see generate-write-flow.js for details): labels
37
+ // render in Arial 14px on Windows; node width is
38
+ // w = max(100, 20 * ceil((labelPx + 50 + (hasInput ? 7 : 0)) / 20)).
39
+ const ARIAL_ADVANCE_1000 = {
40
+ ' ': 278, '!': 278, '"': 355, '#': 556, '$': 556, '%': 889, '&': 667, "'": 191,
41
+ '(': 333, ')': 333, '*': 389, '+': 584, ',': 278, '-': 333, '.': 278, '/': 278,
42
+ '0': 556, '1': 556, '2': 556, '3': 556, '4': 556, '5': 556, '6': 556, '7': 556,
43
+ '8': 556, '9': 556, ':': 278, ';': 278, '<': 584, '=': 584, '>': 584, '?': 556,
44
+ '@': 1015, 'A': 667, 'B': 667, 'C': 722, 'D': 722, 'E': 667, 'F': 611, 'G': 778,
45
+ 'H': 722, 'I': 278, 'J': 500, 'K': 667, 'L': 556, 'M': 833, 'N': 722, 'O': 778,
46
+ 'P': 667, 'Q': 778, 'R': 722, 'S': 667, 'T': 611, 'U': 722, 'V': 667, 'W': 944,
47
+ 'X': 667, 'Y': 667, 'Z': 611, '[': 278, '\\': 278, ']': 278, '^': 469, '_': 556,
48
+ '`': 333, 'a': 556, 'b': 556, 'c': 500, 'd': 556, 'e': 556, 'f': 278, 'g': 556,
49
+ 'h': 556, 'i': 222, 'j': 222, 'k': 500, 'l': 222, 'm': 833, 'n': 556, 'o': 556,
50
+ 'p': 556, 'q': 556, 'r': 333, 's': 500, 't': 278, 'u': 556, 'v': 500, 'w': 722,
51
+ 'x': 500, 'y': 500, 'z': 500, '{': 334, '|': 260, '}': 334, '~': 584
52
+ };
53
+
54
+ function labelPx(label) {
55
+ let units = 0;
56
+ for (const ch of String(label || '')) {
57
+ units += ARIAL_ADVANCE_1000[ch] != null ? ARIAL_ADVANCE_1000[ch] : 556;
58
+ }
59
+ return Math.round((units * 14) / 1000);
60
+ }
61
+
62
+ function nodeWidth(label, hasInput) {
63
+ return Math.max(100, 20 * Math.ceil((labelPx(label) + 50 + (hasInput ? 7 : 0)) / 20));
64
+ }
65
+
66
+ function leftAlignedX(leftEdge, label, hasInput) {
67
+ return leftEdge + nodeWidth(label, hasInput) / 2;
68
+ }
69
+
70
+ const nodes = JSON.parse(fs.readFileSync(FLOW_PATH, 'utf8'));
71
+ const byId = Object.fromEntries(nodes.map((n) => [n.id, n]));
72
+
73
+ const tabNodes = nodes.filter((n) => n.z === TAB);
74
+ const actions = tabNodes.filter((n) => ACTION_TYPES.has(n.type));
75
+ const injects = tabNodes.filter((n) => n.type === 'inject');
76
+ const debugs = tabNodes.filter((n) => n.type === 'debug');
77
+ const comments = tabNodes.filter((n) => n.type === 'comment');
78
+
79
+ const injectsByAction = new Map();
80
+ for (const inj of injects) {
81
+ const targetId = inj.wires?.[0]?.[0];
82
+ if (!targetId) continue;
83
+ if (!injectsByAction.has(targetId)) injectsByAction.set(targetId, []);
84
+ injectsByAction.get(targetId).push(inj);
85
+ }
86
+
87
+ const debugByAction = new Map();
88
+ for (const act of actions) {
89
+ const debugId = act.wires?.[0]?.[0];
90
+ if (debugId && byId[debugId]?.type === 'debug') {
91
+ debugByAction.set(act.id, byId[debugId]);
92
+ }
93
+ }
94
+
95
+ // Preserve visual order from current canvas (top → bottom, left → right).
96
+ const ordered = [...comments, ...actions].sort((a, b) => a.y - b.y || a.x - b.x);
97
+
98
+ let y = 60;
99
+
100
+ const header = comments.find((c) => c.id === 'flow_hdr')
101
+ || comments.sort((a, b) => a.y - b.y)[0];
102
+ if (header) {
103
+ header.x = leftAlignedX(LEFT_INJECT, header.name, false);
104
+ header.y = y;
105
+ y += ROW_H + SECTION_GAP;
106
+ }
107
+
108
+ for (const item of ordered) {
109
+ if (item === header) continue;
110
+
111
+ if (item.type === 'comment') {
112
+ item.x = leftAlignedX(LEFT_INJECT, item.name, false);
113
+ item.y = y;
114
+ y += COMMENT_GAP;
115
+ continue;
116
+ }
117
+
118
+ if (!ACTION_TYPES.has(item.type)) continue;
119
+
120
+ const groupInjects = (injectsByAction.get(item.id) || [])
121
+ .sort((a, b) => a.y - b.y || a.name.localeCompare(b.name));
122
+ const dbg = debugByAction.get(item.id);
123
+
124
+ const blockTop = y;
125
+
126
+ for (let i = 0; i < groupInjects.length; i++) {
127
+ const inj = groupInjects[i];
128
+ inj.x = leftAlignedX(LEFT_INJECT, inj.name, false);
129
+ inj.y = blockTop + i * ROW_H;
130
+ }
131
+
132
+ // Write/read + debug align to the first inject row (template layout).
133
+ item.x = leftAlignedX(LEFT_MID, item.name, true);
134
+ item.y = blockTop;
135
+
136
+ if (dbg) {
137
+ dbg.x = leftAlignedX(LEFT_DEBUG, dbg.name, true);
138
+ dbg.y = blockTop;
139
+ }
140
+
141
+ const blockRows = Math.max(groupInjects.length, 1);
142
+ y = blockTop + blockRows * ROW_H + GROUP_GAP;
143
+ }
144
+
145
+ fs.writeFileSync(FLOW_PATH, JSON.stringify(nodes, null, 4) + '\n');
146
+
147
+ const summary = {
148
+ path: FLOW_PATH,
149
+ actions: actions.length,
150
+ injects: injects.length,
151
+ debugs: debugs.length,
152
+ comments: comments.length,
153
+ canvasHeight: y,
154
+ columns: { inject: LEFT_INJECT, mid: LEFT_MID, debug: LEFT_DEBUG }
155
+ };
156
+ console.log(JSON.stringify(summary, null, 2));
@@ -0,0 +1,16 @@
1
+ 'use strict';
2
+
3
+ const { run } = require('node:test');
4
+ const { spec } = require('node:test/reporters');
5
+ const fs = require('node:fs');
6
+ const path = require('node:path');
7
+
8
+ const testDir = path.join(__dirname, '..', 'test');
9
+ const files = fs.readdirSync(testDir)
10
+ .filter((f) => f.endsWith('.test.js'))
11
+ .map((f) => path.join(testDir, f));
12
+
13
+ run({ files, concurrency: true })
14
+ .on('test:fail', () => { process.exitCode = 1; })
15
+ .compose(new spec())
16
+ .pipe(process.stdout);