@yarkivaev/scada 1.4.0 → 1.5.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/index.js +1 -0
- package/package.json +2 -1
- package/src/postgresSensor.js +105 -0
package/index.js
CHANGED
|
@@ -48,3 +48,4 @@ export { default as pubsub } from './src/pubsub.js';
|
|
|
48
48
|
export { default as scyllaSensor } from './src/scyllaSensor.js';
|
|
49
49
|
export { default as clickhouseSensor } from './src/clickhouseSensor.js';
|
|
50
50
|
export { default as sqliteSensor } from './src/sqliteSensor.js';
|
|
51
|
+
export { default as postgresSensor } from './src/postgresSensor.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yarkivaev/scada",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "SCADA domain objects for plant monitoring",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"globals": "^17.0.0",
|
|
24
24
|
"mocha": "^10.2.0",
|
|
25
25
|
"better-sqlite3": "^11.0.0",
|
|
26
|
+
"pg": "^8.0.0",
|
|
26
27
|
"testcontainers": "^10.13.0"
|
|
27
28
|
},
|
|
28
29
|
"files": [
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sensor backed by PostgreSQL metrics table with downsampling.
|
|
3
|
+
*
|
|
4
|
+
* Reads sensor measurements from a PostgreSQL metrics table
|
|
5
|
+
* and provides real-time streaming via polling.
|
|
6
|
+
* Supports time-based downsampling using date_bin.
|
|
7
|
+
*
|
|
8
|
+
* @param {object} connection - PostgreSQL connection with query(sql, params) method
|
|
9
|
+
* @param {string} topic - Metric topic in format '{machine}/{sensor}'
|
|
10
|
+
* @param {string} displayName - Human-readable sensor name
|
|
11
|
+
* @param {string} unit - Measurement unit (e.g., 'V', 'cos(φ)')
|
|
12
|
+
* @returns {object} sensor with name, current, measurements and stream methods
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* const sensor = postgresSensor(conn, 'icht1/voltage', 'Voltage', 'V');
|
|
16
|
+
* sensor.name(); // 'Voltage'
|
|
17
|
+
* await sensor.current(); // { found: true, timestamp, value, unit } or { found: false }
|
|
18
|
+
* await sensor.measurements({ start, end }, 60000); // downsampled to 1-minute intervals
|
|
19
|
+
* sensor.stream(since, 1000, callback); // live stream
|
|
20
|
+
*/
|
|
21
|
+
// eslint-disable-next-line max-lines-per-function
|
|
22
|
+
export default function postgresSensor(connection, topic, displayName, unit) {
|
|
23
|
+
return {
|
|
24
|
+
/**
|
|
25
|
+
* Returns the human-readable sensor name.
|
|
26
|
+
*
|
|
27
|
+
* @returns {string} Display name
|
|
28
|
+
*/
|
|
29
|
+
name() {
|
|
30
|
+
return displayName;
|
|
31
|
+
},
|
|
32
|
+
/**
|
|
33
|
+
* Returns the most recent measurement.
|
|
34
|
+
*
|
|
35
|
+
* @returns {Promise<object>} Object with found flag and optional timestamp, value, unit
|
|
36
|
+
*/
|
|
37
|
+
async current() {
|
|
38
|
+
const rows = await connection.query(
|
|
39
|
+
'SELECT ts, value FROM metrics WHERE topic = $1 ORDER BY ts DESC LIMIT 1',
|
|
40
|
+
[topic]
|
|
41
|
+
);
|
|
42
|
+
if (rows.length === 0) {
|
|
43
|
+
return { found: false };
|
|
44
|
+
}
|
|
45
|
+
return { found: true, timestamp: new Date(rows[0].ts), value: rows[0].value, unit };
|
|
46
|
+
},
|
|
47
|
+
/**
|
|
48
|
+
* Returns downsampled measurements within a time range.
|
|
49
|
+
*
|
|
50
|
+
* @param {object} range - Object with start and end Date properties
|
|
51
|
+
* @param {number} step - Downsampling bucket size in milliseconds
|
|
52
|
+
* @returns {Promise<Array>} Array of {timestamp, value, unit} objects
|
|
53
|
+
*/
|
|
54
|
+
async measurements(range, step) {
|
|
55
|
+
const seconds = Math.max(1, Math.floor(step / 1000));
|
|
56
|
+
const rows = await connection.query(
|
|
57
|
+
`SELECT bucket AS ts, value FROM (
|
|
58
|
+
SELECT date_bin($1::interval, ts, '1970-01-01'::timestamptz) AS bucket, value,
|
|
59
|
+
ROW_NUMBER() OVER (PARTITION BY date_bin($1::interval, ts, '1970-01-01'::timestamptz) ORDER BY ts DESC) AS rn
|
|
60
|
+
FROM metrics WHERE topic = $2 AND ts >= $3 AND ts <= $4
|
|
61
|
+
) sub WHERE rn = 1 ORDER BY ts`,
|
|
62
|
+
[`${seconds} seconds`, topic, range.start, range.end]
|
|
63
|
+
);
|
|
64
|
+
return rows.map((row) => {
|
|
65
|
+
return { timestamp: new Date(row.ts), value: row.value, unit };
|
|
66
|
+
});
|
|
67
|
+
},
|
|
68
|
+
/**
|
|
69
|
+
* Polls for new measurements and delivers them via callback.
|
|
70
|
+
*
|
|
71
|
+
* @param {Date} since - Start timestamp for polling
|
|
72
|
+
* @param {number} step - Polling interval in milliseconds
|
|
73
|
+
* @param {Function} callback - Called with each {timestamp, value, unit}
|
|
74
|
+
* @param {Function} [clock] - Optional time provider returning current Date
|
|
75
|
+
* @returns {object} Object with cancel() method to stop polling
|
|
76
|
+
*/
|
|
77
|
+
stream(since, step, callback, clock) {
|
|
78
|
+
const time = clock || (() => { return new Date(); });
|
|
79
|
+
let lastTs = since;
|
|
80
|
+
const timer = setInterval(async () => {
|
|
81
|
+
try {
|
|
82
|
+
const rows = await connection.query(
|
|
83
|
+
'SELECT ts, value FROM metrics WHERE topic = $1 AND ts > $2 AND ts <= $3 ORDER BY ts LIMIT 100',
|
|
84
|
+
[topic, lastTs, time()]
|
|
85
|
+
);
|
|
86
|
+
rows.forEach((row) => {
|
|
87
|
+
const timestamp = new Date(row.ts);
|
|
88
|
+
callback({ timestamp, value: row.value, unit });
|
|
89
|
+
lastTs = timestamp;
|
|
90
|
+
});
|
|
91
|
+
} catch {
|
|
92
|
+
// Connection errors are non-fatal; next poll retries
|
|
93
|
+
}
|
|
94
|
+
}, step);
|
|
95
|
+
return {
|
|
96
|
+
/**
|
|
97
|
+
* Stops the polling timer.
|
|
98
|
+
*/
|
|
99
|
+
cancel() {
|
|
100
|
+
clearInterval(timer);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}
|