@yarkivaev/scada 2.3.116 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +90 -0
- package/charts/scada/templates/processes.yaml +22 -0
- package/charts/scada/values.schema.json +12 -0
- package/db/migrations/V0009__collector_task_state.sql +6 -0
- package/package.json +11 -5
- package/src/application/deploy/readDocument.js +4 -1
- package/src/application/plantServer.js +1 -1
- package/src/application/runtime/additionalAdapters.js +139 -0
- package/src/application/runtime/assignment.js +30 -0
- package/src/application/runtime/budgetedSink.js +17 -0
- package/src/application/runtime/collectorSignals.js +14 -0
- package/src/application/runtime/compositeLifecycle.js +49 -0
- package/src/application/runtime/createRegistry.js +20 -0
- package/src/application/runtime/deadline.js +8 -0
- package/src/application/runtime/index.js +10 -0
- package/src/application/runtime/manifest.js +38 -0
- package/src/application/runtime/pipelineAdapters.js +60 -0
- package/src/application/runtime/resolveSecrets.js +12 -0
- package/src/application/runtime/resourceClaims.js +21 -0
- package/src/application/runtime/runCollector.js +50 -0
- package/src/application/runtime/runtime.js +86 -0
- package/src/application/runtime/runtimeHealth.js +29 -0
- package/src/application/runtime/runtimeServices.js +15 -0
- package/src/application/runtime/stateDocument.js +22 -0
- package/src/application/runtime/streamPorts.js +42 -0
- package/src/application/runtime/streamTask.js +105 -0
- package/src/application/runtime/taskBudget.js +31 -0
- package/src/application/runtime/taskContext.js +18 -0
- package/src/application/runtime/taskQueue.js +21 -0
- package/src/application/runtime/taskSlot.js +46 -0
- package/src/application/runtime/taskState.js +73 -0
- package/src/application/siteCollection.js +14 -0
- package/src/application/siteServer.js +7 -6
- package/src/application/siteTelemetry.js +1 -1
- package/src/application/supervisorSink.js +9 -8
- package/src/application/sync/siteSyncBind.js +1 -1
- package/src/application/sync/siteSyncTargets.js +1 -1
- package/src/bin/collector.js +7 -0
- package/src/bin/modbus-to-mqtt.js +11 -9
- package/src/infrastructure/ingest/activity/activityTracking.js +15 -21
- package/src/infrastructure/ingest/activity/activityTransformer.js +16 -15
- package/src/infrastructure/ingest/durableConsume.js +2 -2
- package/src/infrastructure/ingest/ingestBatch.js +13 -0
- package/src/infrastructure/ingest/modbus/mx210Tcp.js +4 -4
- package/src/infrastructure/ingest/modbus/silentStreams.js +33 -8
- package/src/infrastructure/ingest/mqtt/metricsTransformer.js +4 -5
- package/src/infrastructure/ingest/mqtt/modbusMqtt.js +17 -19
- package/src/infrastructure/ingest/mqtt/modbusPublish.js +4 -0
- package/src/infrastructure/ingest/mqtt/mqttMetrics.js +17 -21
- package/src/infrastructure/ingest/mqtt/registerPublish.js +3 -3
- package/src/infrastructure/ingest/pipelines/alertPipeline.js +7 -6
- package/src/infrastructure/ingest/pipelines/decisionPipeline.js +7 -6
- package/src/infrastructure/ingest/pipelines/segmentPipeline.js +8 -17
- package/src/infrastructure/ingest/pipelines/stompOptions.js +7 -0
- package/src/infrastructure/ingest/telemetry/amqpMetricsIngest.js +10 -11
- package/src/infrastructure/ingest/telemetry/amqpMqttRelay.js +10 -6
- package/src/infrastructure/ingest/telemetry/foldingSink.js +5 -2
- package/src/infrastructure/messaging/stomp/segmentRetags.js +1 -1
- package/src/infrastructure/messaging/stomp/userDecisions.js +1 -1
- package/src/infrastructure/opcua/certificates.js +11 -0
- package/src/infrastructure/opcua/delivery.js +38 -0
- package/src/infrastructure/opcua/endpoint.js +12 -2
- package/src/infrastructure/opcua/session.js +20 -10
- package/src/infrastructure/opcua/source.js +6 -3
- package/src/infrastructure/persistence/runtime/taskStatePg.js +24 -0
- package/src/infrastructure/streams/README.md +291 -0
- package/src/infrastructure/streams/abortable.js +14 -0
- package/src/infrastructure/streams/amqpSink/publication.js +46 -0
- package/src/infrastructure/streams/amqpSink/session.js +96 -0
- package/src/infrastructure/streams/amqpSink.js +47 -0
- package/src/infrastructure/streams/amqpSource/session.js +127 -0
- package/src/infrastructure/streams/amqpSource.js +97 -0
- package/src/infrastructure/streams/brokerClose.js +19 -0
- package/src/infrastructure/streams/budgetAccept.js +6 -0
- package/src/infrastructure/streams/clickhouseSink.js +48 -0
- package/src/infrastructure/streams/deadline.js +1 -0
- package/src/infrastructure/streams/follow.js +41 -0
- package/src/infrastructure/streams/index.js +15 -0
- package/src/infrastructure/streams/lokiSource.js +88 -0
- package/src/infrastructure/streams/modbusConnection.js +173 -0
- package/src/infrastructure/streams/modbusRtuBusSource.js +124 -0
- package/src/infrastructure/streams/modbusSource.js +185 -0
- package/src/infrastructure/streams/mqttSink/session.js +51 -0
- package/src/infrastructure/streams/mqttSink.js +54 -0
- package/src/infrastructure/streams/mqttSource.js +66 -0
- package/src/infrastructure/streams/pollingSource.js +1 -0
- package/src/infrastructure/streams/positive.js +5 -0
- package/src/infrastructure/streams/postgresSink.js +124 -0
- package/src/infrastructure/streams/serialReceive.js +80 -0
- package/src/infrastructure/streams/serialSource.js +87 -0
- package/src/infrastructure/streams/sqliteSink.js +55 -0
- package/src/infrastructure/streams/stompBody.js +46 -0
- package/src/infrastructure/streams/stompSend.js +89 -0
- package/src/infrastructure/streams/stompSource.js +201 -0
- package/src/infrastructure/sync/operationSyncIngest.js +1 -1
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import ModbusRTU from 'modbus-serial';
|
|
2
|
+
import deadline from './deadline.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Idle state for Modbus connection.
|
|
6
|
+
*
|
|
7
|
+
* @returns {object} State with no open attempt
|
|
8
|
+
*/
|
|
9
|
+
export function idle() {
|
|
10
|
+
return {
|
|
11
|
+
connected() {
|
|
12
|
+
return false;
|
|
13
|
+
},
|
|
14
|
+
retrying() {
|
|
15
|
+
return false;
|
|
16
|
+
},
|
|
17
|
+
opening() {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Retrying state for Modbus connection.
|
|
25
|
+
*
|
|
26
|
+
* @param {object} handle - Timer handle from setTimeout
|
|
27
|
+
* @returns {object} State with retrying() returning true
|
|
28
|
+
*/
|
|
29
|
+
function retrying(handle) {
|
|
30
|
+
return {
|
|
31
|
+
connected() {
|
|
32
|
+
return false;
|
|
33
|
+
},
|
|
34
|
+
retrying() {
|
|
35
|
+
return true;
|
|
36
|
+
},
|
|
37
|
+
opening() {
|
|
38
|
+
return false;
|
|
39
|
+
},
|
|
40
|
+
cancel() {
|
|
41
|
+
clearTimeout(handle);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* In-flight TCP or RTU connect state.
|
|
48
|
+
*
|
|
49
|
+
* @returns {object} State with opening() returning true
|
|
50
|
+
*/
|
|
51
|
+
export function opening() {
|
|
52
|
+
return {
|
|
53
|
+
connected() {
|
|
54
|
+
return false;
|
|
55
|
+
},
|
|
56
|
+
retrying() {
|
|
57
|
+
return false;
|
|
58
|
+
},
|
|
59
|
+
opening() {
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Connected state for Modbus connection.
|
|
67
|
+
*
|
|
68
|
+
* @param {object} client - ModbusRTU client
|
|
69
|
+
* @returns {object} State with connected() returning true
|
|
70
|
+
*/
|
|
71
|
+
function connected(client) {
|
|
72
|
+
return {
|
|
73
|
+
connected() {
|
|
74
|
+
return true;
|
|
75
|
+
},
|
|
76
|
+
retrying() {
|
|
77
|
+
return false;
|
|
78
|
+
},
|
|
79
|
+
opening() {
|
|
80
|
+
return false;
|
|
81
|
+
},
|
|
82
|
+
disconnect() {
|
|
83
|
+
client.close();
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Destroys a Modbus client without throwing if already closed.
|
|
90
|
+
*
|
|
91
|
+
* @param {object} client - ModbusRTU client
|
|
92
|
+
*/
|
|
93
|
+
export function drop(client) {
|
|
94
|
+
if (client && typeof client.destroy === 'function') {
|
|
95
|
+
client.destroy(() => {
|
|
96
|
+
return undefined;
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Builds a ModbusRTU client for one connect attempt.
|
|
103
|
+
*
|
|
104
|
+
* @returns {object} Fresh ModbusRTU client
|
|
105
|
+
*/
|
|
106
|
+
export function create() {
|
|
107
|
+
return new ModbusRTU();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Starts one connect attempt and ignores results after stop().
|
|
112
|
+
*
|
|
113
|
+
* @param {object} bag - Mutable lifecycle bag
|
|
114
|
+
*/
|
|
115
|
+
export function attempt(bag) {
|
|
116
|
+
const token = bag.epoch;
|
|
117
|
+
bag.state = opening();
|
|
118
|
+
drop(bag.client);
|
|
119
|
+
const client = bag.create();
|
|
120
|
+
bag.client = client;
|
|
121
|
+
const connecting = bag.connect(client);
|
|
122
|
+
void connecting.then(() => { if (token !== bag.epoch) { drop(client); } }).catch(() => { return undefined; });
|
|
123
|
+
bag.opened = deadline(connecting, 5000, 'Modbus connect', (error) => {
|
|
124
|
+
bag.fatal = error; bag.epoch += 1; drop(client);
|
|
125
|
+
}).then(() => {
|
|
126
|
+
if (token !== bag.epoch) {
|
|
127
|
+
drop(client);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
bag.delay = bag.interval;
|
|
131
|
+
bag.state = connected(client);
|
|
132
|
+
client.setTimeout(bag.interval * 1000);
|
|
133
|
+
client.once('close', () => {
|
|
134
|
+
if (token === bag.epoch && bag.client === client) { bag.lost(); }
|
|
135
|
+
});
|
|
136
|
+
void bag.source.start().catch((error) => { bag.fatal = error; drop(client); });
|
|
137
|
+
}).catch((err) => {
|
|
138
|
+
if (token !== bag.epoch) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
drop(bag.client);
|
|
142
|
+
bag.log.error(`Connection to ${bag.target} failed, retrying in ${bag.delay}s: ${err.message}`);
|
|
143
|
+
const handle = setTimeout(() => {
|
|
144
|
+
attempt(bag);
|
|
145
|
+
}, bag.delay * 1000);
|
|
146
|
+
bag.state = retrying(handle);
|
|
147
|
+
bag.delay = Math.min(bag.delay * 2, bag.maxDelay);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Resets a live session and retries through attempt() backoff.
|
|
153
|
+
*
|
|
154
|
+
* @param {object} bag - Mutable lifecycle bag
|
|
155
|
+
*/
|
|
156
|
+
export function resume(bag) {
|
|
157
|
+
if (!bag.state.connected()) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const drained = bag.source.stop();
|
|
161
|
+
const { client } = bag;
|
|
162
|
+
bag.client = undefined;
|
|
163
|
+
bag.state = idle();
|
|
164
|
+
drop(client);
|
|
165
|
+
bag.log.error(`Connection to ${bag.target} lost, retrying in ${bag.delay}s`);
|
|
166
|
+
const token = bag.epoch;
|
|
167
|
+
const handle = setTimeout(() => {
|
|
168
|
+
void drained.then(() => { if (token === bag.epoch) { attempt(bag); } }).catch((error) => { bag.fatal = error; });
|
|
169
|
+
}, bag.delay * 1000);
|
|
170
|
+
bag.state = retrying(handle);
|
|
171
|
+
bag.delay = Math.min(bag.delay * 2, bag.maxDelay);
|
|
172
|
+
}
|
|
173
|
+
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import ModbusRTU from 'modbus-serial';
|
|
2
|
+
import positive from './positive.js';
|
|
3
|
+
import deadline from './deadline.js';
|
|
4
|
+
|
|
5
|
+
function validate(path, serial, slaves, poll) {
|
|
6
|
+
if (typeof path !== 'string' || !path) { throw new Error('Serial path must be a non-empty string'); }
|
|
7
|
+
if (!serial || !Number.isFinite(serial.baudRate) || serial.baudRate <= 0) { throw new Error('Serial baudRate must be a positive number'); }
|
|
8
|
+
if (!Array.isArray(slaves) || !slaves.length) { throw new Error('Slaves must be a non-empty array'); }
|
|
9
|
+
positive(poll.interval, 'interval');
|
|
10
|
+
slaves.forEach((slave) => {
|
|
11
|
+
if (!Number.isInteger(slave.slaveId) || slave.slaveId < 1 || slave.slaveId > 247 || typeof slave.collector?.accept !== 'function') {
|
|
12
|
+
throw new TypeError('Each RTU slave requires an ID from 1 to 247 and a collector');
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
function report(ctx, error) {
|
|
17
|
+
try { ctx.poll.log?.error(`RTU bus on ${ctx.path}: ${error.message}`); } catch {
|
|
18
|
+
// Diagnostics cannot stop recovery.
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function close(ctx) {
|
|
22
|
+
ctx.closing ??= deadline(new Promise((resolve, reject) => {
|
|
23
|
+
ctx.client.close((error) => { if (error) { reject(error); } else { resolve(); } });
|
|
24
|
+
}), ctx.poll.stopTimeoutMs, 'RTU close');
|
|
25
|
+
return ctx.closing;
|
|
26
|
+
}
|
|
27
|
+
function pause(ctx) {
|
|
28
|
+
return new Promise((resolve) => { ctx.wake = resolve; ctx.timer = setTimeout(resolve, ctx.poll.interval * 1000); })
|
|
29
|
+
.finally(() => { clearTimeout(ctx.timer); ctx.wake = null; });
|
|
30
|
+
}
|
|
31
|
+
async function read(ctx, slave) {
|
|
32
|
+
ctx.client.setID(slave.slaveId);
|
|
33
|
+
const result = await deadline(ctx.client.readHoldingRegisters(ctx.poll.address, ctx.poll.count), ctx.poll.readTimeoutMs, 'RTU read');
|
|
34
|
+
if (ctx.active) {
|
|
35
|
+
await deadline(Promise.resolve().then(() => {
|
|
36
|
+
return slave.collector.accept(result.data, { signal: ctx.controller.signal });
|
|
37
|
+
}), ctx.poll.acceptTimeoutMs, 'RTU acceptance', (error) => { ctx.controller.abort(error); });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
async function cycle(ctx) {
|
|
41
|
+
for (const slave of ctx.slaves) {
|
|
42
|
+
if (!ctx.active) { return; }
|
|
43
|
+
try {
|
|
44
|
+
// Shared setID requires every read and acceptance to finish before the next slave.
|
|
45
|
+
// eslint-disable-next-line no-await-in-loop
|
|
46
|
+
await read(ctx, slave);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if (error.code === 'OPERATION_TIMEOUT') { throw error; }
|
|
49
|
+
report(ctx, error);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async function session(ctx) {
|
|
54
|
+
ctx.opening = true;
|
|
55
|
+
const opening = ctx.client.connectRTUBuffered(ctx.path, ctx.serial);
|
|
56
|
+
void Promise.resolve(opening).then(() => {
|
|
57
|
+
ctx.opening = false;
|
|
58
|
+
if (!ctx.active) { return close(ctx); }
|
|
59
|
+
return undefined;
|
|
60
|
+
}, () => { ctx.opening = false; }).catch((error) => { report(ctx, error); });
|
|
61
|
+
await deadline(opening, ctx.poll.connectTimeoutMs, 'RTU connect');
|
|
62
|
+
while (ctx.active) {
|
|
63
|
+
// eslint-disable-next-line no-await-in-loop
|
|
64
|
+
await cycle(ctx);
|
|
65
|
+
// eslint-disable-next-line no-await-in-loop
|
|
66
|
+
if (ctx.active) { await pause(ctx); }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async function run(ctx) {
|
|
70
|
+
while (ctx.active) {
|
|
71
|
+
ctx.client = (ctx.poll.createClient || (() => { return new ModbusRTU(); }))();
|
|
72
|
+
ctx.client.setTimeout(ctx.poll.readTimeoutMs);
|
|
73
|
+
ctx.closing = null;
|
|
74
|
+
try {
|
|
75
|
+
// eslint-disable-next-line no-await-in-loop
|
|
76
|
+
await session(ctx);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
report(ctx, error);
|
|
79
|
+
if (error.code === 'OPERATION_TIMEOUT') { Object.assign(ctx, { fatal: error, active: false }); }
|
|
80
|
+
} finally {
|
|
81
|
+
// Never treat closing a still-opening handle as proof its eventual port is closed.
|
|
82
|
+
// eslint-disable-next-line no-await-in-loop
|
|
83
|
+
if (!ctx.opening) { await close(ctx); }
|
|
84
|
+
}
|
|
85
|
+
// eslint-disable-next-line no-await-in-loop
|
|
86
|
+
if (ctx.active) { await pause(ctx); }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function launch(path, serial, slaves, poll) {
|
|
90
|
+
const ctx = { path, serial, slaves, poll, active: true, controller: new AbortController() };
|
|
91
|
+
ctx.task = run(ctx);
|
|
92
|
+
void ctx.task.catch((error) => { ctx.fatal = error; ctx.active = false; report(ctx, error); });
|
|
93
|
+
return ctx;
|
|
94
|
+
}
|
|
95
|
+
/** One serialized RTU bus. stop drains/closes; uncertain deadlines quarantine this instance. */
|
|
96
|
+
export default function modbusRtuBusSource(path, serial, slaves, poll) {
|
|
97
|
+
validate(path, serial, slaves, poll);
|
|
98
|
+
const settings = { readTimeoutMs: 5000, connectTimeoutMs: 5000, acceptTimeoutMs: 10000, stopTimeoutMs: 5000, ...poll };
|
|
99
|
+
['readTimeoutMs', 'connectTimeoutMs', 'acceptTimeoutMs', 'stopTimeoutMs'].forEach((key) => { positive(settings[key], key); });
|
|
100
|
+
let ctx;
|
|
101
|
+
let revision = 0;
|
|
102
|
+
return {
|
|
103
|
+
async start() {
|
|
104
|
+
if (ctx?.active) { return; }
|
|
105
|
+
revision += 1;
|
|
106
|
+
const requested = revision;
|
|
107
|
+
if (ctx) {
|
|
108
|
+
await ctx.task;
|
|
109
|
+
if (ctx.fatal) { throw ctx.fatal; }
|
|
110
|
+
}
|
|
111
|
+
if (requested === revision) { ctx = launch(path, serial, slaves, settings); }
|
|
112
|
+
},
|
|
113
|
+
stop() {
|
|
114
|
+
revision += 1;
|
|
115
|
+
if (!ctx) { return Promise.resolve(); }
|
|
116
|
+
ctx.active = false;
|
|
117
|
+
ctx.controller.abort(new Error('RTU source stopped'));
|
|
118
|
+
ctx.wake?.();
|
|
119
|
+
return deadline(ctx.task, settings.stopTimeoutMs, 'RTU stop', (error) => { ctx.fatal = error; if (!ctx.opening) { void close(ctx).catch(() => { return undefined; }); } }).then(() => {
|
|
120
|
+
if (ctx.fatal) { throw ctx.fatal; }
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import pollingSource from './pollingSource.js';
|
|
2
|
+
import deadline from './deadline.js';
|
|
3
|
+
import abortable from './abortable.js';
|
|
4
|
+
import { attempt, create, drop, idle, opening, resume } from './modbusConnection.js';
|
|
5
|
+
|
|
6
|
+
function read(bag, address, count) {
|
|
7
|
+
return async function fetch(since, until, { signal } = {}) {
|
|
8
|
+
const { client } = bag;
|
|
9
|
+
try {
|
|
10
|
+
const result = await abortable(client.readHoldingRegisters(address, count), signal, () => { drop(client); });
|
|
11
|
+
return [result.data];
|
|
12
|
+
} catch (err) {
|
|
13
|
+
if (bag.client === client) { bag.lost(); }
|
|
14
|
+
throw err;
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Shared Modbus polling lifecycle for TCP and RTU transports.
|
|
21
|
+
*
|
|
22
|
+
* @param {object} options - connect, target, address, count, interval, collector, clk, client, log
|
|
23
|
+
* @returns {object} Source with start() and stop() methods
|
|
24
|
+
*/
|
|
25
|
+
function modbusPollingSource(options) {
|
|
26
|
+
const { address, count, interval, collector, clk } = options;
|
|
27
|
+
const bag = {
|
|
28
|
+
...options,
|
|
29
|
+
client: undefined,
|
|
30
|
+
state: idle(),
|
|
31
|
+
epoch: 0,
|
|
32
|
+
maxDelay: 300,
|
|
33
|
+
delay: interval
|
|
34
|
+
};
|
|
35
|
+
bag.lost = function lost() {
|
|
36
|
+
resume(bag);
|
|
37
|
+
};
|
|
38
|
+
bag.source = pollingSource(read(bag, address, count), interval, collector, clk);
|
|
39
|
+
return {
|
|
40
|
+
/**
|
|
41
|
+
* Connects to the Modbus device and starts polling.
|
|
42
|
+
*/
|
|
43
|
+
start() {
|
|
44
|
+
if (bag.fatal) { return Promise.reject(bag.fatal); }
|
|
45
|
+
if (bag.state.connected() || bag.state.retrying() || bag.state.opening()) {
|
|
46
|
+
return Promise.resolve();
|
|
47
|
+
}
|
|
48
|
+
const token = bag.epoch;
|
|
49
|
+
bag.state = opening();
|
|
50
|
+
return Promise.resolve(bag.stopping).then(() => { if (token === bag.epoch) { attempt(bag); } });
|
|
51
|
+
},
|
|
52
|
+
/**
|
|
53
|
+
* Stops polling and disconnects from the Modbus device.
|
|
54
|
+
*/
|
|
55
|
+
stop() {
|
|
56
|
+
bag.epoch += 1;
|
|
57
|
+
const drained = bag.source.stop();
|
|
58
|
+
if (bag.state.retrying()) {
|
|
59
|
+
bag.state.cancel();
|
|
60
|
+
}
|
|
61
|
+
const { client } = bag;
|
|
62
|
+
bag.client = undefined;
|
|
63
|
+
bag.state = idle();
|
|
64
|
+
drop(client);
|
|
65
|
+
bag.stopping = deadline(Promise.all([drained, bag.opened]), 5000, 'Modbus stop', (error) => { bag.fatal = error; }).then(() => {
|
|
66
|
+
if (bag.fatal) { throw bag.fatal; }
|
|
67
|
+
});
|
|
68
|
+
return bag.stopping;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Modbus TCP polling source for reading holding registers.
|
|
75
|
+
*
|
|
76
|
+
* Connects to a Modbus TCP device and polls holding registers
|
|
77
|
+
* at a specified interval. Forwards register arrays to the
|
|
78
|
+
* collector via pollingSource.
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* const source = modbusSource('192.168.2.148', 502, 4000, 14, 5, collector, clock);
|
|
82
|
+
* source.start();
|
|
83
|
+
*
|
|
84
|
+
* @param {string} host - Modbus TCP host address
|
|
85
|
+
* @param {number} port - Modbus TCP port
|
|
86
|
+
* @param {number} address - Starting register address
|
|
87
|
+
* @param {number} count - Number of registers to read
|
|
88
|
+
* @param {number} interval - Polling interval in seconds
|
|
89
|
+
* @param {object} collector - Collector with accept() method
|
|
90
|
+
* @param {object} clk - Clock with millis() method
|
|
91
|
+
* @param {object} [log] - Logger with error() method for connection failures
|
|
92
|
+
* @returns {object} Source with start() and stop() methods
|
|
93
|
+
*/
|
|
94
|
+
// Preserve the established positional adapter API.
|
|
95
|
+
// eslint-disable-next-line max-params
|
|
96
|
+
export default function modbusSource(host, port, address, count, interval, collector, clk, log = console) {
|
|
97
|
+
if (typeof host !== 'string' || host.length === 0) {
|
|
98
|
+
throw new Error('Host must be a non-empty string');
|
|
99
|
+
}
|
|
100
|
+
if (typeof port !== 'number' || port <= 0) {
|
|
101
|
+
throw new Error(`Port must be a positive number, got: ${port}`);
|
|
102
|
+
}
|
|
103
|
+
if (typeof address !== 'number' || address < 0) {
|
|
104
|
+
throw new Error(`Address must be a non-negative number, got: ${address}`);
|
|
105
|
+
}
|
|
106
|
+
if (typeof count !== 'number' || count <= 0) {
|
|
107
|
+
throw new Error(`Count must be a positive number, got: ${count}`);
|
|
108
|
+
}
|
|
109
|
+
function connect(modbusClient) {
|
|
110
|
+
return modbusClient.connectTCP(host, { port });
|
|
111
|
+
}
|
|
112
|
+
return modbusPollingSource({
|
|
113
|
+
create,
|
|
114
|
+
connect,
|
|
115
|
+
target: `${host}:${port}`,
|
|
116
|
+
address,
|
|
117
|
+
count,
|
|
118
|
+
interval,
|
|
119
|
+
collector,
|
|
120
|
+
clk,
|
|
121
|
+
log
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Modbus RTU polling source for reading holding registers over serial.
|
|
127
|
+
*
|
|
128
|
+
* Connects to a Modbus RTU device on a serial port and polls holding registers
|
|
129
|
+
* at a specified interval. Forwards register arrays to the collector via pollingSource.
|
|
130
|
+
*
|
|
131
|
+
* @example
|
|
132
|
+
* const source = modbusRtuSource('/dev/ttyUSB0', { baudRate: 9600, slaveId: 1 }, 4000, 44, 5, collector, clock);
|
|
133
|
+
* source.start();
|
|
134
|
+
*
|
|
135
|
+
* @param {string} path - Serial device path (e.g. /dev/ttyUSB0)
|
|
136
|
+
* @param {object} serial - baudRate, dataBits, stopBits, parity, slaveId
|
|
137
|
+
* @param {number} address - Starting register address
|
|
138
|
+
* @param {number} count - Number of registers to read
|
|
139
|
+
* @param {number} interval - Polling interval in seconds
|
|
140
|
+
* @param {object} collector - Collector with accept() method
|
|
141
|
+
* @param {object} clk - Clock with millis() method
|
|
142
|
+
* @param {object} [log] - Logger with error() method for connection failures
|
|
143
|
+
* @returns {object} Source with start() and stop() methods
|
|
144
|
+
*/
|
|
145
|
+
// Preserve the established positional adapter API.
|
|
146
|
+
// eslint-disable-next-line max-params
|
|
147
|
+
export function modbusRtuSource(path, serial, address, count, interval, collector, clk, log = console) {
|
|
148
|
+
if (typeof path !== 'string' || path.length === 0) {
|
|
149
|
+
throw new Error('Serial path must be a non-empty string');
|
|
150
|
+
}
|
|
151
|
+
if (!serial || typeof serial !== 'object') {
|
|
152
|
+
throw new Error('Serial config must be an object');
|
|
153
|
+
}
|
|
154
|
+
if (typeof serial.baudRate !== 'number' || serial.baudRate <= 0) {
|
|
155
|
+
throw new Error(`Baud rate must be a positive number, got: ${serial.baudRate}`);
|
|
156
|
+
}
|
|
157
|
+
if (typeof address !== 'number' || address < 0) {
|
|
158
|
+
throw new Error(`Address must be a non-negative number, got: ${address}`);
|
|
159
|
+
}
|
|
160
|
+
if (typeof count !== 'number' || count <= 0) {
|
|
161
|
+
throw new Error(`Count must be a positive number, got: ${count}`);
|
|
162
|
+
}
|
|
163
|
+
const slaveId = serial.slaveId || 1;
|
|
164
|
+
function connect(modbusClient) {
|
|
165
|
+
return modbusClient.connectRTUBuffered(path, {
|
|
166
|
+
baudRate: serial.baudRate,
|
|
167
|
+
dataBits: serial.dataBits || 8,
|
|
168
|
+
stopBits: serial.stopBits || 1,
|
|
169
|
+
parity: serial.parity || 'none'
|
|
170
|
+
}).then(() => {
|
|
171
|
+
modbusClient.setID(slaveId);
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
return modbusPollingSource({
|
|
175
|
+
create,
|
|
176
|
+
connect,
|
|
177
|
+
target: path,
|
|
178
|
+
address,
|
|
179
|
+
count,
|
|
180
|
+
interval,
|
|
181
|
+
collector,
|
|
182
|
+
clk,
|
|
183
|
+
log
|
|
184
|
+
});
|
|
185
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import deadline from '../deadline.js';
|
|
2
|
+
|
|
3
|
+
export function terminate(live, error) {
|
|
4
|
+
if (live.dead) { return live.closing; }
|
|
5
|
+
live.dead = true;
|
|
6
|
+
live.ready = false;
|
|
7
|
+
live.pending.forEach((settle) => { settle(error || new Error('MQTT connection closed')); });
|
|
8
|
+
live.closing = deadline(new Promise((resolve) => {
|
|
9
|
+
live.client.end(true, {}, resolve);
|
|
10
|
+
}), live.options.stopTimeoutMs, 'MQTT disconnect');
|
|
11
|
+
// An event callback cannot surface a rejected shutdown. stop() still returns it.
|
|
12
|
+
void live.closing.catch(() => { return undefined; });
|
|
13
|
+
return live.closing;
|
|
14
|
+
}
|
|
15
|
+
export function connect(live) {
|
|
16
|
+
return deadline(new Promise((resolve, reject) => {
|
|
17
|
+
function fail(error) { reject(error); void terminate(live, error); }
|
|
18
|
+
live.client.on('error', fail);
|
|
19
|
+
live.client.on('close', () => { fail(new Error('MQTT connection closed')); });
|
|
20
|
+
live.client.on('offline', () => { fail(new Error('MQTT connection offline')); });
|
|
21
|
+
live.client.on('connect', () => {
|
|
22
|
+
if (!live.dead) { live.ready = true; resolve(); }
|
|
23
|
+
});
|
|
24
|
+
live.pending.add(reject);
|
|
25
|
+
}), live.options.connectTimeoutMs, 'MQTT connect', (error) => { void terminate(live, error); });
|
|
26
|
+
}
|
|
27
|
+
export function publish(live, record, signal) {
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
let done = false;
|
|
30
|
+
const timer = { handle: null };
|
|
31
|
+
function abort() { void terminate(live, signal.reason || new Error('MQTT publish aborted')); }
|
|
32
|
+
function settle(error) {
|
|
33
|
+
if (done) { return; }
|
|
34
|
+
done = true;
|
|
35
|
+
clearTimeout(timer.handle);
|
|
36
|
+
signal?.removeEventListener('abort', abort);
|
|
37
|
+
live.pending.delete(settle);
|
|
38
|
+
live.count -= 1;
|
|
39
|
+
if (error) { reject(error); } else { resolve(); }
|
|
40
|
+
}
|
|
41
|
+
timer.handle = setTimeout(() => { void terminate(live, new Error('MQTT confirmation timed out; outcome uncertain')); }, live.options.confirmTimeoutMs);
|
|
42
|
+
live.pending.add(settle);
|
|
43
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
44
|
+
if (signal?.aborted) { abort(); return; }
|
|
45
|
+
try { live.client.publish(record.topic, record.payload, { qos: live.options.qos }, (error) => {
|
|
46
|
+
settle(error);
|
|
47
|
+
if (error) { void terminate(live, error); }
|
|
48
|
+
}); }
|
|
49
|
+
catch (error) { void terminate(live, error); }
|
|
50
|
+
});
|
|
51
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import mqtt from 'mqtt';
|
|
2
|
+
import positive from './positive.js';
|
|
3
|
+
import { connect, publish, terminate } from './mqttSink/session.js';
|
|
4
|
+
|
|
5
|
+
function settings(options) {
|
|
6
|
+
const config = { qos: 0, maxPending: 1024, maxPendingBytes: 1048576, connectTimeoutMs: 5000, confirmTimeoutMs: 10000, stopTimeoutMs: 5000, ...options };
|
|
7
|
+
['maxPending', 'maxPendingBytes', 'connectTimeoutMs', 'confirmTimeoutMs', 'stopTimeoutMs'].forEach((key) => { positive(config[key], key); });
|
|
8
|
+
if (!Number.isSafeInteger(config.maxPending)) { throw new TypeError('maxPending must be a safe integer'); }
|
|
9
|
+
if (![0, 1, 2].includes(config.qos)) { throw new TypeError('qos must be 0, 1, or 2'); }
|
|
10
|
+
return config;
|
|
11
|
+
}
|
|
12
|
+
function write(live, records, signal) {
|
|
13
|
+
if (!live?.ready || live.dead) { throw new Error('Sink is not connected'); }
|
|
14
|
+
if (!Array.isArray(records) || records.some((record) => {
|
|
15
|
+
return !record || typeof record.topic !== 'string' || !record.topic || !(typeof record.payload === 'string' || Buffer.isBuffer(record.payload));
|
|
16
|
+
})) { return Promise.reject(new TypeError('MQTT records require a topic and string/Buffer payload')); }
|
|
17
|
+
if (signal?.aborted) { return Promise.reject(signal.reason || new Error('MQTT publish aborted')); }
|
|
18
|
+
const bytes = records.reduce((total, record) => { return total + Buffer.byteLength(record.payload) + Buffer.byteLength(record.topic); }, 0);
|
|
19
|
+
if (live.bytes + bytes > live.options.maxPendingBytes || live.count + records.length > live.options.maxPending) { return Promise.reject(new Error('MQTT pending capacity exceeded')); }
|
|
20
|
+
live.count += records.length;
|
|
21
|
+
live.bytes += bytes;
|
|
22
|
+
return Promise.all(records.map((record) => {
|
|
23
|
+
if (live.dead) { live.count -= 1; return Promise.reject(new Error('MQTT connection closed')); }
|
|
24
|
+
return publish(live, record, signal);
|
|
25
|
+
})).finally(() => { live.bytes -= bytes; });
|
|
26
|
+
}
|
|
27
|
+
/** start awaits CONNACK; write awaits publish completion (broker confirmation at QoS 1/2).
|
|
28
|
+
* QoS 0 only confirms local handoff. No automatic replay of uncertain writes.
|
|
29
|
+
*/
|
|
30
|
+
export default function mqttSink(url, options = {}) {
|
|
31
|
+
if (typeof url !== 'string' || !url) { throw new Error('URL must be a non-empty string'); }
|
|
32
|
+
const config = settings(options);
|
|
33
|
+
let live;
|
|
34
|
+
return {
|
|
35
|
+
start() {
|
|
36
|
+
if (live && !live.dead) { return live.opening; }
|
|
37
|
+
if (live?.dead) { return Promise.reject(new Error('MQTT session retired; create a new sink after stop')); }
|
|
38
|
+
const client = (options.connect || mqtt.connect)(url, {
|
|
39
|
+
...options.connectionOptions, clientId: options.clientId, protocolVersion: 5,
|
|
40
|
+
reconnectPeriod: 0, queueQoSZero: false, connectTimeout: config.connectTimeoutMs
|
|
41
|
+
});
|
|
42
|
+
live = { client, options: config, pending: new Set(), count: 0, bytes: 0, ready: false, dead: false };
|
|
43
|
+
live.opening = connect(live);
|
|
44
|
+
return live.opening;
|
|
45
|
+
},
|
|
46
|
+
write(records, { signal } = {}) { return write(live, records, signal); },
|
|
47
|
+
stop() {
|
|
48
|
+
const stopped = live;
|
|
49
|
+
return (stopped ? terminate(stopped, new Error('MQTT sink stopped')) : Promise.resolve()).then(() => {
|
|
50
|
+
if (live === stopped) { live = undefined; }
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import mqtt from 'mqtt';
|
|
2
|
+
import positive from './positive.js';
|
|
3
|
+
import deadline from './deadline.js';
|
|
4
|
+
import budgetAccept from './budgetAccept.js';
|
|
5
|
+
|
|
6
|
+
function report(options, error) {
|
|
7
|
+
try { options.onError?.(error); } catch {
|
|
8
|
+
// Diagnostics cannot break transport callbacks.
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
function bind(live, collector, options) {
|
|
12
|
+
live.client.on('error', (error) => { report(options, error); });
|
|
13
|
+
// MQTT.js serializes handleMessage and sends PUBACK/PUBCOMP only on successful completion.
|
|
14
|
+
live.client.handleMessage = (packet, callback) => {
|
|
15
|
+
const accepted = Promise.resolve().then(() => {
|
|
16
|
+
if (!live.active) { throw new Error('MQTT source stopped'); }
|
|
17
|
+
return budgetAccept(collector, { topic: packet.topic, payload: packet.payload.toString() }, packet.payload.length, options, { signal: live.controller.signal });
|
|
18
|
+
});
|
|
19
|
+
live.pending.add(accepted);
|
|
20
|
+
void accepted.finally(() => { live.pending.delete(accepted); }).catch(() => { return undefined; });
|
|
21
|
+
deadline(accepted, options.acceptTimeoutMs, 'MQTT acceptance', (error) => {
|
|
22
|
+
live.active = false; live.controller.abort(error); live.client.end(true);
|
|
23
|
+
}).then(() => {
|
|
24
|
+
callback(live.active ? undefined : new Error('MQTT source stopped'));
|
|
25
|
+
}, (error) => { callback(error); report(options, error); });
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function open(url, list, collector, options) {
|
|
29
|
+
const client = (options.connect || mqtt.connect)(url, {
|
|
30
|
+
...options.connectionOptions, clientId: options.clientId, clean: !options.clientId, protocolVersion: 5,
|
|
31
|
+
properties: { sessionExpiryInterval: options.clientId ? options.sessionExpiryInterval ?? 3600 : 0,
|
|
32
|
+
receiveMaximum: options.prefetch ?? 32, maximumPacketSize: options.maxPacketBytes ?? 1048576 }
|
|
33
|
+
});
|
|
34
|
+
const live = { client, active: true, pending: new Set(), controller: new AbortController() };
|
|
35
|
+
bind(live, collector, options);
|
|
36
|
+
client.on('connect', () => {
|
|
37
|
+
if (live.active) { client.subscribe(list, { qos: options.qos ?? (options.clientId ? 1 : 0) }); }
|
|
38
|
+
});
|
|
39
|
+
return live;
|
|
40
|
+
}
|
|
41
|
+
/** ACK follows asynchronous acceptance, not merely receipt. Acceptance is not necessarily durable. */
|
|
42
|
+
export default function mqttSource(url, topics, collector, options = {}) {
|
|
43
|
+
if (typeof url !== 'string' || !url) { throw new Error('URL must be a non-empty string'); }
|
|
44
|
+
if (typeof topics !== 'string' || !topics.trim()) { throw new Error('Topic must be a non-empty string'); }
|
|
45
|
+
if (typeof collector?.accept !== 'function') { throw new Error('Collector must have an accept() method'); }
|
|
46
|
+
const config = { acceptTimeoutMs: 10000, stopTimeoutMs: 5000, ...options };
|
|
47
|
+
positive(config.acceptTimeoutMs, 'acceptTimeoutMs');
|
|
48
|
+
positive(config.stopTimeoutMs, 'stopTimeoutMs');
|
|
49
|
+
const list = topics.split(',').map((topic) => { return topic.trim(); }).filter(Boolean);
|
|
50
|
+
let live;
|
|
51
|
+
return {
|
|
52
|
+
start() {
|
|
53
|
+
if (!live) { live = open(url, list, collector, config); }
|
|
54
|
+
},
|
|
55
|
+
stop() {
|
|
56
|
+
if (!live) { return Promise.resolve(); }
|
|
57
|
+
const stopped = live;
|
|
58
|
+
stopped.active = false;
|
|
59
|
+
stopped.controller.abort(new Error('MQTT source stopped'));
|
|
60
|
+
const closing = new Promise((resolve) => { stopped.client.end(true, {}, resolve); });
|
|
61
|
+
return deadline(Promise.all([closing, ...stopped.pending]), config.stopTimeoutMs, 'MQTT stop').then(() => {
|
|
62
|
+
if (live === stopped) { live = undefined; }
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { pollingSource as default } from '@yarkivaev/source-to-sink';
|