@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.
- package/README.md +6 -1
- package/charts/scada/Chart.yaml +6 -0
- package/charts/scada/templates/_helpers.tpl +63 -0
- package/charts/scada/templates/clickhouse.yaml +57 -0
- package/charts/scada/templates/collector.yaml +115 -0
- package/charts/scada/templates/postgres.yaml +70 -0
- package/charts/scada/templates/rabbitmq.yaml +150 -0
- package/charts/scada/templates/site-server.yaml +110 -0
- package/charts/scada/templates/supervisor-sink.yaml +64 -0
- package/charts/scada/values.yaml +30 -0
- package/index.js +10 -0
- package/package.json +4 -2
- package/src/application/deploy/appReleases.js +50 -0
- package/src/application/deploy/applyIncludes.js +31 -0
- package/src/application/deploy/expand.js +49 -0
- package/src/application/deploy/expandDevices.js +135 -0
- package/src/application/deploy/expandFlows.js +281 -0
- package/src/application/deploy/expandNamespace.js +193 -0
- package/src/application/deploy/expandPattern.js +207 -0
- package/src/application/deploy/expandProfiles.js +70 -0
- package/src/application/deploy/frozen.js +23 -0
- package/src/application/deploy/mergeDocuments.js +98 -0
- package/src/application/deploy/paths.js +86 -0
- package/src/application/deploy/pointPaths.js +20 -0
- package/src/application/deploy/readDocument.js +169 -0
- package/src/application/deploy/releaseEdges.js +185 -0
- package/src/application/deploy/releaseNodes.js +118 -0
- package/src/application/deploy/releaseValues.js +200 -0
- package/src/application/deploy/releases.js +115 -0
- package/src/application/deploy/withCollectors.js +28 -0
- package/src/bin/modbus-to-mqtt.js +57 -0
- package/src/infrastructure/ingest/mqtt/addressWindow.js +79 -0
- package/src/infrastructure/ingest/mqtt/modbusPublish.js +277 -0
- package/src/infrastructure/ingest/mqtt/registerPoint.js +90 -0
- 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
|
+
}
|