@yarkivaev/scada 1.5.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.
Files changed (190) hide show
  1. package/README.md +196 -33
  2. package/db/migrations/C0001__central_drop_metrics.sql +1 -0
  3. package/db/migrations/C0002__central_revoke_delete.sql +12 -0
  4. package/db/migrations/C0003__central_operators.sql +7 -0
  5. package/db/migrations/C0004__central_operations_grants.sql +2 -0
  6. package/db/migrations/C0005__central_operators_grants.sql +1 -0
  7. package/db/migrations/C0006__central_operators_registration.sql +16 -0
  8. package/db/migrations/E0001__edge_metrics.sql +8 -0
  9. package/db/migrations/E0002__edge_retention_delete.sql +8 -0
  10. package/db/migrations/E0003__edge_operations_grants.sql +2 -0
  11. package/db/migrations/V0001__baseline.sql +25 -0
  12. package/db/migrations/V0002__sink_extension_tables.sql +6 -0
  13. package/db/migrations/V0003__operations.sql +10 -0
  14. package/db/migrations/V0004__ingest_checkpoints.sql +6 -0
  15. package/db/migrations/V0005__user_decisions_operator.sql +2 -0
  16. package/index.js +75 -46
  17. package/package.json +28 -9
  18. package/src/application/bindSilentStreams.js +20 -0
  19. package/src/application/edgeOperatorCatalog.js +45 -0
  20. package/src/application/export/exportJob.js +118 -0
  21. package/src/application/export/exportQuery.js +32 -0
  22. package/src/application/export/exportSink.js +25 -0
  23. package/src/application/export/exportStream.js +33 -0
  24. package/src/application/machineInPlant.js +20 -0
  25. package/src/application/metricsPlant.js +35 -0
  26. package/src/application/plantApi.js +49 -0
  27. package/src/application/plantOperations.js +19 -0
  28. package/src/application/plantServer.js +107 -0
  29. package/src/application/shopWithTimeline.js +112 -0
  30. package/src/application/siteOperatorCatalog.js +78 -0
  31. package/src/application/siteServer.js +191 -0
  32. package/src/application/supervisorSink.js +97 -0
  33. package/src/application/timelineOperatorFromEnv.js +19 -0
  34. package/src/bin/supervisor-sink.js +28 -0
  35. package/src/{alert.js → domain/alerting/alert.js} +2 -2
  36. package/src/{alerts.js → domain/alerting/alerts.js} +3 -3
  37. package/src/domain/alerting/ingest.js +18 -0
  38. package/src/domain/ingest/ingestCursor.js +26 -0
  39. package/src/domain/operation/operation.js +23 -0
  40. package/src/domain/operation/operations.js +81 -0
  41. package/src/domain/operator/operator.js +23 -0
  42. package/src/domain/plant/machine.js +32 -0
  43. package/src/domain/plant/plant.js +20 -0
  44. package/src/domain/plant/shop.js +24 -0
  45. package/src/domain/segment/dispatch.js +31 -0
  46. package/src/domain/segment/normalize.js +31 -0
  47. package/src/domain/segment/silenceBudget.js +16 -0
  48. package/src/{initialized.js → domain/shared/initialized.js} +3 -4
  49. package/src/{pubsub.js → domain/shared/pubsub.js} +2 -3
  50. package/src/domain/timeline/timeline.js +25 -0
  51. package/src/infrastructure/catalog/tag-catalog.json +27 -0
  52. package/src/infrastructure/catalog/tagCatalog.js +65 -0
  53. package/src/infrastructure/client/index.js +20 -0
  54. package/src/infrastructure/client/machineClient.js +159 -0
  55. package/src/infrastructure/client/machineOperationsClient.js +159 -0
  56. package/src/infrastructure/client/scadaClient.js +67 -0
  57. package/src/infrastructure/client/sseConnection.js +42 -0
  58. package/src/infrastructure/http/edge/edgeApi.js +54 -0
  59. package/src/infrastructure/http/edge/httpMetricsRead.js +34 -0
  60. package/src/infrastructure/http/edge/metricsSensor.js +15 -0
  61. package/src/infrastructure/http/edge/parseStateHttpTimeoutMs.js +20 -0
  62. package/src/infrastructure/http/edge/routes/checkpoint/checkpointRoutes.js +79 -0
  63. package/src/infrastructure/http/edge/routes/metrics/metricsBatch.js +59 -0
  64. package/src/infrastructure/http/edge/routes/metrics/metricsCurrent.js +29 -0
  65. package/src/infrastructure/http/edge/routes/metrics/metricsPoll.js +25 -0
  66. package/src/infrastructure/http/edge/routes/metrics/metricsRange.js +26 -0
  67. package/src/infrastructure/http/edge/routes/metrics/metricsRoutes.js +21 -0
  68. package/src/infrastructure/http/edge/routes/retention/retentionRoutes.js +41 -0
  69. package/src/infrastructure/http/edge/startTestEdgeApi.js +39 -0
  70. package/src/infrastructure/http/edge/stateAccess.js +13 -0
  71. package/src/infrastructure/http/edge/stateHttpClient.js +106 -0
  72. package/src/infrastructure/http/edge/stateHttpTimeoutError.js +13 -0
  73. package/src/infrastructure/http/plant/json/decisionJson.js +44 -0
  74. package/src/infrastructure/http/plant/json/operationJson.js +28 -0
  75. package/src/infrastructure/http/plant/json/operatorJson.js +22 -0
  76. package/src/infrastructure/http/plant/json/segmentJson.js +27 -0
  77. package/src/infrastructure/http/plant/operationAudit.js +56 -0
  78. package/src/infrastructure/http/plant/routes/alertRoute.js +93 -0
  79. package/src/infrastructure/http/plant/routes/catalogRoute.js +21 -0
  80. package/src/infrastructure/http/plant/routes/decisionRoute.js +60 -0
  81. package/src/infrastructure/http/plant/routes/machineRoute.js +35 -0
  82. package/src/infrastructure/http/plant/routes/measurementRoute.js +60 -0
  83. package/src/infrastructure/http/plant/routes/operationDrafts.js +79 -0
  84. package/src/infrastructure/http/plant/routes/operationRoute.js +80 -0
  85. package/src/infrastructure/http/plant/routes/operationWrites.js +186 -0
  86. package/src/infrastructure/http/plant/routes/operatorRoute.js +143 -0
  87. package/src/infrastructure/http/plant/routes/simulationRoute.js +61 -0
  88. package/src/infrastructure/http/plant/routes/timelineRoute.js +112 -0
  89. package/src/infrastructure/http/plant/streams/alertStream.js +68 -0
  90. package/src/infrastructure/http/plant/streams/heartbeatStream.js +34 -0
  91. package/src/infrastructure/http/plant/streams/measurementStream.js +90 -0
  92. package/src/infrastructure/http/plant/streams/operationStream.js +42 -0
  93. package/src/infrastructure/http/plant/streams/timelineStream.js +97 -0
  94. package/src/infrastructure/http/plant/timelineOperator.js +87 -0
  95. package/src/infrastructure/ingest/activity/activityTracking.js +74 -0
  96. package/src/infrastructure/ingest/activity/activityTransformer.js +45 -0
  97. package/src/infrastructure/ingest/codecs/alertCodec.js +56 -0
  98. package/src/infrastructure/ingest/codecs/segmentCodec.js +30 -0
  99. package/src/infrastructure/ingest/codecs/userDecisionCodec.js +78 -0
  100. package/src/infrastructure/ingest/cooldown.js +24 -0
  101. package/src/infrastructure/ingest/db/migrate.js +78 -0
  102. package/src/infrastructure/ingest/db/migrationProfile.js +35 -0
  103. package/src/infrastructure/ingest/db/runRetention.js +58 -0
  104. package/src/infrastructure/ingest/ingestCheckpoint.js +71 -0
  105. package/src/infrastructure/ingest/modbus/mx210Tcp.js +81 -0
  106. package/src/infrastructure/ingest/modbus/silentStreams.js +152 -0
  107. package/src/infrastructure/ingest/mqtt/metricsTransformer.js +54 -0
  108. package/src/infrastructure/ingest/mqtt/modbusDeviceSpec.js +112 -0
  109. package/src/infrastructure/ingest/mqtt/modbusMqtt.js +204 -0
  110. package/src/infrastructure/ingest/mqtt/mqttMetrics.js +78 -0
  111. package/src/infrastructure/ingest/parseRequestTimeoutMs.js +20 -0
  112. package/src/infrastructure/ingest/pipelines/alertPipeline.js +48 -0
  113. package/src/infrastructure/ingest/pipelines/decisionPipeline.js +45 -0
  114. package/src/infrastructure/ingest/pipelines/segmentPipeline.js +60 -0
  115. package/src/infrastructure/ingest/processingErrorLog.js +25 -0
  116. package/src/infrastructure/ingest/silentOpenWatch.js +50 -0
  117. package/src/infrastructure/ingest/sinks/alertSink.js +43 -0
  118. package/src/infrastructure/ingest/sinks/closeOrphanOpen.js +32 -0
  119. package/src/infrastructure/ingest/sinks/closeSilentOpen.js +39 -0
  120. package/src/infrastructure/ingest/sinks/retagSink.js +43 -0
  121. package/src/infrastructure/ingest/sinks/userDecisionSink.js +41 -0
  122. package/src/infrastructure/ingest/telemetry/amqpMetricsIngest.js +94 -0
  123. package/src/infrastructure/ingest/telemetry/amqpMqttRelay.js +71 -0
  124. package/src/infrastructure/ingest/telemetry/deliverToMqttRecord.js +19 -0
  125. package/src/infrastructure/ingest/telemetry/streamNameFromTopic.js +21 -0
  126. package/src/infrastructure/messaging/ownership/httpOperations.js +96 -0
  127. package/src/infrastructure/messaging/ownership/httpTimeline.js +99 -0
  128. package/src/infrastructure/messaging/ownership/machineOwners.js +39 -0
  129. package/src/infrastructure/messaging/ownership/ownerTimeline.js +28 -0
  130. package/src/infrastructure/messaging/stomp/alerts/stompAlerts.js +21 -0
  131. package/src/infrastructure/messaging/stomp/alerts/stompAlertsCollection.js +51 -0
  132. package/src/infrastructure/messaging/stomp/alerts/stompAlertsInit.js +24 -0
  133. package/src/infrastructure/messaging/stomp/alerts/stompAlertsLogic.js +51 -0
  134. package/src/infrastructure/messaging/stomp/stompTimelineSegments.js +80 -0
  135. package/src/infrastructure/messaging/stomp/timeline.js +21 -0
  136. package/src/infrastructure/messaging/stomp/userDecisionBody.js +25 -0
  137. package/src/infrastructure/messaging/stomp/userDecisions.js +42 -0
  138. package/src/infrastructure/operators/centralOperators.js +64 -0
  139. package/src/infrastructure/operators/edgeOperators.js +39 -0
  140. package/src/infrastructure/operators/operatorById.js +33 -0
  141. package/src/infrastructure/operators/operatorExtras.js +41 -0
  142. package/src/infrastructure/operators/operators.js +32 -0
  143. package/src/infrastructure/operators/operatorsFromSeed.js +18 -0
  144. package/src/infrastructure/operators/operatorsSync.js +57 -0
  145. package/src/infrastructure/persistence/clickhouse/connection.js +58 -0
  146. package/src/infrastructure/persistence/clickhouse/pollTopicCursors.js +48 -0
  147. package/src/{clickhouseSensor.js → infrastructure/persistence/clickhouse/sensor.js} +9 -27
  148. package/src/infrastructure/persistence/clickhouse/streamHub.js +165 -0
  149. package/src/infrastructure/persistence/memory/alerts.js +21 -0
  150. package/src/infrastructure/persistence/memory/checkpoints.js +98 -0
  151. package/src/infrastructure/persistence/memory/metrics.js +69 -0
  152. package/src/infrastructure/persistence/memory/metricsDisabled.js +19 -0
  153. package/src/infrastructure/persistence/memory/operations.js +87 -0
  154. package/src/infrastructure/persistence/memory/segments.js +83 -0
  155. package/src/infrastructure/persistence/memory/timeline.js +56 -0
  156. package/src/infrastructure/persistence/metricsSensor.js +112 -0
  157. package/src/infrastructure/persistence/pg/alerts.js +25 -0
  158. package/src/infrastructure/persistence/pg/checkpoints.js +115 -0
  159. package/src/infrastructure/persistence/pg/metrics.js +73 -0
  160. package/src/infrastructure/persistence/pg/operations.js +95 -0
  161. package/src/infrastructure/persistence/pg/operators.js +118 -0
  162. package/src/infrastructure/persistence/pg/segments.js +48 -0
  163. package/src/infrastructure/persistence/pg/timeline.js +53 -0
  164. package/src/infrastructure/persistence/pg/userDecisions.js +72 -0
  165. package/src/infrastructure/persistence/postgresPool.js +24 -0
  166. package/src/infrastructure/persistence/stateDataFromMemory.js +28 -0
  167. package/src/infrastructure/persistence/stateDataFromPool.js +18 -0
  168. package/src/infrastructure/sync/operationCodec.js +75 -0
  169. package/src/infrastructure/sync/operationSyncIngest.js +82 -0
  170. package/src/infrastructure/sync/operationSyncSink.js +40 -0
  171. package/src/activeMelting.js +0 -54
  172. package/src/completedMelting.js +0 -42
  173. package/src/event.js +0 -24
  174. package/src/events.js +0 -51
  175. package/src/interval.js +0 -18
  176. package/src/machineChronology.js +0 -79
  177. package/src/meltingChronology.js +0 -37
  178. package/src/meltingMachine.js +0 -53
  179. package/src/meltingRuleEngine.js +0 -37
  180. package/src/meltingShop.js +0 -32
  181. package/src/meltings.js +0 -97
  182. package/src/monitoredMeltingMachine.js +0 -33
  183. package/src/plant.js +0 -23
  184. package/src/postgresSensor.js +0 -105
  185. package/src/requests.js +0 -44
  186. package/src/rule.js +0 -33
  187. package/src/rules.js +0 -23
  188. package/src/scyllaSensor.js +0 -54
  189. package/src/segments.js +0 -64
  190. 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
+ }