@yarkivaev/scada 1.4.0 → 2.3.45
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 +196 -33
- package/db/migrations/C0001__central_drop_metrics.sql +1 -0
- package/db/migrations/C0002__central_revoke_delete.sql +12 -0
- package/db/migrations/C0003__central_operators.sql +7 -0
- package/db/migrations/C0004__central_operations_grants.sql +2 -0
- package/db/migrations/C0005__central_operators_grants.sql +1 -0
- package/db/migrations/C0006__central_operators_registration.sql +16 -0
- package/db/migrations/E0001__edge_metrics.sql +8 -0
- package/db/migrations/E0002__edge_retention_delete.sql +8 -0
- package/db/migrations/E0003__edge_operations_grants.sql +2 -0
- package/db/migrations/V0001__baseline.sql +25 -0
- package/db/migrations/V0002__sink_extension_tables.sql +6 -0
- package/db/migrations/V0003__operations.sql +10 -0
- package/db/migrations/V0004__ingest_checkpoints.sql +6 -0
- package/db/migrations/V0005__user_decisions_operator.sql +2 -0
- package/index.js +75 -45
- package/package.json +28 -8
- package/src/application/bindSilentStreams.js +20 -0
- package/src/application/edgeOperatorCatalog.js +45 -0
- package/src/application/export/exportJob.js +118 -0
- package/src/application/export/exportQuery.js +32 -0
- package/src/application/export/exportSink.js +25 -0
- package/src/application/export/exportStream.js +33 -0
- package/src/application/machineInPlant.js +20 -0
- package/src/application/metricsPlant.js +35 -0
- package/src/application/plantApi.js +49 -0
- package/src/application/plantOperations.js +19 -0
- package/src/application/plantServer.js +107 -0
- package/src/application/shopWithTimeline.js +112 -0
- package/src/application/siteOperatorCatalog.js +78 -0
- package/src/application/siteServer.js +191 -0
- package/src/application/supervisorSink.js +97 -0
- package/src/application/timelineOperatorFromEnv.js +19 -0
- package/src/bin/supervisor-sink.js +28 -0
- package/src/{alert.js → domain/alerting/alert.js} +2 -2
- package/src/{alerts.js → domain/alerting/alerts.js} +3 -3
- package/src/domain/alerting/ingest.js +18 -0
- package/src/domain/ingest/ingestCursor.js +26 -0
- package/src/domain/operation/operation.js +23 -0
- package/src/domain/operation/operations.js +81 -0
- package/src/domain/operator/operator.js +23 -0
- package/src/domain/plant/machine.js +32 -0
- package/src/domain/plant/plant.js +20 -0
- package/src/domain/plant/shop.js +24 -0
- package/src/domain/segment/dispatch.js +31 -0
- package/src/domain/segment/normalize.js +31 -0
- package/src/domain/segment/silenceBudget.js +16 -0
- package/src/{initialized.js → domain/shared/initialized.js} +3 -4
- package/src/{pubsub.js → domain/shared/pubsub.js} +2 -3
- package/src/domain/timeline/timeline.js +25 -0
- package/src/infrastructure/catalog/tag-catalog.json +27 -0
- package/src/infrastructure/catalog/tagCatalog.js +65 -0
- package/src/infrastructure/client/index.js +20 -0
- package/src/infrastructure/client/machineClient.js +159 -0
- package/src/infrastructure/client/machineOperationsClient.js +159 -0
- package/src/infrastructure/client/scadaClient.js +67 -0
- package/src/infrastructure/client/sseConnection.js +42 -0
- package/src/infrastructure/http/edge/edgeApi.js +54 -0
- package/src/infrastructure/http/edge/httpMetricsRead.js +34 -0
- package/src/infrastructure/http/edge/metricsSensor.js +15 -0
- package/src/infrastructure/http/edge/parseStateHttpTimeoutMs.js +20 -0
- package/src/infrastructure/http/edge/routes/checkpoint/checkpointRoutes.js +79 -0
- package/src/infrastructure/http/edge/routes/metrics/metricsBatch.js +59 -0
- package/src/infrastructure/http/edge/routes/metrics/metricsCurrent.js +29 -0
- package/src/infrastructure/http/edge/routes/metrics/metricsPoll.js +25 -0
- package/src/infrastructure/http/edge/routes/metrics/metricsRange.js +26 -0
- package/src/infrastructure/http/edge/routes/metrics/metricsRoutes.js +21 -0
- package/src/infrastructure/http/edge/routes/retention/retentionRoutes.js +41 -0
- package/src/infrastructure/http/edge/startTestEdgeApi.js +39 -0
- package/src/infrastructure/http/edge/stateAccess.js +13 -0
- package/src/infrastructure/http/edge/stateHttpClient.js +106 -0
- package/src/infrastructure/http/edge/stateHttpTimeoutError.js +13 -0
- package/src/infrastructure/http/plant/json/decisionJson.js +44 -0
- package/src/infrastructure/http/plant/json/operationJson.js +28 -0
- package/src/infrastructure/http/plant/json/operatorJson.js +22 -0
- package/src/infrastructure/http/plant/json/segmentJson.js +27 -0
- package/src/infrastructure/http/plant/operationAudit.js +56 -0
- package/src/infrastructure/http/plant/routes/alertRoute.js +93 -0
- package/src/infrastructure/http/plant/routes/catalogRoute.js +21 -0
- package/src/infrastructure/http/plant/routes/decisionRoute.js +60 -0
- package/src/infrastructure/http/plant/routes/machineRoute.js +35 -0
- package/src/infrastructure/http/plant/routes/measurementRoute.js +60 -0
- package/src/infrastructure/http/plant/routes/operationDrafts.js +79 -0
- package/src/infrastructure/http/plant/routes/operationRoute.js +80 -0
- package/src/infrastructure/http/plant/routes/operationWrites.js +186 -0
- package/src/infrastructure/http/plant/routes/operatorRoute.js +143 -0
- package/src/infrastructure/http/plant/routes/simulationRoute.js +61 -0
- package/src/infrastructure/http/plant/routes/timelineRoute.js +112 -0
- package/src/infrastructure/http/plant/streams/alertStream.js +68 -0
- package/src/infrastructure/http/plant/streams/heartbeatStream.js +34 -0
- package/src/infrastructure/http/plant/streams/measurementStream.js +90 -0
- package/src/infrastructure/http/plant/streams/operationStream.js +42 -0
- package/src/infrastructure/http/plant/streams/timelineStream.js +97 -0
- package/src/infrastructure/http/plant/timelineOperator.js +87 -0
- package/src/infrastructure/ingest/activity/activityTracking.js +74 -0
- package/src/infrastructure/ingest/activity/activityTransformer.js +45 -0
- package/src/infrastructure/ingest/codecs/alertCodec.js +56 -0
- package/src/infrastructure/ingest/codecs/segmentCodec.js +30 -0
- package/src/infrastructure/ingest/codecs/userDecisionCodec.js +78 -0
- package/src/infrastructure/ingest/cooldown.js +24 -0
- package/src/infrastructure/ingest/db/migrate.js +78 -0
- package/src/infrastructure/ingest/db/migrationProfile.js +35 -0
- package/src/infrastructure/ingest/db/runRetention.js +58 -0
- package/src/infrastructure/ingest/ingestCheckpoint.js +71 -0
- package/src/infrastructure/ingest/modbus/mx210Tcp.js +81 -0
- package/src/infrastructure/ingest/modbus/silentStreams.js +152 -0
- package/src/infrastructure/ingest/mqtt/metricsTransformer.js +54 -0
- package/src/infrastructure/ingest/mqtt/modbusDeviceSpec.js +112 -0
- package/src/infrastructure/ingest/mqtt/modbusMqtt.js +204 -0
- package/src/infrastructure/ingest/mqtt/mqttMetrics.js +78 -0
- package/src/infrastructure/ingest/parseRequestTimeoutMs.js +20 -0
- package/src/infrastructure/ingest/pipelines/alertPipeline.js +48 -0
- package/src/infrastructure/ingest/pipelines/decisionPipeline.js +45 -0
- package/src/infrastructure/ingest/pipelines/segmentPipeline.js +60 -0
- package/src/infrastructure/ingest/processingErrorLog.js +25 -0
- package/src/infrastructure/ingest/silentOpenWatch.js +50 -0
- package/src/infrastructure/ingest/sinks/alertSink.js +43 -0
- package/src/infrastructure/ingest/sinks/closeOrphanOpen.js +32 -0
- package/src/infrastructure/ingest/sinks/closeSilentOpen.js +39 -0
- package/src/infrastructure/ingest/sinks/retagSink.js +43 -0
- package/src/infrastructure/ingest/sinks/userDecisionSink.js +41 -0
- package/src/infrastructure/ingest/telemetry/amqpMetricsIngest.js +94 -0
- package/src/infrastructure/ingest/telemetry/amqpMqttRelay.js +71 -0
- package/src/infrastructure/ingest/telemetry/deliverToMqttRecord.js +19 -0
- package/src/infrastructure/ingest/telemetry/streamNameFromTopic.js +21 -0
- package/src/infrastructure/messaging/ownership/httpOperations.js +96 -0
- package/src/infrastructure/messaging/ownership/httpTimeline.js +99 -0
- package/src/infrastructure/messaging/ownership/machineOwners.js +39 -0
- package/src/infrastructure/messaging/ownership/ownerTimeline.js +28 -0
- package/src/infrastructure/messaging/stomp/alerts/stompAlerts.js +21 -0
- package/src/infrastructure/messaging/stomp/alerts/stompAlertsCollection.js +51 -0
- package/src/infrastructure/messaging/stomp/alerts/stompAlertsInit.js +24 -0
- package/src/infrastructure/messaging/stomp/alerts/stompAlertsLogic.js +51 -0
- package/src/infrastructure/messaging/stomp/stompTimelineSegments.js +80 -0
- package/src/infrastructure/messaging/stomp/timeline.js +21 -0
- package/src/infrastructure/messaging/stomp/userDecisionBody.js +25 -0
- package/src/infrastructure/messaging/stomp/userDecisions.js +42 -0
- package/src/infrastructure/operators/centralOperators.js +64 -0
- package/src/infrastructure/operators/edgeOperators.js +39 -0
- package/src/infrastructure/operators/operatorById.js +33 -0
- package/src/infrastructure/operators/operatorExtras.js +41 -0
- package/src/infrastructure/operators/operators.js +32 -0
- package/src/infrastructure/operators/operatorsFromSeed.js +18 -0
- package/src/infrastructure/operators/operatorsSync.js +57 -0
- package/src/infrastructure/persistence/clickhouse/connection.js +58 -0
- package/src/infrastructure/persistence/clickhouse/pollTopicCursors.js +48 -0
- package/src/{clickhouseSensor.js → infrastructure/persistence/clickhouse/sensor.js} +9 -27
- package/src/infrastructure/persistence/clickhouse/streamHub.js +165 -0
- package/src/infrastructure/persistence/memory/alerts.js +21 -0
- package/src/infrastructure/persistence/memory/checkpoints.js +98 -0
- package/src/infrastructure/persistence/memory/metrics.js +69 -0
- package/src/infrastructure/persistence/memory/metricsDisabled.js +19 -0
- package/src/infrastructure/persistence/memory/operations.js +87 -0
- package/src/infrastructure/persistence/memory/segments.js +83 -0
- package/src/infrastructure/persistence/memory/timeline.js +56 -0
- package/src/infrastructure/persistence/metricsSensor.js +112 -0
- package/src/infrastructure/persistence/pg/alerts.js +25 -0
- package/src/infrastructure/persistence/pg/checkpoints.js +115 -0
- package/src/infrastructure/persistence/pg/metrics.js +73 -0
- package/src/infrastructure/persistence/pg/operations.js +95 -0
- package/src/infrastructure/persistence/pg/operators.js +118 -0
- package/src/infrastructure/persistence/pg/segments.js +48 -0
- package/src/infrastructure/persistence/pg/timeline.js +53 -0
- package/src/infrastructure/persistence/pg/userDecisions.js +72 -0
- package/src/infrastructure/persistence/postgresPool.js +24 -0
- package/src/infrastructure/persistence/stateDataFromMemory.js +28 -0
- package/src/infrastructure/persistence/stateDataFromPool.js +18 -0
- package/src/infrastructure/sync/operationCodec.js +75 -0
- package/src/infrastructure/sync/operationSyncIngest.js +82 -0
- package/src/infrastructure/sync/operationSyncSink.js +40 -0
- package/src/activeMelting.js +0 -54
- package/src/completedMelting.js +0 -42
- package/src/event.js +0 -24
- package/src/events.js +0 -51
- package/src/interval.js +0 -18
- package/src/machineChronology.js +0 -79
- package/src/meltingChronology.js +0 -37
- package/src/meltingMachine.js +0 -53
- package/src/meltingRuleEngine.js +0 -37
- package/src/meltingShop.js +0 -32
- package/src/meltings.js +0 -97
- package/src/monitoredMeltingMachine.js +0 -33
- package/src/plant.js +0 -23
- package/src/requests.js +0 -44
- package/src/rule.js +0 -33
- package/src/rules.js +0 -23
- package/src/scyllaSensor.js +0 -54
- package/src/segments.js +0 -64
- package/src/sqliteSensor.js +0 -106
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
function intervalFor(days) {
|
|
2
|
+
return `${days} days`;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
async function deleteStale(pool, days) {
|
|
6
|
+
const window = intervalFor(days);
|
|
7
|
+
await pool.query('BEGIN');
|
|
8
|
+
try {
|
|
9
|
+
const metrics = await pool.query(
|
|
10
|
+
'DELETE FROM metrics WHERE ts < NOW() - $1::interval',
|
|
11
|
+
[window]
|
|
12
|
+
);
|
|
13
|
+
const segments = await pool.query(
|
|
14
|
+
`DELETE FROM segments
|
|
15
|
+
WHERE end_time > start_time
|
|
16
|
+
AND end_time < NOW() - $1::interval`,
|
|
17
|
+
[window]
|
|
18
|
+
);
|
|
19
|
+
const alerts = await pool.query(
|
|
20
|
+
'DELETE FROM alerts WHERE "timestamp" < NOW() - $1::interval',
|
|
21
|
+
[window]
|
|
22
|
+
);
|
|
23
|
+
const decisions = await pool.query(
|
|
24
|
+
'DELETE FROM user_decisions WHERE start_time < NOW() - $1::interval',
|
|
25
|
+
[window]
|
|
26
|
+
);
|
|
27
|
+
await pool.query('COMMIT');
|
|
28
|
+
return {
|
|
29
|
+
metrics: metrics.rowCount,
|
|
30
|
+
segments: segments.rowCount,
|
|
31
|
+
alerts: alerts.rowCount,
|
|
32
|
+
userDecisions: decisions.rowCount
|
|
33
|
+
};
|
|
34
|
+
} catch (error) {
|
|
35
|
+
await pool.query('ROLLBACK');
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function vacuumTables(pool) {
|
|
41
|
+
const names = ['metrics', 'segments', 'alerts', 'user_decisions'];
|
|
42
|
+
await Promise.all(names.map((name) => {
|
|
43
|
+
return pool.query(`VACUUM ANALYZE ${name}`);
|
|
44
|
+
}));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Deletes Postgres rows older than the retention window and vacuums tables.
|
|
49
|
+
*
|
|
50
|
+
* @param {object} pool - pg.Pool instance
|
|
51
|
+
* @param {number} days - retention window length in days
|
|
52
|
+
* @returns {Promise<{retentionDays: number, deleted: object}>}
|
|
53
|
+
*/
|
|
54
|
+
export default async function runRetention(pool, days) {
|
|
55
|
+
const deleted = await deleteStale(pool, days);
|
|
56
|
+
await vacuumTables(pool);
|
|
57
|
+
return { retentionDays: days, deleted };
|
|
58
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { ingestCursorAt, ingestCursorEmpty } from '../../domain/ingest/ingestCursor.js';
|
|
2
|
+
|
|
3
|
+
function findRow(store, source, machine) {
|
|
4
|
+
return store.checkpoints.find((row) => {
|
|
5
|
+
return row.source === source && row.machine === machine;
|
|
6
|
+
});
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* In-memory ingest checkpoint port for tests and local runs.
|
|
11
|
+
*
|
|
12
|
+
* @param {object} store - shared store with checkpoints array
|
|
13
|
+
* @returns {object} checkpoint port with read and write methods
|
|
14
|
+
*/
|
|
15
|
+
export default function ingestCheckpointMemory(store) {
|
|
16
|
+
if (!store.checkpoints) {
|
|
17
|
+
store.checkpoints = [];
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
read(source, machine) {
|
|
21
|
+
const row = findRow(store, source, machine);
|
|
22
|
+
if (!row) {
|
|
23
|
+
return Promise.resolve(ingestCursorEmpty());
|
|
24
|
+
}
|
|
25
|
+
return Promise.resolve(ingestCursorAt(new Date(row.cursor_at)));
|
|
26
|
+
},
|
|
27
|
+
write(source, machine, cursorAt) {
|
|
28
|
+
const row = findRow(store, source, machine);
|
|
29
|
+
if (row) {
|
|
30
|
+
row.cursor_at = new Date(cursorAt);
|
|
31
|
+
return Promise.resolve();
|
|
32
|
+
}
|
|
33
|
+
store.checkpoints.push({
|
|
34
|
+
source,
|
|
35
|
+
machine,
|
|
36
|
+
cursor_at: new Date(cursorAt)
|
|
37
|
+
});
|
|
38
|
+
return Promise.resolve();
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* PostgreSQL ingest checkpoint port backed by ingest_checkpoints table.
|
|
45
|
+
*
|
|
46
|
+
* @param {object} pool - pg pool with query method
|
|
47
|
+
* @returns {object} checkpoint port with read and write methods
|
|
48
|
+
*/
|
|
49
|
+
export function ingestCheckpointPg(pool) {
|
|
50
|
+
return {
|
|
51
|
+
read(source, machine) {
|
|
52
|
+
return pool.query(
|
|
53
|
+
'SELECT cursor_at FROM ingest_checkpoints WHERE source = $1 AND machine = $2',
|
|
54
|
+
[source, machine]
|
|
55
|
+
).then((result) => {
|
|
56
|
+
if (result.rows.length === 0) {
|
|
57
|
+
return ingestCursorEmpty();
|
|
58
|
+
}
|
|
59
|
+
return ingestCursorAt(new Date(result.rows[0].cursor_at));
|
|
60
|
+
});
|
|
61
|
+
},
|
|
62
|
+
write(source, machine, cursorAt) {
|
|
63
|
+
return pool.query(
|
|
64
|
+
`INSERT INTO ingest_checkpoints (source, machine, cursor_at)
|
|
65
|
+
VALUES ($1, $2, $3)
|
|
66
|
+
ON CONFLICT (source, machine) DO UPDATE SET cursor_at = EXCLUDED.cursor_at`,
|
|
67
|
+
[source, machine, cursorAt]
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { modbusSource } from '@yarkivaev/source-to-sink';
|
|
2
|
+
|
|
3
|
+
const layout = [
|
|
4
|
+
{ offset: 0, channel: 'AI1' },
|
|
5
|
+
{ offset: 3, channel: 'AI2' },
|
|
6
|
+
{ offset: 6, channel: 'AI3' },
|
|
7
|
+
{ offset: 9, channel: 'AI4' },
|
|
8
|
+
{ offset: 12, channel: 'AI5' }
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Decodes a CDAB-format float from two uint16 registers.
|
|
13
|
+
*
|
|
14
|
+
* @param {number} low - First uint16 register (low word)
|
|
15
|
+
* @param {number} high - Second uint16 register (high word)
|
|
16
|
+
* @returns {number} Decoded float32 value
|
|
17
|
+
*/
|
|
18
|
+
function decode(low, high) {
|
|
19
|
+
const buffer = new ArrayBuffer(4);
|
|
20
|
+
const view = new DataView(buffer);
|
|
21
|
+
view.setUint16(0, high);
|
|
22
|
+
view.setUint16(2, low);
|
|
23
|
+
return view.getFloat32(0);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Transformer that maps MX210 register blocks to metrics sink records.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} device - Device name in topic path
|
|
30
|
+
* @param {object} collector - Collector with accept(record)
|
|
31
|
+
* @returns {object} Transformer with accept(registers)
|
|
32
|
+
*/
|
|
33
|
+
export function mx210Metrics(device, collector) {
|
|
34
|
+
return {
|
|
35
|
+
accept(registers) {
|
|
36
|
+
const ts = Date.now();
|
|
37
|
+
for (const entry of layout) {
|
|
38
|
+
collector.accept({
|
|
39
|
+
topic: `MX210/${device}/GET/${entry.channel}/VALUE`,
|
|
40
|
+
ts,
|
|
41
|
+
value: decode(registers[entry.offset], registers[entry.offset + 1])
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Owen MX210 TCP stream descriptor for central silent-stream Modbus backup.
|
|
50
|
+
*
|
|
51
|
+
* Opens a Modbus poll that writes ClickHouse metrics records (topic, ts, value),
|
|
52
|
+
* not MQTT publish envelopes.
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* const stream = mx210Tcp('m-1', '192.0.2.10', 502);
|
|
56
|
+
* const poll = stream.open(collector, clock, 5);
|
|
57
|
+
* poll.start();
|
|
58
|
+
*
|
|
59
|
+
* @param {string} name - Stream id (device name in MX210 topics)
|
|
60
|
+
* @param {string} host - Modbus TCP host
|
|
61
|
+
* @param {number} port - Modbus TCP port
|
|
62
|
+
* @returns {object} Stream with name() and open(collector, clock, interval)
|
|
63
|
+
*/
|
|
64
|
+
export default function mx210Tcp(name, host, port) {
|
|
65
|
+
return Object.freeze({
|
|
66
|
+
name() {
|
|
67
|
+
return name;
|
|
68
|
+
},
|
|
69
|
+
open(collector, clk, interval) {
|
|
70
|
+
return modbusSource(
|
|
71
|
+
host,
|
|
72
|
+
port,
|
|
73
|
+
4000,
|
|
74
|
+
14,
|
|
75
|
+
interval,
|
|
76
|
+
mx210Metrics(name, collector),
|
|
77
|
+
clk
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { clock } from '@yarkivaev/source-to-sink';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Whether a stream is past the silence budget.
|
|
5
|
+
*
|
|
6
|
+
* @param {Map<string, number>} lastSeen - Stream id to last edge millis
|
|
7
|
+
* @param {string} name - Stream id
|
|
8
|
+
* @param {object} clk - Clock
|
|
9
|
+
* @param {number} budgetMs - Silence budget in milliseconds
|
|
10
|
+
* @returns {boolean} True when Modbus backup should run
|
|
11
|
+
*/
|
|
12
|
+
function isSilent(lastSeen, name, clk, budgetMs) {
|
|
13
|
+
const at = lastSeen.get(name);
|
|
14
|
+
if (at === undefined) {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
return clk.millis() - at >= budgetMs;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Stops one active poll if present.
|
|
22
|
+
*
|
|
23
|
+
* @param {Map<string, object>} active - Active polls by name
|
|
24
|
+
* @param {string} name - Stream id
|
|
25
|
+
*/
|
|
26
|
+
function stopOne(active, name) {
|
|
27
|
+
const poll = active.get(name);
|
|
28
|
+
if (!poll) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
poll.stop();
|
|
32
|
+
active.delete(name);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Starts one poll when not already active.
|
|
37
|
+
*
|
|
38
|
+
* @param {Map<string, object>} active - Active polls by name
|
|
39
|
+
* @param {object} source - Stream source
|
|
40
|
+
* @param {object} collector - Metrics collector
|
|
41
|
+
* @param {object} clk - Clock
|
|
42
|
+
* @param {number} intervalSec - Poll interval seconds
|
|
43
|
+
*/
|
|
44
|
+
function startOne(active, source, collector, clk, intervalSec) {
|
|
45
|
+
const name = source.name();
|
|
46
|
+
if (active.has(name)) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const poll = source.open(collector, clk, intervalSec);
|
|
50
|
+
active.set(name, poll);
|
|
51
|
+
poll.start();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Reconciles active polls with current silence state.
|
|
56
|
+
*
|
|
57
|
+
* @param {object} state - Gate mutable state
|
|
58
|
+
*/
|
|
59
|
+
function pulse(state) {
|
|
60
|
+
if (!state.running || !state.collector) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
for (const source of state.sources) {
|
|
64
|
+
const name = source.name();
|
|
65
|
+
if (isSilent(state.lastSeen, name, state.clk, state.budgetMs)) {
|
|
66
|
+
startOne(state.active, source, state.collector, state.clk, state.intervalSec);
|
|
67
|
+
} else {
|
|
68
|
+
stopOne(state.active, name);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Builds mutable gate state from config.
|
|
75
|
+
*
|
|
76
|
+
* @param {object} config - silentStreams config
|
|
77
|
+
* @returns {object} Mutable state bag
|
|
78
|
+
*/
|
|
79
|
+
function gateState(config) {
|
|
80
|
+
const { budget, interval, sources } = config;
|
|
81
|
+
return {
|
|
82
|
+
budgetMs: budget * 1000,
|
|
83
|
+
intervalSec: interval,
|
|
84
|
+
sources,
|
|
85
|
+
clk: config.clock || clock(),
|
|
86
|
+
delay: config.delay || ((fn, ms) => {
|
|
87
|
+
return setInterval(fn, ms);
|
|
88
|
+
}),
|
|
89
|
+
clear: config.clear || clearInterval,
|
|
90
|
+
lastSeen: new Map(),
|
|
91
|
+
active: new Map(),
|
|
92
|
+
collector: undefined,
|
|
93
|
+
timer: undefined,
|
|
94
|
+
running: false
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Polls configured Modbus streams only while their edge telemetry is silent.
|
|
100
|
+
*
|
|
101
|
+
* Edge freshness is reported via seen(name); Modbus writes go to the bound sink
|
|
102
|
+
* and never count as seen. Streams never seen are treated as already silent.
|
|
103
|
+
*
|
|
104
|
+
* @example
|
|
105
|
+
* const streams = silentStreams({
|
|
106
|
+
* budget: 15,
|
|
107
|
+
* interval: 5,
|
|
108
|
+
* sources: [mx210Tcp('m-1', '192.0.2.10', 502)]
|
|
109
|
+
* });
|
|
110
|
+
* streams.bind(clickhouseSink);
|
|
111
|
+
* streams.start();
|
|
112
|
+
*
|
|
113
|
+
* @param {object} config - budget (sec), interval (sec), sources, optional clock/delay/clear
|
|
114
|
+
* @returns {object} Gate with seen, bind, start, stop, pulse
|
|
115
|
+
*/
|
|
116
|
+
export default function silentStreams(config) {
|
|
117
|
+
const state = gateState(config);
|
|
118
|
+
function runPulse() {
|
|
119
|
+
pulse(state);
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
seen(name, at = state.clk.millis()) {
|
|
123
|
+
state.lastSeen.set(name, at);
|
|
124
|
+
if (state.running) {
|
|
125
|
+
runPulse();
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
bind(sink) {
|
|
129
|
+
state.collector = {
|
|
130
|
+
accept(record) {
|
|
131
|
+
sink.write([record]);
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
},
|
|
135
|
+
start() {
|
|
136
|
+
state.running = true;
|
|
137
|
+
runPulse();
|
|
138
|
+
state.timer = state.delay(runPulse, state.intervalSec * 1000);
|
|
139
|
+
},
|
|
140
|
+
stop() {
|
|
141
|
+
state.running = false;
|
|
142
|
+
if (state.timer !== undefined) {
|
|
143
|
+
state.clear(state.timer);
|
|
144
|
+
state.timer = undefined;
|
|
145
|
+
}
|
|
146
|
+
for (const name of [...state.active.keys()]) {
|
|
147
|
+
stopOne(state.active, name);
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
pulse: runPulse
|
|
151
|
+
};
|
|
152
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transformer for converting raw MQTT messages to metrics records.
|
|
3
|
+
*
|
|
4
|
+
* Parses payloads as JSON. Accepts either a raw number or an object
|
|
5
|
+
* with a value field. Logs when payload cannot be resolved to a
|
|
6
|
+
* numeric value.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* const transformer = metricsTransformer(collector);
|
|
10
|
+
* transformer.accept({ topic: 'sensors/temp', payload: '42.5' });
|
|
11
|
+
* transformer.accept({ topic: 'sensors/temp', payload: '{"ts":123,"value":42}' });
|
|
12
|
+
*
|
|
13
|
+
* @param {object} collector - Collector with accept() method
|
|
14
|
+
* @param {object} [log] - Logger with warn() method
|
|
15
|
+
* @returns {object} Transformer with accept() method
|
|
16
|
+
*/
|
|
17
|
+
export default function metricsCodec(collector, log = console) {
|
|
18
|
+
if (!collector || typeof collector.accept !== 'function') {
|
|
19
|
+
throw new Error('Collector must have an accept() method');
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
/**
|
|
23
|
+
* Accepts raw MQTT message and transforms to metrics record.
|
|
24
|
+
*
|
|
25
|
+
* @param {object} raw - Raw message with topic and payload
|
|
26
|
+
*/
|
|
27
|
+
accept(raw) {
|
|
28
|
+
let parsed;
|
|
29
|
+
try {
|
|
30
|
+
parsed = JSON.parse(raw.payload);
|
|
31
|
+
} catch {
|
|
32
|
+
log.warn(`Cannot resolve payload on ${raw.topic}: not valid JSON`);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (typeof parsed === 'number') {
|
|
36
|
+
collector.accept({
|
|
37
|
+
topic: raw.topic,
|
|
38
|
+
ts: Date.now(),
|
|
39
|
+
value: parsed
|
|
40
|
+
});
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (typeof parsed === 'object' && parsed !== null && typeof parsed.value === 'number') {
|
|
44
|
+
collector.accept({
|
|
45
|
+
topic: raw.topic,
|
|
46
|
+
ts: parsed.ts || Date.now(),
|
|
47
|
+
value: parsed.value
|
|
48
|
+
});
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
log.warn(`Cannot resolve payload on ${raw.topic}: no numeric value found`);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses Modbus device connection specs from MODBUS_DEVICES entries.
|
|
3
|
+
*
|
|
4
|
+
* TCP format: name:host:port
|
|
5
|
+
* RTU format: name:rtu:path:baud[:line][:slaveId]
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* parseModbusDeviceSpec('cooling-1:rtu:/dev/ttyUSB0:9600:8N1:1');
|
|
9
|
+
* parseModbusDeviceSpec('m-2:192.0.2.11:502');
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Parses serial line format such as 8N1 into modbus-serial options.
|
|
14
|
+
*
|
|
15
|
+
* @param {string} line - Data bits, parity letter, stop bits (e.g. 8N1)
|
|
16
|
+
* @returns {object} dataBits, parity, stopBits
|
|
17
|
+
*/
|
|
18
|
+
export function parseModbusLineFormat(line) {
|
|
19
|
+
if (typeof line !== 'string' || line.length !== 3) {
|
|
20
|
+
throw new Error(`Serial line format must be three characters such as 8N1, got: ${line}`);
|
|
21
|
+
}
|
|
22
|
+
const dataBits = parseInt(line.charAt(0), 10);
|
|
23
|
+
const parityLetter = line.charAt(1).toUpperCase();
|
|
24
|
+
const stopBits = parseInt(line.charAt(2), 10);
|
|
25
|
+
let parity = '';
|
|
26
|
+
if (parityLetter === 'N') {
|
|
27
|
+
parity = 'none';
|
|
28
|
+
} else if (parityLetter === 'E') {
|
|
29
|
+
parity = 'even';
|
|
30
|
+
} else if (parityLetter === 'O') {
|
|
31
|
+
parity = 'odd';
|
|
32
|
+
}
|
|
33
|
+
if (parity.length === 0 || Number.isNaN(dataBits) || Number.isNaN(stopBits)) {
|
|
34
|
+
throw new Error(`Serial line format must be such as 8N1, got: ${line}`);
|
|
35
|
+
}
|
|
36
|
+
return { dataBits, parity, stopBits };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Parses RTU device spec parts into connection parameters.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} spec - Full device spec string
|
|
43
|
+
* @param {Array<string>} parts - Colon-separated spec parts
|
|
44
|
+
* @returns {object} RTU device descriptor
|
|
45
|
+
*/
|
|
46
|
+
function parseRtuDeviceSpec(spec, parts) {
|
|
47
|
+
const name = parts[0];
|
|
48
|
+
const path = parts[2];
|
|
49
|
+
const baudRate = parseInt(parts[3], 10);
|
|
50
|
+
const line = parts[4] || '8N1';
|
|
51
|
+
const slaveId = parseInt(parts[5] || '1', 10);
|
|
52
|
+
if (typeof path !== 'string' || path.length === 0) {
|
|
53
|
+
throw new Error(`RTU path must be a non-empty string in spec: ${spec}`);
|
|
54
|
+
}
|
|
55
|
+
if (Number.isNaN(baudRate) || baudRate <= 0) {
|
|
56
|
+
throw new Error(`RTU baud rate must be positive in spec: ${spec}`);
|
|
57
|
+
}
|
|
58
|
+
if (Number.isNaN(slaveId) || slaveId <= 0) {
|
|
59
|
+
throw new Error(`RTU slave id must be positive in spec: ${spec}`);
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
kind: 'rtu',
|
|
63
|
+
name,
|
|
64
|
+
path,
|
|
65
|
+
serial: {
|
|
66
|
+
baudRate,
|
|
67
|
+
slaveId,
|
|
68
|
+
...parseModbusLineFormat(line)
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Parses TCP device spec parts into connection parameters.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} spec - Full device spec string
|
|
77
|
+
* @param {Array<string>} parts - Colon-separated spec parts
|
|
78
|
+
* @returns {object} TCP device descriptor
|
|
79
|
+
*/
|
|
80
|
+
function parseTcpDeviceSpec(spec, parts) {
|
|
81
|
+
const name = parts[0];
|
|
82
|
+
const host = parts[1];
|
|
83
|
+
const port = parseInt(parts[2], 10);
|
|
84
|
+
if (typeof host !== 'string' || host.length === 0) {
|
|
85
|
+
throw new Error(`TCP host must be a non-empty string in spec: ${spec}`);
|
|
86
|
+
}
|
|
87
|
+
if (Number.isNaN(port) || port <= 0) {
|
|
88
|
+
throw new Error(`TCP port must be positive in spec: ${spec}`);
|
|
89
|
+
}
|
|
90
|
+
return { kind: 'tcp', name, host, port };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Parses one MODBUS_DEVICES entry into TCP or RTU connection parameters.
|
|
95
|
+
*
|
|
96
|
+
* @param {string} spec - Comma-separated device spec fragment
|
|
97
|
+
* @returns {object} Parsed device connection descriptor
|
|
98
|
+
*/
|
|
99
|
+
export function parseModbusDeviceSpec(spec) {
|
|
100
|
+
if (typeof spec !== 'string' || spec.trim().length === 0) {
|
|
101
|
+
throw new Error('Device spec must be a non-empty string');
|
|
102
|
+
}
|
|
103
|
+
const parts = spec.trim().split(':');
|
|
104
|
+
const name = parts[0];
|
|
105
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
106
|
+
throw new Error('Device name must be a non-empty string');
|
|
107
|
+
}
|
|
108
|
+
if (parts[1] === 'rtu') {
|
|
109
|
+
return parseRtuDeviceSpec(spec, parts);
|
|
110
|
+
}
|
|
111
|
+
return parseTcpDeviceSpec(spec, parts);
|
|
112
|
+
}
|