@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.
- package/client/scadaClient.js +39 -1
- package/index.js +4 -0
- package/package.json +2 -2
- package/src/objects/virtualClock.js +50 -0
- package/src/server/measurementStream.js +1 -1
- package/src/server/routes.js +1 -1
- package/src/server/scadaServer.js +3 -1
- package/src/server/segmentRoute.js +9 -9
- package/src/server/simulationRoute.js +63 -0
package/client/scadaClient.js
CHANGED
|
@@ -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.
|
|
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": "
|
|
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
|
+
}
|
package/src/server/routes.js
CHANGED
|
@@ -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((
|
|
44
|
+
const items = segments.map(({ name, start_time: startTime, end_time: endTime, duration, options: opts, label }) => {
|
|
45
45
|
const mapped = {
|
|
46
|
-
name
|
|
47
|
-
start:
|
|
48
|
-
end:
|
|
49
|
-
duration
|
|
46
|
+
name,
|
|
47
|
+
start: startTime.toISOString(),
|
|
48
|
+
end: duration === 0 ? new Date().toISOString() : endTime.toISOString(),
|
|
49
|
+
duration
|
|
50
50
|
};
|
|
51
|
-
if (
|
|
52
|
-
mapped.options =
|
|
51
|
+
if (opts) {
|
|
52
|
+
mapped.options = opts;
|
|
53
53
|
}
|
|
54
|
-
if (
|
|
55
|
-
mapped.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
|
+
}
|