@yarkivaev/scada 2.3.114 → 2.3.116
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 +109 -0
- package/charts/scada/Chart.yaml +2 -2
- package/charts/scada/templates/_helpers.tpl +12 -4
- package/charts/scada/templates/clickhouse.yaml +3 -3
- package/charts/scada/templates/collector.yaml +2 -2
- package/charts/scada/templates/namespace.yaml +6 -0
- package/charts/scada/templates/postgres.yaml +3 -3
- package/charts/scada/templates/processes.yaml +88 -0
- package/charts/scada/templates/rabbitmq-config.yaml +91 -0
- package/charts/scada/templates/rabbitmq.yaml +54 -113
- package/charts/scada/templates/site-server.yaml +5 -3
- package/charts/scada/templates/supervisor-sink.yaml +4 -2
- package/charts/scada/values.schema.json +22 -0
- package/charts/scada/values.yaml +21 -0
- package/index.js +3 -0
- package/package.json +8 -3
- package/src/application/deploy/collectorProcesses.js +43 -0
- package/src/application/deploy/expandFlows.js +21 -10
- package/src/application/deploy/publishedStreams.js +30 -0
- package/src/application/deploy/releaseEdges.js +13 -16
- package/src/application/deploy/releaseNodes.js +13 -0
- package/src/application/deploy/releaseValues.js +4 -15
- package/src/application/deploy/releases.js +3 -1
- package/src/application/deploy/subscriptions.js +20 -0
- package/src/infrastructure/deploy/executeRelease.js +102 -0
- package/src/infrastructure/ingest/durableConsume.js +2 -169
- package/src/infrastructure/ingest/modbus/silentStreams.js +9 -11
- package/src/infrastructure/ingest/silentOpenWatch.js +5 -42
- package/src/infrastructure/ingest/telemetry/amqpMetricsIngest.js +7 -18
- package/src/infrastructure/ingest/telemetry/amqpMqttRelay.js +8 -17
- package/src/infrastructure/messaging/rabbitmq/definitions.js +24 -0
- package/src/infrastructure/messaging/rabbitmq/reconcile.js +104 -0
- package/src/infrastructure/opcua/config.js +38 -0
- package/src/infrastructure/opcua/endpoint.js +80 -0
- package/src/infrastructure/opcua/index.js +10 -0
- package/src/infrastructure/opcua/session.js +60 -0
- package/src/infrastructure/opcua/source.js +35 -0
- package/src/infrastructure/opcua/subscriptionHealth.js +28 -0
- package/src/infrastructure/operators/operatorsSync.js +5 -29
- package/src/infrastructure/persistence/clickhouse/streamHub.js +21 -15
- package/src/infrastructure/persistence/metricsSensor.js +9 -6
- package/src/infrastructure/sync/operationSyncIngest.js +4 -16
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { checkSubscription, watchSubscription } from './subscriptionHealth.js';
|
|
2
|
+
import { AttributeIds, TimestampsToReturn } from 'node-opcua';
|
|
3
|
+
|
|
4
|
+
function deliver(state, channel, value, publish, options) {
|
|
5
|
+
if (state.active && !state.failure && value.statusCode.isGood()) {
|
|
6
|
+
const accepted = Promise.resolve().then(() => {
|
|
7
|
+
if (state.active && !state.failure) {
|
|
8
|
+
return publish(channel, value.value.value);
|
|
9
|
+
}
|
|
10
|
+
return undefined;
|
|
11
|
+
}).catch((error) => {
|
|
12
|
+
try {
|
|
13
|
+
options.onError?.(error, state.connection);
|
|
14
|
+
} catch {
|
|
15
|
+
// Callback isolation.
|
|
16
|
+
}
|
|
17
|
+
}).finally(() => { state.pending.delete(accepted); });
|
|
18
|
+
state.pending.add(accepted);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
async function monitors(subscription, state, connection, publish, options) {
|
|
22
|
+
await Promise.all(connection.channels.map(async (channel) => {
|
|
23
|
+
const item = await subscription.monitor({ nodeId: channel.nodeId, attributeId: AttributeIds.Value },
|
|
24
|
+
{ samplingInterval: connection.publishMs, discardOldest: true, queueSize: 10 }, TimestampsToReturn.Both);
|
|
25
|
+
if (!item.statusCode.isGood()) {
|
|
26
|
+
throw new Error(`Cannot monitor ${channel.nodeId}: ${item.statusCode.toString()}`);
|
|
27
|
+
}
|
|
28
|
+
item.on('changed', (value) => { deliver(state, channel, value, publish, options); });
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
function pause(state, milliseconds) {
|
|
32
|
+
return new Promise((resolve) => {
|
|
33
|
+
state.wake = resolve;
|
|
34
|
+
state.timer = setTimeout(resolve, milliseconds);
|
|
35
|
+
}).finally(() => { clearTimeout(state.timer); });
|
|
36
|
+
}
|
|
37
|
+
async function heartbeat(state, activeSession, connection, publish, options) {
|
|
38
|
+
while (state.active) {
|
|
39
|
+
checkSubscription(state, options);
|
|
40
|
+
// Fresh reads are serialized; a slow read cannot overlap the next heartbeat.
|
|
41
|
+
// eslint-disable-next-line no-await-in-loop
|
|
42
|
+
await pause(state, connection.heartbeatMs);
|
|
43
|
+
if (state.active) {
|
|
44
|
+
checkSubscription(state, options);
|
|
45
|
+
// eslint-disable-next-line no-await-in-loop
|
|
46
|
+
const values = await activeSession.read(connection.channels.map(({ nodeId }) => { return { nodeId, attributeId: AttributeIds.Value }; }), 0);
|
|
47
|
+
values.forEach((value, index) => { deliver(state, connection.channels[index], value, publish, options); });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export default async function session(state, client, connection, publish, options) {
|
|
52
|
+
const activeSession = await client.createSession();
|
|
53
|
+
if (state.active) {
|
|
54
|
+
const subscription = await activeSession.createSubscription2({ requestedPublishingInterval: connection.publishMs,
|
|
55
|
+
requestedLifetimeCount: 30, requestedMaxKeepAliveCount: 3, maxNotificationsPerPublish: 0, publishingEnabled: true, priority: 0 });
|
|
56
|
+
watchSubscription(state, subscription);
|
|
57
|
+
await monitors(subscription, state, connection, publish, options);
|
|
58
|
+
await heartbeat(state, activeSession, connection, publish, options);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { positive, validateConnections } from './config.js';
|
|
2
|
+
export { validateConnections } from './config.js';
|
|
3
|
+
|
|
4
|
+
/** Independent OPC UA endpoints delivering opaque channel metadata and values. */
|
|
5
|
+
export default function opcuaSource(connections, collector, options = {}) {
|
|
6
|
+
const settings = { retryMs: 1000, connectTimeoutMs: 5000, requestTimeoutMs: 5000, ...options };
|
|
7
|
+
validateConnections(connections);
|
|
8
|
+
if (!collector || typeof collector.accept !== 'function') {
|
|
9
|
+
throw new TypeError('Collector must have an accept() method');
|
|
10
|
+
}
|
|
11
|
+
['retryMs', 'connectTimeoutMs', 'requestTimeoutMs'].forEach((name) => { positive(settings[name], name); });
|
|
12
|
+
let endpoints;
|
|
13
|
+
let stopping = Promise.resolve();
|
|
14
|
+
let revision = 0;
|
|
15
|
+
return {
|
|
16
|
+
async start() {
|
|
17
|
+
revision += 1;
|
|
18
|
+
const requested = revision;
|
|
19
|
+
await stopping;
|
|
20
|
+
// Importing the package or validating a factory never loads node-opcua.
|
|
21
|
+
const { default: endpoint } = await import('./endpoint.js');
|
|
22
|
+
if (requested === revision) {
|
|
23
|
+
endpoints ??= connections.map((connection) => {
|
|
24
|
+
return endpoint(connection, (channel, value) => { return collector.accept({ channel, value }); }, settings);
|
|
25
|
+
});
|
|
26
|
+
endpoints.forEach((item) => { item.start(); });
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
stop() {
|
|
30
|
+
revision += 1;
|
|
31
|
+
stopping = Promise.all((endpoints ?? []).map((item) => { return item.stop(); }));
|
|
32
|
+
return stopping;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
function failed(state, message) {
|
|
2
|
+
if (state.active) {
|
|
3
|
+
state.failure = new Error(message);
|
|
4
|
+
state.wake?.();
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
export function watchSubscription(state, subscription) {
|
|
8
|
+
state.lastPublish = Date.now();
|
|
9
|
+
state.subscription = subscription;
|
|
10
|
+
subscription.on('keepalive', () => { state.lastPublish = Date.now(); });
|
|
11
|
+
subscription.on('received_notifications', () => { state.lastPublish = Date.now(); });
|
|
12
|
+
subscription.on('terminated', () => { failed(state, 'OPC UA subscription terminated'); });
|
|
13
|
+
subscription.on('status_changed', (status) => {
|
|
14
|
+
if (!status.isGood()) {
|
|
15
|
+
failed(state, `OPC UA subscription status: ${status.toString()}`);
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
export function checkSubscription(state, options) {
|
|
20
|
+
const { subscription } = state;
|
|
21
|
+
const silence = Math.max(subscription.publishingInterval * subscription.maxKeepAliveCount * 3, options.requestTimeoutMs);
|
|
22
|
+
if (Date.now() - state.lastPublish > silence) {
|
|
23
|
+
failed(state, 'OPC UA subscription stopped publishing');
|
|
24
|
+
}
|
|
25
|
+
if (state.failure) {
|
|
26
|
+
throw state.failure;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { intervalSource } from '@yarkivaev/source-to-sink';
|
|
2
|
+
|
|
1
3
|
const DEFAULT_INTERVAL_MS = 30000;
|
|
2
4
|
|
|
3
5
|
async function refresh(source, provider) {
|
|
@@ -25,33 +27,7 @@ async function refresh(source, provider) {
|
|
|
25
27
|
*/
|
|
26
28
|
export default function operatorsSync(source, provider, options) {
|
|
27
29
|
const intervalMs = options && options.intervalMs ? options.intervalMs : DEFAULT_INTERVAL_MS;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
try {
|
|
32
|
-
await refresh(source, provider);
|
|
33
|
-
} catch {
|
|
34
|
-
/* next tick retries; stale snapshot remains */
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
return {
|
|
38
|
-
async start() {
|
|
39
|
-
if (active) {
|
|
40
|
-
return;
|
|
41
|
-
}
|
|
42
|
-
active = true;
|
|
43
|
-
await tick();
|
|
44
|
-
timer = setInterval(() => {
|
|
45
|
-
void tick();
|
|
46
|
-
}, intervalMs);
|
|
47
|
-
},
|
|
48
|
-
stop() {
|
|
49
|
-
if (!active) {
|
|
50
|
-
return;
|
|
51
|
-
}
|
|
52
|
-
active = false;
|
|
53
|
-
clearInterval(timer);
|
|
54
|
-
timer = undefined;
|
|
55
|
-
}
|
|
56
|
-
};
|
|
30
|
+
return intervalSource(intervalMs, {
|
|
31
|
+
accept() { return refresh(source, provider); }
|
|
32
|
+
}, { immediate: true });
|
|
57
33
|
}
|
|
@@ -9,14 +9,16 @@
|
|
|
9
9
|
* const sub = hub.watch('m1/V', since, 1000, onRow, { unit: 'V', clock });
|
|
10
10
|
* sub.cancel();
|
|
11
11
|
*/
|
|
12
|
+
import { intervalSource } from '@yarkivaev/source-to-sink';
|
|
12
13
|
import pollTopicCursors from './pollTopicCursors.js';
|
|
13
14
|
|
|
14
15
|
const hubs = new WeakMap();
|
|
15
16
|
|
|
16
17
|
function defaultSchedule(step, fn) {
|
|
17
|
-
const
|
|
18
|
+
const source = intervalSource(step, { accept: fn });
|
|
19
|
+
void source.start();
|
|
18
20
|
return () => {
|
|
19
|
-
|
|
21
|
+
return source.stop();
|
|
20
22
|
};
|
|
21
23
|
}
|
|
22
24
|
|
|
@@ -74,21 +76,25 @@ function cursorsOf(bucket) {
|
|
|
74
76
|
return cursors;
|
|
75
77
|
}
|
|
76
78
|
|
|
79
|
+
function advance(watcher, timestamp) {
|
|
80
|
+
if (timestamp.getTime() > watcher.lastTs.getTime()) {
|
|
81
|
+
watcher.lastTs = timestamp;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
77
85
|
function dispatch(bucket, rows) {
|
|
78
|
-
rows.
|
|
86
|
+
return rows.reduce(async (previous, row) => {
|
|
87
|
+
await previous;
|
|
79
88
|
const group = bucket.watchers.get(row.topic);
|
|
80
|
-
if (!group) {
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
89
|
+
if (!group) { return; }
|
|
83
90
|
const timestamp = parseTimestamp(row.ts);
|
|
84
|
-
group.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
88
|
-
watcher
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
});
|
|
91
|
+
await [...group].reduce(async (previousWatcher, watcher) => {
|
|
92
|
+
await previousWatcher;
|
|
93
|
+
if (timestamp.getTime() <= watcher.lastTs.getTime()) { return; }
|
|
94
|
+
await watcher.callback({ timestamp, value: row.value, unit: watcher.unit });
|
|
95
|
+
advance(watcher, timestamp);
|
|
96
|
+
}, Promise.resolve());
|
|
97
|
+
}, Promise.resolve());
|
|
92
98
|
}
|
|
93
99
|
|
|
94
100
|
function buildHub(connection, schedule) {
|
|
@@ -106,7 +112,7 @@ function buildHub(connection, schedule) {
|
|
|
106
112
|
cursorsOf(bucket),
|
|
107
113
|
bucket.clock()
|
|
108
114
|
);
|
|
109
|
-
dispatch(bucket, rows);
|
|
115
|
+
await dispatch(bucket, rows);
|
|
110
116
|
} catch (err) {
|
|
111
117
|
console.error( // eslint-disable-line no-console
|
|
112
118
|
`Stream batch failed on ${connection.url()}:`,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { intervalSource } from '@yarkivaev/source-to-sink';
|
|
1
2
|
import seriesRows, { parseTimestamp } from './seriesRows.js';
|
|
2
3
|
|
|
3
4
|
function pollStream(spec) {
|
|
@@ -6,21 +7,23 @@ function pollStream(spec) {
|
|
|
6
7
|
return new Date();
|
|
7
8
|
});
|
|
8
9
|
let lastTs = since;
|
|
9
|
-
const
|
|
10
|
+
const source = intervalSource(step, { async accept() {
|
|
10
11
|
try {
|
|
11
12
|
const rows = await read.poll(topic, lastTs.toISOString(), time().toISOString());
|
|
12
|
-
rows.
|
|
13
|
+
await rows.reduce(async (previous, row) => {
|
|
14
|
+
await previous;
|
|
13
15
|
const timestamp = parseTimestamp(row.ts);
|
|
14
|
-
callback({ timestamp, value: row.value, unit });
|
|
16
|
+
await callback({ timestamp, value: row.value, unit });
|
|
15
17
|
lastTs = timestamp;
|
|
16
|
-
});
|
|
18
|
+
}, Promise.resolve());
|
|
17
19
|
} catch {
|
|
18
20
|
/* next poll retries */
|
|
19
21
|
}
|
|
20
|
-
}
|
|
22
|
+
} });
|
|
23
|
+
void source.start();
|
|
21
24
|
return {
|
|
22
25
|
cancel() {
|
|
23
|
-
|
|
26
|
+
return source.stop();
|
|
24
27
|
}
|
|
25
28
|
};
|
|
26
29
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { amqpSource } from '@yarkivaev/source-to-sink';
|
|
2
2
|
import operationCodec from './operationCodec.js';
|
|
3
3
|
import operationSyncSink from './operationSyncSink.js';
|
|
4
4
|
|
|
@@ -43,25 +43,13 @@ export function operationConsumer(codec, channel) {
|
|
|
43
43
|
* const ingest = operationSyncIngest(amqpUrl, 'scada.operations.ingest', dataAccess.operations);
|
|
44
44
|
* await ingest.start();
|
|
45
45
|
*/
|
|
46
|
-
function subscribeOperations(queue, prefetch, codec) {
|
|
47
|
-
return async (channel) => {
|
|
48
|
-
await channel.assertQueue(queue, { durable: true });
|
|
49
|
-
channel.prefetch(prefetch);
|
|
50
|
-
const onMessage = operationConsumer(codec, channel);
|
|
51
|
-
const tag = await channel.consume(queue, onMessage, { noAck: false });
|
|
52
|
-
return tag.consumerTag;
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
|
|
56
46
|
export default function operationSyncIngest(amqpUrl, queue, operations, options = {}) {
|
|
57
47
|
const prefetch = options.prefetch || 32;
|
|
58
48
|
const sink = operationSyncSink(operations);
|
|
59
49
|
const codec = operationCodec(sink);
|
|
60
|
-
const session =
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
options
|
|
64
|
-
);
|
|
50
|
+
const session = amqpSource(amqpUrl, queue, {
|
|
51
|
+
accept(delivery) { return acceptOperationDeliver(codec, delivery.content); }
|
|
52
|
+
}, { ...options, prefetch });
|
|
65
53
|
return {
|
|
66
54
|
start() {
|
|
67
55
|
return session.start();
|