@yarkivaev/scada 1.3.3 → 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 CHANGED
@@ -47,3 +47,5 @@ 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';
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.3",
3
+ "version": "1.5.0",
4
4
  "description": "SCADA domain objects for plant monitoring",
5
5
  "repository": {
6
6
  "type": "git",
@@ -22,6 +22,8 @@
22
22
  "eslint": "^9.39.2",
23
23
  "globals": "^17.0.0",
24
24
  "mocha": "^10.2.0",
25
+ "better-sqlite3": "^11.0.0",
26
+ "pg": "^8.0.0",
25
27
  "testcontainers": "^10.13.0"
26
28
  },
27
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
+ }
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 relabeling by startTime and streaming events via pubsub.
6
+ * Supports resolving by startTime and streaming events via pubsub.
7
7
  *
8
- * @returns {object} collection with add, relabel, query, stream methods
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.relabel(startTime, ['heating'], {});
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
- relabel(start, tags, properties) {
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: 'relabeled', segment: found });
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: 'relabeled', segment: found });
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
+ }