@yarkivaev/scada-server 1.1.2 → 1.2.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.
@@ -7,12 +7,14 @@ import machineClient from './machineClient.js';
7
7
  * @param {string} baseUrl - server base URL
8
8
  * @param {function} fetcher - fetch function
9
9
  * @param {function} eventSource - EventSource constructor
10
- * @returns {object} client with machines, machine methods
10
+ * @returns {object} client with machines, machine, jump, reset, simulation methods
11
11
  *
12
12
  * @example
13
13
  * const client = scadaClient('http://localhost:3000/api/v1', fetch, EventSource);
14
14
  * const machines = await client.machines();
15
15
  * const machine = client.machine('icht1');
16
+ * await client.jump('2025-06-15T10:00:00Z');
17
+ * await client.reset();
16
18
  */
17
19
  export default function scadaClient(baseUrl, fetcher, eventSource) {
18
20
  return {
@@ -28,6 +30,42 @@ export default function scadaClient(baseUrl, fetcher, eventSource) {
28
30
  },
29
31
  machine(machineId) {
30
32
  return machineClient(baseUrl, machineId, fetcher, eventSource);
33
+ },
34
+ async jump(timestamp) {
35
+ const response = await fetcher(`${baseUrl}/simulation/jump`, {
36
+ method: 'POST',
37
+ headers: { 'Content-Type': 'application/json' },
38
+ body: JSON.stringify({ timestamp })
39
+ });
40
+ const payload = await response.json();
41
+ if (!response.ok) {
42
+ const error = new Error(payload.error.message);
43
+ error.code = payload.error.code;
44
+ throw error;
45
+ }
46
+ return payload;
47
+ },
48
+ async reset() {
49
+ const response = await fetcher(`${baseUrl}/simulation`, {
50
+ method: 'DELETE'
51
+ });
52
+ const payload = await response.json();
53
+ if (!response.ok) {
54
+ const error = new Error(payload.error.message);
55
+ error.code = payload.error.code;
56
+ throw error;
57
+ }
58
+ return payload;
59
+ },
60
+ async simulation() {
61
+ const response = await fetcher(`${baseUrl}/simulation`);
62
+ const payload = await response.json();
63
+ if (!response.ok) {
64
+ const error = new Error(payload.error.message);
65
+ error.code = payload.error.code;
66
+ throw error;
67
+ }
68
+ return payload;
31
69
  }
32
70
  };
33
71
  }
package/index.js CHANGED
@@ -16,6 +16,10 @@
16
16
  export { default as scadaServer } from './src/server/scadaServer.js';
17
17
  export { default as routes } from './src/server/routes.js';
18
18
 
19
+ // Simulation
20
+ export { default as virtualClock } from './src/objects/virtualClock.js';
21
+ export { default as simulationRoute } from './src/server/simulationRoute.js';
22
+
19
23
  // Objects
20
24
  export { default as route } from './src/objects/route.js';
21
25
  export { default as jsonResponse } from './src/objects/jsonResponse.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yarkivaev/scada-server",
3
- "version": "1.1.2",
3
+ "version": "1.2.0",
4
4
  "description": "Composable SCADA server implementing Supervisor API",
5
5
  "repository": {
6
6
  "type": "git",
@@ -23,7 +23,7 @@
23
23
  },
24
24
  "devDependencies": {
25
25
  "@eslint/js": "^9.39.2",
26
- "@yarkivaev/scada": "^1.2.0",
26
+ "@yarkivaev/scada": "1.3.0",
27
27
  "c8": "^10.1.3",
28
28
  "eslint": "^9.39.2",
29
29
  "globals": "^17.0.0",
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Virtual clock with mutable time offset for simulation.
3
+ * Callable as a function (backward-compatible with clock()),
4
+ * with jump(), reset(), and offset() methods.
5
+ *
6
+ * @param {function} source - real time provider returning Date
7
+ * @returns {function} clock function with jump, reset, offset methods
8
+ *
9
+ * @example
10
+ * const clock = virtualClock(() => new Date());
11
+ * clock(); // returns current real time
12
+ * clock.jump(new Date('2025-01-01T00:00:00Z'));
13
+ * clock(); // returns time offset from 2025-01-01
14
+ * clock.reset();
15
+ * clock(); // returns current real time again
16
+ */
17
+ export default function virtualClock(source) {
18
+ let shift = 0;
19
+ /**
20
+ * Returns current virtual time as Date.
21
+ *
22
+ * @returns {Date} virtual time shifted from source
23
+ */
24
+ function clock() {
25
+ return new Date(source().getTime() + shift);
26
+ }
27
+ /**
28
+ * Jumps virtual time to target date.
29
+ *
30
+ * @param {Date} target - date to jump to
31
+ */
32
+ clock.jump = function jump(target) {
33
+ shift = target.getTime() - source().getTime();
34
+ };
35
+ /**
36
+ * Resets virtual time offset to zero.
37
+ */
38
+ clock.reset = function reset() {
39
+ shift = 0;
40
+ };
41
+ /**
42
+ * Returns current time offset in milliseconds.
43
+ *
44
+ * @returns {number} offset in milliseconds
45
+ */
46
+ clock.offset = function offset() {
47
+ return shift;
48
+ };
49
+ return clock;
50
+ }
@@ -52,7 +52,7 @@ export default function measurementStream(basePath, plant, clock) {
52
52
  timestamp: item.timestamp.toISOString(),
53
53
  value: item.value
54
54
  });
55
- });
55
+ }, clock);
56
56
  subscriptions.push(subscription);
57
57
  }
58
58
  });
@@ -20,7 +20,7 @@ export default function routes(routeList) {
20
20
  if (req.method === 'OPTIONS') {
21
21
  res.writeHead(200, {
22
22
  'Access-Control-Allow-Origin': '*',
23
- 'Access-Control-Allow-Methods': 'GET, PATCH, OPTIONS',
23
+ 'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
24
24
  'Access-Control-Allow-Headers': 'Content-Type'
25
25
  });
26
26
  res.end();
@@ -9,6 +9,7 @@ import segmentRoute from './segmentRoute.js';
9
9
  import segmentStream from './segmentStream.js';
10
10
  import requestRoute from './requestRoute.js';
11
11
  import requestStream from './requestStream.js';
12
+ import simulationRoute from './simulationRoute.js';
12
13
  import routes from './routes.js';
13
14
 
14
15
  /**
@@ -43,7 +44,8 @@ export default function scadaServer(basePath, plant, clock) {
43
44
  ...segmentRoute(basePath, plant),
44
45
  ...segmentStream(basePath, plant, time),
45
46
  ...requestRoute(basePath, plant),
46
- ...requestStream(basePath, plant, time)
47
+ ...requestStream(basePath, plant, time),
48
+ ...(time.jump ? simulationRoute(basePath, time) : [])
47
49
  ];
48
50
  return routes(routeList);
49
51
  }
@@ -41,18 +41,18 @@ export default function segmentRoute(basePath, plant) {
41
41
  options.to = query.to;
42
42
  }
43
43
  const segments = await machine.segments.query(options);
44
- const items = segments.map((s) => {
44
+ const items = segments.map(({ name, start_time: startTime, end_time: endTime, duration, options: opts, label }) => {
45
45
  const mapped = {
46
- name: s.name,
47
- start: s.startTime.toISOString(),
48
- end: s.duration === 0 ? new Date().toISOString() : s.endTime.toISOString(),
49
- duration: s.duration
46
+ name,
47
+ start: startTime.toISOString(),
48
+ end: duration === 0 ? new Date().toISOString() : endTime.toISOString(),
49
+ duration
50
50
  };
51
- if (s.options) {
52
- mapped.options = s.options;
51
+ if (opts) {
52
+ mapped.options = opts;
53
53
  }
54
- if (s.label) {
55
- mapped.label = s.label;
54
+ if (label) {
55
+ mapped.label = label;
56
56
  }
57
57
  return mapped;
58
58
  });
@@ -0,0 +1,63 @@
1
+ import errorResponse from '../objects/errorResponse.js';
2
+ import jsonResponse from '../objects/jsonResponse.js';
3
+ import route from '../objects/route.js';
4
+
5
+ /**
6
+ * Simulation control routes factory.
7
+ * Creates routes for jumping, resetting, and querying virtual clock.
8
+ *
9
+ * @param {string} basePath - base URL path
10
+ * @param {function} clock - virtual clock with jump, reset, offset methods
11
+ * @returns {array} array of route objects
12
+ *
13
+ * @example
14
+ * const routes = simulationRoute('/api', virtualClock(() => new Date()));
15
+ */
16
+ export default function simulationRoute(basePath, clock) {
17
+ return [
18
+ route(
19
+ 'POST',
20
+ `${basePath}/simulation/jump`,
21
+ (req, res) => {
22
+ let body = '';
23
+ req.on('data', (chunk) => {
24
+ body += chunk;
25
+ });
26
+ req.on('end', () => {
27
+ const parsed = JSON.parse(body);
28
+ const target = new Date(parsed.timestamp);
29
+ if (isNaN(target.getTime())) {
30
+ errorResponse('BAD_REQUEST', 'Invalid timestamp', 400).send(res);
31
+ return;
32
+ }
33
+ clock.jump(target);
34
+ jsonResponse({
35
+ timestamp: clock().toISOString(),
36
+ offset: clock.offset()
37
+ }).send(res);
38
+ });
39
+ }
40
+ ),
41
+ route(
42
+ 'DELETE',
43
+ `${basePath}/simulation`,
44
+ (req, res) => {
45
+ clock.reset();
46
+ jsonResponse({
47
+ timestamp: clock().toISOString(),
48
+ offset: clock.offset()
49
+ }).send(res);
50
+ }
51
+ ),
52
+ route(
53
+ 'GET',
54
+ `${basePath}/simulation`,
55
+ (req, res) => {
56
+ jsonResponse({
57
+ timestamp: clock().toISOString(),
58
+ offset: clock.offset()
59
+ }).send(res);
60
+ }
61
+ )
62
+ ];
63
+ }