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