@yarkivaev/scada-server 1.0.1 → 1.1.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/machineClient.js +45 -23
- package/package.json +2 -1
- package/src/server/machineRoute.js +4 -4
- package/src/server/requestRoute.js +86 -0
- package/src/server/requestStream.js +68 -0
- package/src/server/scadaServer.js +9 -1
- package/src/server/segmentRoute.js +63 -0
- package/src/server/segmentStream.js +67 -0
package/client/machineClient.js
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
import sseConnection from './sseConnection.js';
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Builds a JSON request payload with method, headers, and body.
|
|
5
|
+
*
|
|
6
|
+
* @param {string} method - HTTP method
|
|
7
|
+
* @param {object} data - request body data
|
|
8
|
+
* @returns {object} fetch options with method, headers, and stringified body
|
|
9
|
+
*/
|
|
10
|
+
function payload(method, data) {
|
|
11
|
+
return {
|
|
12
|
+
method,
|
|
13
|
+
headers: { 'Content-Type': 'application/json' },
|
|
14
|
+
body: JSON.stringify(data)
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
3
18
|
/**
|
|
4
19
|
* Client for single machine endpoints.
|
|
5
20
|
* Returns object with methods for machine operations.
|
|
@@ -18,11 +33,11 @@ export default function machineClient(baseUrl, machineId, fetcher, eventSource)
|
|
|
18
33
|
const url = `${baseUrl}/machines/${machineId}`;
|
|
19
34
|
async function request(path, options) {
|
|
20
35
|
const response = await fetcher(`${url}${path}`, options);
|
|
21
|
-
const
|
|
36
|
+
const result = await response.json();
|
|
22
37
|
if (!response.ok) {
|
|
23
|
-
throw
|
|
38
|
+
throw result;
|
|
24
39
|
}
|
|
25
|
-
return
|
|
40
|
+
return result;
|
|
26
41
|
}
|
|
27
42
|
return {
|
|
28
43
|
async info() {
|
|
@@ -80,11 +95,7 @@ export default function machineClient(baseUrl, machineId, fetcher, eventSource)
|
|
|
80
95
|
return sseConnection(`${url}/alerts/stream`, eventSource);
|
|
81
96
|
},
|
|
82
97
|
async acknowledge(alertId) {
|
|
83
|
-
return request(`/alerts/${alertId}`, {
|
|
84
|
-
method: 'PATCH',
|
|
85
|
-
headers: { 'Content-Type': 'application/json' },
|
|
86
|
-
body: JSON.stringify({ acknowledged: true })
|
|
87
|
-
});
|
|
98
|
+
return request(`/alerts/${alertId}`, payload('PATCH', { acknowledged: true }));
|
|
88
99
|
},
|
|
89
100
|
async meltings(options) {
|
|
90
101
|
const params = new URLSearchParams();
|
|
@@ -109,6 +120,29 @@ export default function machineClient(baseUrl, machineId, fetcher, eventSource)
|
|
|
109
120
|
meltingStream() {
|
|
110
121
|
return sseConnection(`${url}/meltings/stream`, eventSource);
|
|
111
122
|
},
|
|
123
|
+
async segments(options) {
|
|
124
|
+
const params = new URLSearchParams();
|
|
125
|
+
if (options && options.from) {
|
|
126
|
+
params.set('from', options.from);
|
|
127
|
+
}
|
|
128
|
+
if (options && options.to) {
|
|
129
|
+
params.set('to', options.to);
|
|
130
|
+
}
|
|
131
|
+
const qs = params.toString();
|
|
132
|
+
return request(`/segments${qs ? `?${qs}` : ''}`);
|
|
133
|
+
},
|
|
134
|
+
segmentStream() {
|
|
135
|
+
return sseConnection(`${url}/segments/stream`, eventSource);
|
|
136
|
+
},
|
|
137
|
+
async requests() {
|
|
138
|
+
return request('/requests');
|
|
139
|
+
},
|
|
140
|
+
requestStream() {
|
|
141
|
+
return sseConnection(`${url}/requests/stream`, eventSource);
|
|
142
|
+
},
|
|
143
|
+
async respond(requestId, data) {
|
|
144
|
+
return request(`/requests/${requestId}/respond`, payload('POST', data));
|
|
145
|
+
},
|
|
112
146
|
async startMelting() {
|
|
113
147
|
return request('/meltings/start', { method: 'POST' });
|
|
114
148
|
},
|
|
@@ -119,25 +153,13 @@ export default function machineClient(baseUrl, machineId, fetcher, eventSource)
|
|
|
119
153
|
return request('/weight');
|
|
120
154
|
},
|
|
121
155
|
async setWeight(amount) {
|
|
122
|
-
return request('/weight', {
|
|
123
|
-
method: 'PUT',
|
|
124
|
-
headers: { 'Content-Type': 'application/json' },
|
|
125
|
-
body: JSON.stringify({ amount })
|
|
126
|
-
});
|
|
156
|
+
return request('/weight', payload('PUT', { amount }));
|
|
127
157
|
},
|
|
128
158
|
async load(amount) {
|
|
129
|
-
return request('/load', {
|
|
130
|
-
method: 'POST',
|
|
131
|
-
headers: { 'Content-Type': 'application/json' },
|
|
132
|
-
body: JSON.stringify({ amount })
|
|
133
|
-
});
|
|
159
|
+
return request('/load', payload('POST', { amount }));
|
|
134
160
|
},
|
|
135
161
|
async dispense(amount) {
|
|
136
|
-
return request('/dispense', {
|
|
137
|
-
method: 'POST',
|
|
138
|
-
headers: { 'Content-Type': 'application/json' },
|
|
139
|
-
body: JSON.stringify({ amount })
|
|
140
|
-
});
|
|
162
|
+
return request('/dispense', payload('POST', { amount }));
|
|
141
163
|
}
|
|
142
164
|
};
|
|
143
165
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yarkivaev/scada-server",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Composable SCADA server implementing Supervisor API",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
21
|
"@eslint/js": "^9.39.2",
|
|
22
|
+
"@yarkivaev/scada": "^1.2.0",
|
|
22
23
|
"c8": "^10.1.3",
|
|
23
24
|
"eslint": "^9.39.2",
|
|
24
25
|
"globals": "^17.0.0",
|
|
@@ -55,13 +55,13 @@ export default function machineRoute(basePath, plant) {
|
|
|
55
55
|
}
|
|
56
56
|
jsonResponse(machine).send(res);
|
|
57
57
|
}),
|
|
58
|
-
route('GET', `${basePath}/machines/:machineId/weight`, (req, res, params) => {
|
|
58
|
+
route('GET', `${basePath}/machines/:machineId/weight`, async (req, res, params) => {
|
|
59
59
|
const machine = findMachine(params.machineId);
|
|
60
60
|
if (!machine) {
|
|
61
61
|
errorResponse('NOT_FOUND', `Machine '${params.machineId}' not found`, 404).send(res);
|
|
62
62
|
return;
|
|
63
63
|
}
|
|
64
|
-
jsonResponse({ amount: machine.chronology().get({ type: 'current' }).weight }).send(res);
|
|
64
|
+
jsonResponse({ amount: (await machine.chronology().get({ type: 'current' })).weight }).send(res);
|
|
65
65
|
}),
|
|
66
66
|
route('PUT', `${basePath}/machines/:machineId/weight`, (req, res, params) => {
|
|
67
67
|
const machine = findMachine(params.machineId);
|
|
@@ -73,10 +73,10 @@ export default function machineRoute(basePath, plant) {
|
|
|
73
73
|
req.on('data', (chunk) => {
|
|
74
74
|
body += chunk;
|
|
75
75
|
});
|
|
76
|
-
req.on('end', () => {
|
|
76
|
+
req.on('end', async () => {
|
|
77
77
|
const data = JSON.parse(body);
|
|
78
78
|
machine.reset(data.amount);
|
|
79
|
-
jsonResponse({ amount: machine.chronology().get({ type: 'current' }).weight }).send(res);
|
|
79
|
+
jsonResponse({ amount: (await machine.chronology().get({ type: 'current' })).weight }).send(res);
|
|
80
80
|
});
|
|
81
81
|
}),
|
|
82
82
|
route('POST', `${basePath}/machines/:machineId/load`, (req, res, params) => {
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import errorResponse from '../objects/errorResponse.js';
|
|
2
|
+
import jsonResponse from '../objects/jsonResponse.js';
|
|
3
|
+
import route from '../objects/route.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Label request routes factory.
|
|
7
|
+
* Creates routes for GET and POST /machines/:machineId/requests.
|
|
8
|
+
*
|
|
9
|
+
* @param {string} basePath - base URL path
|
|
10
|
+
* @param {object} plant - plant domain object from scada package
|
|
11
|
+
* @returns {array} array of route objects
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* const routes = requestRoute('/api/v1', plant);
|
|
15
|
+
*/
|
|
16
|
+
export default function requestRoute(basePath, plant) {
|
|
17
|
+
function find(id) {
|
|
18
|
+
for (const shop of Object.values(plant.shops.get())) {
|
|
19
|
+
const machine = shop.machines.get()[id];
|
|
20
|
+
if (machine) {
|
|
21
|
+
return { machine, shop };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
return [
|
|
27
|
+
route(
|
|
28
|
+
'GET',
|
|
29
|
+
`${basePath}/machines/:machineId/requests`,
|
|
30
|
+
async (req, res, params) => {
|
|
31
|
+
const result = find(params.machineId);
|
|
32
|
+
if (!result) {
|
|
33
|
+
jsonResponse({ items: [] }).send(res);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const { machine } = result;
|
|
37
|
+
const requests = await machine.requests.query();
|
|
38
|
+
const items = requests.map((item) => {
|
|
39
|
+
return {
|
|
40
|
+
id: item.id,
|
|
41
|
+
segment: {
|
|
42
|
+
name: item.name,
|
|
43
|
+
start: item.startTime.toISOString(),
|
|
44
|
+
end: item.endTime.toISOString(),
|
|
45
|
+
duration: item.duration
|
|
46
|
+
},
|
|
47
|
+
options: item.options
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
jsonResponse({ items }).send(res);
|
|
51
|
+
}
|
|
52
|
+
),
|
|
53
|
+
route(
|
|
54
|
+
'POST',
|
|
55
|
+
`${basePath}/machines/:machineId/requests/:requestId/respond`,
|
|
56
|
+
(req, res, params) => {
|
|
57
|
+
let body = '';
|
|
58
|
+
req.on('data', (chunk) => {
|
|
59
|
+
body += chunk;
|
|
60
|
+
});
|
|
61
|
+
req.on('end', async () => {
|
|
62
|
+
const result = find(params.machineId);
|
|
63
|
+
if (!result) {
|
|
64
|
+
errorResponse(
|
|
65
|
+
'NOT_FOUND',
|
|
66
|
+
`Machine '${params.machineId}' not found`,
|
|
67
|
+
404
|
|
68
|
+
).send(res);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const { machine } = result;
|
|
72
|
+
const response = await machine.requests.respond(params.requestId, JSON.parse(body));
|
|
73
|
+
if (!response) {
|
|
74
|
+
errorResponse(
|
|
75
|
+
'NOT_FOUND',
|
|
76
|
+
`Request '${params.requestId}' not found`,
|
|
77
|
+
404
|
|
78
|
+
).send(res);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
jsonResponse({ id: params.requestId, status: 'resolved' }).send(res);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
];
|
|
86
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import route from '../objects/route.js';
|
|
2
|
+
import sseResponse from '../objects/sseResponse.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Request stream route factory.
|
|
6
|
+
* Creates SSE route for /machines/:machineId/requests/stream.
|
|
7
|
+
* Streams request_created and request_resolved events for the specified machine.
|
|
8
|
+
*
|
|
9
|
+
* @param {string} basePath - base URL path
|
|
10
|
+
* @param {object} plant - plant domain object from scada package
|
|
11
|
+
* @param {function} clock - time provider
|
|
12
|
+
* @returns {array} array of route objects
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* const routes = requestStream('/api/v1', plant, clock);
|
|
16
|
+
*/
|
|
17
|
+
export default function requestStream(basePath, plant, clock) {
|
|
18
|
+
function find(machineId) {
|
|
19
|
+
for (const shop of Object.values(plant.shops.get())) {
|
|
20
|
+
const machine = shop.machines.get()[machineId];
|
|
21
|
+
if (machine) {
|
|
22
|
+
return { machine, shop };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
return [
|
|
28
|
+
route(
|
|
29
|
+
'GET',
|
|
30
|
+
`${basePath}/machines/:machineId/requests/stream`,
|
|
31
|
+
(req, res, params) => {
|
|
32
|
+
const sse = sseResponse(res, clock);
|
|
33
|
+
sse.heartbeat();
|
|
34
|
+
const result = find(params.machineId);
|
|
35
|
+
if (!result) {
|
|
36
|
+
sse.close();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const { machine } = result;
|
|
40
|
+
const subscription = machine.requests.stream((event) => {
|
|
41
|
+
if (event.type === 'created') {
|
|
42
|
+
sse.emit('request_created', {
|
|
43
|
+
id: event.request.id,
|
|
44
|
+
segment: {
|
|
45
|
+
name: event.request.name,
|
|
46
|
+
start: event.request.startTime.toISOString(),
|
|
47
|
+
end: event.request.endTime.toISOString(),
|
|
48
|
+
duration: event.request.duration
|
|
49
|
+
},
|
|
50
|
+
options: event.request.options
|
|
51
|
+
});
|
|
52
|
+
} else if (event.type === 'resolved') {
|
|
53
|
+
sse.emit('request_resolved', {
|
|
54
|
+
id: event.request.id
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
const heartbeat = setInterval(() => {
|
|
59
|
+
sse.heartbeat();
|
|
60
|
+
}, 30000);
|
|
61
|
+
req.on('close', () => {
|
|
62
|
+
clearInterval(heartbeat);
|
|
63
|
+
subscription.cancel();
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
];
|
|
68
|
+
}
|
|
@@ -5,6 +5,10 @@ import measurementRoute from './measurementRoute.js';
|
|
|
5
5
|
import measurementStream from './measurementStream.js';
|
|
6
6
|
import meltingRoute from './meltingRoute.js';
|
|
7
7
|
import meltingStream from './meltingStream.js';
|
|
8
|
+
import segmentRoute from './segmentRoute.js';
|
|
9
|
+
import segmentStream from './segmentStream.js';
|
|
10
|
+
import requestRoute from './requestRoute.js';
|
|
11
|
+
import requestStream from './requestStream.js';
|
|
8
12
|
import routes from './routes.js';
|
|
9
13
|
|
|
10
14
|
/**
|
|
@@ -35,7 +39,11 @@ export default function scadaServer(basePath, plant, clock) {
|
|
|
35
39
|
...alertStream(basePath, plant, time),
|
|
36
40
|
...alertRoute(basePath, plant),
|
|
37
41
|
...meltingStream(basePath, plant, time),
|
|
38
|
-
...meltingRoute(basePath, plant)
|
|
42
|
+
...meltingRoute(basePath, plant),
|
|
43
|
+
...segmentRoute(basePath, plant),
|
|
44
|
+
...segmentStream(basePath, plant, time),
|
|
45
|
+
...requestRoute(basePath, plant),
|
|
46
|
+
...requestStream(basePath, plant, time)
|
|
39
47
|
];
|
|
40
48
|
return routes(routeList);
|
|
41
49
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import jsonResponse from '../objects/jsonResponse.js';
|
|
2
|
+
import route from '../objects/route.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Segment routes factory.
|
|
6
|
+
* Creates route for GET /machines/:machineId/segments.
|
|
7
|
+
*
|
|
8
|
+
* @param {string} basePath - base URL path
|
|
9
|
+
* @param {object} plant - plant domain object from scada package
|
|
10
|
+
* @returns {array} array of route objects
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* const routes = segmentRoute('/api/v1', plant);
|
|
14
|
+
*/
|
|
15
|
+
export default function segmentRoute(basePath, plant) {
|
|
16
|
+
function find(id) {
|
|
17
|
+
for (const shop of Object.values(plant.shops.get())) {
|
|
18
|
+
const machine = shop.machines.get()[id];
|
|
19
|
+
if (machine) {
|
|
20
|
+
return { machine, shop };
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
return [
|
|
26
|
+
route(
|
|
27
|
+
'GET',
|
|
28
|
+
`${basePath}/machines/:machineId/segments`,
|
|
29
|
+
async (req, res, params, query) => {
|
|
30
|
+
const result = find(params.machineId);
|
|
31
|
+
if (!result) {
|
|
32
|
+
jsonResponse({ items: [] }).send(res);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const { machine } = result;
|
|
36
|
+
const options = {};
|
|
37
|
+
if (query.from) {
|
|
38
|
+
options.from = query.from;
|
|
39
|
+
}
|
|
40
|
+
if (query.to) {
|
|
41
|
+
options.to = query.to;
|
|
42
|
+
}
|
|
43
|
+
const segments = await machine.segments.query(options);
|
|
44
|
+
const items = segments.map((s) => {
|
|
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
|
|
50
|
+
};
|
|
51
|
+
if (s.options) {
|
|
52
|
+
mapped.options = s.options;
|
|
53
|
+
}
|
|
54
|
+
if (s.label) {
|
|
55
|
+
mapped.label = s.label;
|
|
56
|
+
}
|
|
57
|
+
return mapped;
|
|
58
|
+
});
|
|
59
|
+
jsonResponse({ items }).send(res);
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
];
|
|
63
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import route from '../objects/route.js';
|
|
2
|
+
import sseResponse from '../objects/sseResponse.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Segment stream route factory.
|
|
6
|
+
* Creates SSE route for /machines/:machineId/segments/stream.
|
|
7
|
+
* Streams segment_created and segment_relabeled events for the specified machine.
|
|
8
|
+
*
|
|
9
|
+
* @param {string} basePath - base URL path
|
|
10
|
+
* @param {object} plant - plant domain object from scada package
|
|
11
|
+
* @param {function} clock - time provider
|
|
12
|
+
* @returns {array} array of route objects
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* const routes = segmentStream('/api/v1', plant, clock);
|
|
16
|
+
*/
|
|
17
|
+
export default function segmentStream(basePath, plant, clock) {
|
|
18
|
+
function find(machineId) {
|
|
19
|
+
for (const shop of Object.values(plant.shops.get())) {
|
|
20
|
+
const machine = shop.machines.get()[machineId];
|
|
21
|
+
if (machine) {
|
|
22
|
+
return { machine, shop };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
return [
|
|
28
|
+
route(
|
|
29
|
+
'GET',
|
|
30
|
+
`${basePath}/machines/:machineId/segments/stream`,
|
|
31
|
+
(req, res, params) => {
|
|
32
|
+
const sse = sseResponse(res, clock);
|
|
33
|
+
sse.heartbeat();
|
|
34
|
+
const result = find(params.machineId);
|
|
35
|
+
if (!result) {
|
|
36
|
+
sse.close();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const { machine } = result;
|
|
40
|
+
const subscription = machine.segments.stream((event) => {
|
|
41
|
+
if (event.type === 'created') {
|
|
42
|
+
sse.emit('segment_created', {
|
|
43
|
+
name: event.segment.name,
|
|
44
|
+
start: event.segment.startTime.toISOString(),
|
|
45
|
+
end: event.segment.endTime.toISOString(),
|
|
46
|
+
duration: event.segment.duration
|
|
47
|
+
});
|
|
48
|
+
} else if (event.type === 'relabeled') {
|
|
49
|
+
sse.emit('segment_relabeled', {
|
|
50
|
+
name: event.segment.name,
|
|
51
|
+
start: event.segment.startTime.toISOString(),
|
|
52
|
+
end: event.segment.endTime.toISOString(),
|
|
53
|
+
duration: event.segment.duration
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
const heartbeat = setInterval(() => {
|
|
58
|
+
sse.heartbeat();
|
|
59
|
+
}, 30000);
|
|
60
|
+
req.on('close', () => {
|
|
61
|
+
clearInterval(heartbeat);
|
|
62
|
+
subscription.cancel();
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
];
|
|
67
|
+
}
|