@yarkivaev/scada 2.3.90 → 2.3.113

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 +88 -0
  4. package/charts/scada/templates/clickhouse.yaml +62 -0
  5. package/charts/scada/templates/collector.yaml +114 -0
  6. package/charts/scada/templates/postgres.yaml +67 -0
  7. package/charts/scada/templates/rabbitmq.yaml +174 -0
  8. package/charts/scada/templates/site-server.yaml +107 -0
  9. package/charts/scada/templates/supervisor-sink.yaml +62 -0
  10. package/charts/scada/values.yaml +40 -0
  11. package/index.js +10 -0
  12. package/package.json +3 -1
  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,115 @@
1
+ import expand from './expand.js';
2
+ import { assertHeld, edgesOf } from './releaseEdges.js';
3
+ import { profileFor, serverFor } from './releaseNodes.js';
4
+ import { bindModules, busesFor, machinesOf, passedApps, routesFor } from './releaseValues.js';
5
+ import frozen from './frozen.js';
6
+
7
+ /**
8
+ * Starts release values from the scada app and the profile modules.
9
+ *
10
+ * @param {object} profile - resolved profile
11
+ * @returns {object} image and module values
12
+ * @example
13
+ * withImage({ apps: { scada: { image: 'example.invalid/plant:1' } }, modules: {} });
14
+ */
15
+ function withImage(profile) {
16
+ const app = profile.apps && profile.apps.scada;
17
+ const image = app && app.image;
18
+ if (!image) {
19
+ throw new Error('system document has no scada image');
20
+ }
21
+ return { image, ...bindModules(profile, app.secret) };
22
+ }
23
+
24
+ /**
25
+ * Copies process fields the chart reads beside the image.
26
+ *
27
+ * @param {object} values - release values
28
+ * @param {object} profile - resolved profile
29
+ * @param {object} server - selected server
30
+ * @returns {object} values with command, service, env, and pull secret
31
+ * @example
32
+ * withProcess({ image: 'example.invalid/plant:1' }, { apps: { scada: {} } }, {});
33
+ */
34
+ function withProcess(values, profile, server) {
35
+ const app = profile.apps && profile.apps.scada;
36
+ const copy = { ...values };
37
+ if (profile.command) {
38
+ copy.command = profile.command;
39
+ }
40
+ if (app && app.pullSecret) {
41
+ copy.pullSecret = app.pullSecret;
42
+ }
43
+ if (server.service) {
44
+ copy.service = server.service;
45
+ }
46
+ if (profile.env) {
47
+ copy.env = profile.env;
48
+ }
49
+ return copy;
50
+ }
51
+
52
+ /**
53
+ * Copies machines, sites, and bus addresses when the server has any.
54
+ *
55
+ * @param {object} values - release values
56
+ * @param {object[]} owned - local fan-in records
57
+ * @param {object[]} routes - incoming sites
58
+ * @param {object[]} buses - fan-out bus bindings
59
+ * @returns {object} values with those lists
60
+ * @example
61
+ * withFlows({ image: 'example.invalid/plant:1' }, [], [], []);
62
+ */
63
+ function withFlows(values, owned, routes, buses) {
64
+ const copy = { ...values };
65
+ if (owned.length > 0) {
66
+ copy.machines = owned;
67
+ }
68
+ if (routes.length > 0) {
69
+ copy.sites = routes;
70
+ }
71
+ if (buses.length > 0) {
72
+ copy.buses = buses;
73
+ }
74
+ return copy;
75
+ }
76
+
77
+ /**
78
+ * Chart values for one server.
79
+ *
80
+ * @param {object} document - expanded document
81
+ * @param {object} server - selected server
82
+ * @param {object} profile - resolved profile
83
+ * @param {object[]} edges - directed edges
84
+ * @returns {object} scada release values
85
+ * @example
86
+ * scadaRelease({ servers: [] }, { fan_in: [], fan_out: [], id: 'cell' }, { apps: { scada: { image: 'example.invalid/plant:1' } } }, []);
87
+ */
88
+ function scadaRelease(document, server, profile, edges) {
89
+ const buses = busesFor(document, server);
90
+ const owned = machinesOf(server);
91
+ const routes = routesFor(document, edges, server);
92
+ const values = withImage(profile);
93
+ return withFlows(withProcess(values, profile, server), owned, routes, buses);
94
+ }
95
+
96
+ /**
97
+ * Cuts one cluster out of a system document.
98
+ *
99
+ * @param {object} document - system document
100
+ * @param {string} cluster - release selector
101
+ * @returns {{apps: object[], scada: object}} scada values and the profile's other apps
102
+ * @example
103
+ * releases({ profiles: {}, servers: [] }, 'missing');
104
+ */
105
+ export default function releases(document, cluster) {
106
+ const expanded = expand(document);
107
+ const host = serverFor(expanded, cluster);
108
+ const links = edgesOf(expanded);
109
+ const profile = profileFor(expanded, host);
110
+ assertHeld(expanded, links);
111
+ return frozen({
112
+ apps: passedApps(profile),
113
+ scada: scadaRelease(expanded, host, profile, links)
114
+ });
115
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Adds collector rows the chart groups by command.
3
+ *
4
+ * The plant supplies the argv. Mapping is copied as the machine has it.
5
+ *
6
+ * @example
7
+ * withCollectors({ machines: [{ from: 'modbus://192.0.2.10:502', name: 'mill/electrical' }] }, () => { return ['node', 'bin/modbus-to-mqtt.js']; });
8
+ *
9
+ * @param {object} values - release values
10
+ * @param {function} command - address and stream to argv
11
+ * @returns {object} values whose collectors carry command, address, and path
12
+ */
13
+ export default function withCollectors(values, command) {
14
+ return {
15
+ ...values,
16
+ collectors: (values.machines || []).map((machine) => {
17
+ const item = {
18
+ address: machine.from,
19
+ command: command(machine.from, machine.name),
20
+ path: machine.name
21
+ };
22
+ if (machine.mapping) {
23
+ item.mapping = machine.mapping;
24
+ }
25
+ return item;
26
+ })
27
+ };
28
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Modbus collector entry point.
3
+ *
4
+ * Polls ADDRESS or MODBUS_DEVICES and publishes STREAM + MAPPING document paths.
5
+ */
6
+ process.on('unhandledRejection', (err) => {
7
+ process.stderr.write(`Unhandled rejection: ${String(err)}\n`);
8
+ process.exit(1);
9
+ });
10
+
11
+ import modbusPublish from '../infrastructure/ingest/mqtt/modbusPublish.js';
12
+
13
+ /**
14
+ * Parses a JSON array environment value.
15
+ *
16
+ * @param {string|undefined} text - environment value
17
+ * @returns {Array|undefined} parsed array
18
+ */
19
+ function jsonList(text) {
20
+ if (typeof text !== 'string' || !text.startsWith('[')) {
21
+ return undefined;
22
+ }
23
+ return JSON.parse(text);
24
+ }
25
+
26
+ /**
27
+ * Parses an optional integer environment value.
28
+ *
29
+ * @param {string|undefined} text - environment value
30
+ * @returns {number|undefined} parsed integer
31
+ */
32
+ function integer(text) {
33
+ if (typeof text !== 'string' || text.length === 0) {
34
+ return undefined;
35
+ }
36
+ const value = parseInt(text, 10);
37
+ if (Number.isNaN(value)) {
38
+ throw new Error(`Expected a number, got: ${text}`);
39
+ }
40
+ return value;
41
+ }
42
+
43
+ const addresses = jsonList(process.env.ADDRESS);
44
+ const pipeline = modbusPublish(process.env.MQTT_URL, {
45
+ address: integer(process.env.MODBUS_ADDRESS),
46
+ addresses,
47
+ clientId: process.env.CLIENT_ID,
48
+ count: integer(process.env.MODBUS_COUNT),
49
+ devices: process.env.MODBUS_DEVICES,
50
+ interval: parseInt(process.env.POLL_INTERVAL || '5', 10),
51
+ maps: jsonList(process.env.MAPPING) || [],
52
+ streams: jsonList(process.env.STREAM) || [],
53
+ threshold: parseInt(process.env.CIRCUIT_THRESHOLD || '5', 10),
54
+ timeout: parseInt(process.env.CIRCUIT_TIMEOUT || '60', 10)
55
+ });
56
+
57
+ pipeline.start();
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Numeric query parameter on a device address.
3
+ *
4
+ * @param {URL} url - device address
5
+ * @param {string} key - query key
6
+ * @returns {number|undefined} parsed value
7
+ */
8
+ function queryNumber(url, key) {
9
+ const text = url.searchParams.get(key);
10
+ if (text === null) {
11
+ return undefined;
12
+ }
13
+ const value = parseInt(text, 10);
14
+ if (Number.isNaN(value) || value < 0) {
15
+ throw new Error(`Address ${key} must be a non-negative number, got: ${text}`);
16
+ }
17
+ return value;
18
+ }
19
+
20
+ /**
21
+ * Modbus TCP spec from a modbus URL.
22
+ *
23
+ * @param {string} name - device name
24
+ * @param {URL} url - device address
25
+ * @returns {string} name:host:port
26
+ */
27
+ function tcpSpec(name, url) {
28
+ const port = parseInt(url.port, 10);
29
+ if (url.hostname.length === 0 || Number.isNaN(port) || port <= 0) {
30
+ throw new Error(`Modbus address must be modbus://host:port, got: ${url.href}`);
31
+ }
32
+ return `${name}:${url.hostname}:${port}`;
33
+ }
34
+
35
+ /**
36
+ * Modbus RTU spec from an rtu URL.
37
+ *
38
+ * @param {string} name - device name
39
+ * @param {URL} url - device address
40
+ * @returns {string} name:rtu:path:baud:line:slave
41
+ */
42
+ function rtuSpec(name, url) {
43
+ const parts = url.pathname.split(':');
44
+ const path = parts[0];
45
+ const baud = parseInt(parts[1], 10);
46
+ const line = parts[2] || '8N1';
47
+ const slave = parts[3] || '1';
48
+ if (!path.startsWith('/dev/') || Number.isNaN(baud) || baud <= 0) {
49
+ throw new Error(`RTU address must be rtu:///dev/ttyX:baud:line:slave, got: ${url.href}`);
50
+ }
51
+ return `${name}:rtu:${path}:${baud}:${line}:${slave}`;
52
+ }
53
+
54
+ /**
55
+ * Connection spec and optional register window for one device URL.
56
+ *
57
+ * TCP addresses are `modbus://host:port`. RTU addresses are
58
+ * `rtu:///dev/ttyX:baud:line:slave`. `at` and `count` are the poll window.
59
+ *
60
+ * @example
61
+ * addressWindow('modbus://192.0.2.11:502?at=4000&count=2', 'bay/voltage');
62
+ *
63
+ * @param {string} address - device URL
64
+ * @param {string} name - device name in the modbus spec
65
+ * @returns {{address: number|undefined, count: number|undefined, spec: string}} connection
66
+ */
67
+ export default function addressWindow(address, name) {
68
+ const url = new URL(address);
69
+ const spec = url.protocol === 'modbus:' ? tcpSpec(name, url) : '';
70
+ const serial = url.protocol === 'rtu:' ? rtuSpec(name, url) : spec;
71
+ if (serial.length === 0) {
72
+ throw new Error(`Device address scheme must be modbus or rtu, got: ${address}`);
73
+ }
74
+ return Object.freeze({
75
+ address: queryNumber(url, 'at'),
76
+ count: queryNumber(url, 'count'),
77
+ spec: serial
78
+ });
79
+ }
@@ -0,0 +1,277 @@
1
+ import addressWindow from './addressWindow.js';
2
+ import modbusMqtt from './modbusMqtt.js';
3
+ import { parsePoint } from './registerPoint.js';
4
+ import registerPublish, { measurementPlans } from './registerPublish.js';
5
+
6
+ export { measurementPlans };
7
+
8
+ /**
9
+ * Device name used in a modbus spec.
10
+ *
11
+ * The document path is the name when it has no colon. An empty stream
12
+ * gets a positional name.
13
+ *
14
+ * @param {string} stream - document path
15
+ * @param {number} index - device index
16
+ * @returns {string} spec name
17
+ */
18
+ function deviceName(stream, index) {
19
+ if (typeof stream !== 'string' || stream.length === 0) {
20
+ return `device-${index}`;
21
+ }
22
+ if (stream.includes(':')) {
23
+ throw new Error(`Document path must not contain a colon, got: ${stream}`);
24
+ }
25
+ return stream;
26
+ }
27
+
28
+ /**
29
+ * Stream path at one device index.
30
+ *
31
+ * @param {object} config - pipeline config
32
+ * @param {number} index - device index
33
+ * @returns {string} document path or empty
34
+ */
35
+ function streamOf(config, index) {
36
+ const streams = Array.isArray(config.streams) ? config.streams : [];
37
+ return typeof streams[index] === 'string' ? streams[index] : '';
38
+ }
39
+
40
+ /**
41
+ * Point map at one device index.
42
+ *
43
+ * @param {object} config - pipeline config
44
+ * @param {number} index - device index
45
+ * @returns {object} parameter id to register point
46
+ */
47
+ function mapOf(config, index) {
48
+ const maps = Array.isArray(config.maps) ? config.maps : [];
49
+ return maps[index] || {};
50
+ }
51
+
52
+ /**
53
+ * Devices from ADDRESS urls or a MODBUS_DEVICES list.
54
+ *
55
+ * @param {object} config - pipeline config
56
+ * @returns {Array<object>} connection rows
57
+ */
58
+ function listed(config) {
59
+ const addresses = Array.isArray(config.addresses) ? config.addresses : [];
60
+ if (addresses.length > 0) {
61
+ return addresses.map((address, index) => {
62
+ const name = deviceName(streamOf(config, index), index);
63
+ return { ...addressWindow(address, name), name };
64
+ });
65
+ }
66
+ const devices = typeof config.devices === 'string' ? config.devices : '';
67
+ if (devices.length === 0) {
68
+ throw new Error('Modbus publish needs ADDRESS or MODBUS_DEVICES');
69
+ }
70
+ return devices.split(',').map((spec) => {
71
+ const trimmed = spec.trim();
72
+ return {
73
+ address: undefined,
74
+ count: undefined,
75
+ name: trimmed.split(':')[0],
76
+ spec: trimmed
77
+ };
78
+ });
79
+ }
80
+
81
+ /**
82
+ * Requires STREAM and MAPPING to match the device count when present.
83
+ *
84
+ * @param {object} config - pipeline config
85
+ * @param {number} count - device count
86
+ */
87
+ function assertAligned(config, count) {
88
+ const maps = Array.isArray(config.maps) ? config.maps : [];
89
+ const streams = Array.isArray(config.streams) ? config.streams : [];
90
+ if (maps.length > 0 && maps.length !== count) {
91
+ throw new Error(`MAPPING has ${maps.length} maps for ${count} devices`);
92
+ }
93
+ if (streams.length > 0 && streams.length !== count) {
94
+ throw new Error(`STREAM has ${streams.length} paths for ${count} devices`);
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Requires unique device names.
100
+ *
101
+ * @param {Array<object>} rows - connection rows
102
+ */
103
+ function assertNames(rows) {
104
+ const seen = new Set();
105
+ rows.forEach((row) => {
106
+ if (seen.has(row.name) || row.name.length === 0) {
107
+ throw new Error(`Device name must be unique and non-empty, got: ${row.name}`);
108
+ }
109
+ seen.add(row.name);
110
+ });
111
+ }
112
+
113
+ /**
114
+ * Lowest and highest PDU touched by a publish plan.
115
+ *
116
+ * @param {Array<{point: string}>} points - publish plan
117
+ * @returns {{end: number|undefined, start: number|undefined}} span
118
+ */
119
+ function cover(points) {
120
+ if (points.length === 0) {
121
+ return { end: undefined, start: undefined };
122
+ }
123
+ return points.reduce((box, item) => {
124
+ const parsed = parsePoint(item.point);
125
+ return {
126
+ end: Math.max(box.end, parsed.address + parsed.width),
127
+ start: Math.min(box.start, parsed.address)
128
+ };
129
+ }, { end: 0, start: Number.MAX_SAFE_INTEGER });
130
+ }
131
+
132
+ /**
133
+ * Rejects a point that falls outside the poll window.
134
+ *
135
+ * @param {Array<{point: string}>} points - publish plan
136
+ * @param {number} address - window start
137
+ * @param {number} count - window length
138
+ * @param {string} name - device name
139
+ */
140
+ function assertFit(points, address, count, name) {
141
+ points.forEach((item) => {
142
+ const parsed = parsePoint(item.point);
143
+ const index = parsed.address - address;
144
+ if (index < 0 || index + parsed.width > count) {
145
+ throw new Error(`Register point ${item.point} is outside ${address}+${count} on ${name}`);
146
+ }
147
+ });
148
+ }
149
+
150
+ /**
151
+ * Poll window for one device.
152
+ *
153
+ * Query `at` and `count` win, then config, then the span of the points.
154
+ *
155
+ * @param {object} row - connection row
156
+ * @param {Array<{point: string}>} points - publish plan
157
+ * @param {object} config - pipeline config
158
+ * @returns {{address: number, count: number}} window
159
+ */
160
+ function resolveWindow(row, points, config) {
161
+ const box = cover(points);
162
+ const address = row.address ?? config.address ?? box.start;
163
+ const count = row.count ?? config.count ?? (box.end - address);
164
+ if (!Number.isInteger(address) || !Number.isInteger(count) || count <= 0) {
165
+ throw new Error(`Register window is missing for ${row.name}`);
166
+ }
167
+ assertFit(points, address, count, row.name);
168
+ return { address, count };
169
+ }
170
+
171
+ /**
172
+ * One shared window for every device in the process.
173
+ *
174
+ * @param {Array<object>} rows - connection rows
175
+ * @param {Array<Array<{point: string}>>} plans - publish plans
176
+ * @param {object} config - pipeline config
177
+ * @returns {{address: number, count: number}} window
178
+ */
179
+ function oneWindow(rows, plans, config) {
180
+ const windows = rows.map((row, index) => {
181
+ return resolveWindow(row, plans[index], config);
182
+ });
183
+ const first = windows[0];
184
+ const mismatch = windows.some((item) => {
185
+ return item.address !== first.address || item.count !== first.count;
186
+ });
187
+ if (mismatch) {
188
+ throw new Error('Modbus collectors on one process must share one register window');
189
+ }
190
+ return first;
191
+ }
192
+
193
+ /**
194
+ * Publish plans in device order.
195
+ *
196
+ * @param {Array<object>} rows - connection rows
197
+ * @param {object} config - pipeline config
198
+ * @returns {Array<Array<{point: string, topic: string}>>} plans
199
+ */
200
+ function plansFor(rows, config) {
201
+ return rows.map((row, index) => {
202
+ return measurementPlans(streamOf(config, index), mapOf(config, index));
203
+ });
204
+ }
205
+
206
+ /**
207
+ * Validates modbusPublish arguments.
208
+ *
209
+ * @param {string} mqtt - MQTT broker URL
210
+ * @param {object} config - pipeline config
211
+ */
212
+ function assertPublish(mqtt, config) {
213
+ if (typeof mqtt !== 'string' || mqtt.length === 0) {
214
+ throw new Error('MQTT URL must be a non-empty string');
215
+ }
216
+ if (!config || typeof config !== 'object') {
217
+ throw new Error('Config must be an object');
218
+ }
219
+ if (typeof config.interval !== 'number' || typeof config.threshold !== 'number') {
220
+ throw new Error('interval, threshold, and timeout must be numbers');
221
+ }
222
+ if (typeof config.timeout !== 'number') {
223
+ throw new Error('interval, threshold, and timeout must be numbers');
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Polls Modbus and publishes measurements named by STREAM and MAPPING.
229
+ *
230
+ * A point is `holding:<pdu>`, `holding:<pdu>:abcd`, or `holding:<pdu>:cdab`.
231
+ * The topic is the document path `stream/parameter`. Devices come from
232
+ * ADDRESS urls or a MODBUS_DEVICES list. One process shares one window.
233
+ *
234
+ * @example
235
+ * const pipeline = modbusPublish('mqtt://127.0.0.1:1883', {
236
+ * addresses: ['modbus://192.0.2.11:502?at=4000&count=2'],
237
+ * interval: 5,
238
+ * maps: [{ voltage: 'holding:4000:abcd' }],
239
+ * streams: ['mill/electrical'],
240
+ * threshold: 5,
241
+ * timeout: 60
242
+ * });
243
+ * pipeline.start();
244
+ *
245
+ * @param {string} mqtt - MQTT broker URL
246
+ * @param {object} config - addresses or devices, streams, maps, interval, threshold, timeout
247
+ * @returns {object} pipeline with start() and stop()
248
+ */
249
+ export default function modbusPublish(mqtt, config) {
250
+ assertPublish(mqtt, config);
251
+ const rows = listed(config);
252
+ assertAligned(config, rows.length);
253
+ assertNames(rows);
254
+ const plans = plansFor(rows, config);
255
+ const frame = oneWindow(rows, plans, config);
256
+ const specs = rows.map((row) => {
257
+ return row.spec;
258
+ }).join(',');
259
+ const byName = new Map(rows.map((row, index) => {
260
+ return [row.name, plans[index]];
261
+ }));
262
+ return modbusMqtt(mqtt, specs, {
263
+ address: frame.address,
264
+ clientId: config.clientId || 'scada-modbus',
265
+ count: frame.count,
266
+ interval: config.interval,
267
+ threshold: config.threshold,
268
+ timeout: config.timeout,
269
+ transformerFactory(name, collector) {
270
+ const plan = byName.get(name);
271
+ if (!plan) {
272
+ throw new Error(`Modbus device ${name} has no map`);
273
+ }
274
+ return registerPublish(plan, frame.address, collector);
275
+ }
276
+ });
277
+ }
@@ -0,0 +1,90 @@
1
+ const orders = Object.freeze({
2
+ abcd: 'abcd',
3
+ cdab: 'cdab',
4
+ uint16: 'uint16'
5
+ });
6
+
7
+ /**
8
+ * Parses a holding-register point.
9
+ *
10
+ * `holding:<address>` is one uint16. `holding:<address>:abcd` and
11
+ * `holding:<address>:cdab` are float32 word orders. The address is the
12
+ * same PDU number as the poll window.
13
+ *
14
+ * @example
15
+ * parsePoint('holding:4000:cdab');
16
+ *
17
+ * @param {string} point - register point
18
+ * @returns {{address: number, order: string, width: number}} parsed point
19
+ */
20
+ export function parsePoint(point) {
21
+ const parts = String(point).split(':');
22
+ const address = parseInt(parts[1], 10);
23
+ const order = parts[2] || orders.uint16;
24
+ if (parts[0] !== 'holding' || Number.isNaN(address) || address < 0) {
25
+ throw new Error(`Register point must be holding:<address>, got: ${point}`);
26
+ }
27
+ if (!Object.hasOwn(orders, order)) {
28
+ throw new Error(`Register word order must be uint16, abcd, or cdab, got: ${point}`);
29
+ }
30
+ return Object.freeze({
31
+ address,
32
+ order,
33
+ width: order === orders.uint16 ? 1 : 2
34
+ });
35
+ }
36
+
37
+ /**
38
+ * Float32 word order for a pair of uint16 registers.
39
+ *
40
+ * @param {string} order - abcd or cdab
41
+ * @param {number} first - first register in the window
42
+ * @param {number} second - second register in the window
43
+ * @returns {number[]} high word then low word
44
+ */
45
+ function placed(order, first, second) {
46
+ if (order === orders.cdab) {
47
+ return [second, first];
48
+ }
49
+ return [first, second];
50
+ }
51
+
52
+ /**
53
+ * Decodes a float32 from two uint16 registers.
54
+ *
55
+ * @param {string} order - abcd or cdab
56
+ * @param {number} first - first register in the window
57
+ * @param {number} second - second register in the window
58
+ * @returns {number} decoded float
59
+ */
60
+ function floatOf(order, first, second) {
61
+ const pair = placed(order, first, second);
62
+ const buffer = new ArrayBuffer(4);
63
+ const view = new DataView(buffer);
64
+ view.setUint16(0, pair[0]);
65
+ view.setUint16(2, pair[1]);
66
+ return view.getFloat32(0);
67
+ }
68
+
69
+ /**
70
+ * Decodes one holding point from a register window.
71
+ *
72
+ * @example
73
+ * decodePoint('holding:4000:cdab', 4000, [0, 16256]);
74
+ *
75
+ * @param {string} point - register point
76
+ * @param {number} origin - first PDU address in the window
77
+ * @param {Array<number>} registers - uint16 window
78
+ * @returns {number} decoded value
79
+ */
80
+ export function decodePoint(point, origin, registers) {
81
+ const parsed = parsePoint(point);
82
+ const index = parsed.address - origin;
83
+ if (!Array.isArray(registers) || index < 0 || index + parsed.width > registers.length) {
84
+ throw new Error(`Register point ${point} is outside the window at ${origin}`);
85
+ }
86
+ if (parsed.order === orders.uint16) {
87
+ return registers[index];
88
+ }
89
+ return floatOf(parsed.order, registers[index], registers[index + 1]);
90
+ }