@yarkivaev/scada-server 1.0.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/LICENSE +21 -0
- package/README.md +53 -0
- package/client/index.js +12 -0
- package/client/machineClient.js +143 -0
- package/client/scadaClient.js +33 -0
- package/client/sseConnection.js +30 -0
- package/index.js +26 -0
- package/package.json +32 -0
- package/src/objects/cursor.js +50 -0
- package/src/objects/errorResponse.js +20 -0
- package/src/objects/jsonResponse.js +24 -0
- package/src/objects/pagination.js +29 -0
- package/src/objects/route.js +54 -0
- package/src/objects/sseResponse.js +34 -0
- package/src/objects/timeExpression.js +47 -0
- package/src/server/alertRoute.js +96 -0
- package/src/server/alertStream.js +68 -0
- package/src/server/machineRoute.js +115 -0
- package/src/server/measurementRoute.js +63 -0
- package/src/server/measurementStream.js +71 -0
- package/src/server/meltingRoute.js +175 -0
- package/src/server/meltingStream.js +70 -0
- package/src/server/routes.js +39 -0
- package/src/server/scadaServer.js +41 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import errorResponse from '../objects/errorResponse.js';
|
|
2
|
+
import jsonResponse from '../objects/jsonResponse.js';
|
|
3
|
+
import pagination from '../objects/pagination.js';
|
|
4
|
+
import route from '../objects/route.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Alert routes factory.
|
|
8
|
+
* Creates routes for GET and PATCH /machines/:machineId/alerts.
|
|
9
|
+
*
|
|
10
|
+
* @param {string} basePath - base URL path
|
|
11
|
+
* @param {object} plant - plant domain object from scada package
|
|
12
|
+
* @returns {array} array of route objects
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* const routes = alertRoute('/api/v1', plant);
|
|
16
|
+
*/
|
|
17
|
+
export default function alertRoute(basePath, plant) {
|
|
18
|
+
function find(id) {
|
|
19
|
+
for (const shop of Object.values(plant.shops.get())) {
|
|
20
|
+
const machine = shop.machines.get()[id];
|
|
21
|
+
if (machine) {
|
|
22
|
+
return machine;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
function findAlert(alertId) {
|
|
28
|
+
for (const shop of Object.values(plant.shops.get())) {
|
|
29
|
+
for (const machine of Object.values(shop.machines.get())) {
|
|
30
|
+
const alert = machine.alerts().find((a) => {
|
|
31
|
+
return a.id === alertId;
|
|
32
|
+
});
|
|
33
|
+
if (alert) {
|
|
34
|
+
return alert;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
return [
|
|
41
|
+
route(
|
|
42
|
+
'GET',
|
|
43
|
+
`${basePath}/machines/:machineId/alerts`,
|
|
44
|
+
(req, res, params, query) => {
|
|
45
|
+
const machine = find(params.machineId);
|
|
46
|
+
if (!machine) {
|
|
47
|
+
jsonResponse({ items: [], page: 1, size: 10, total: 0 }).send(res);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const page = query.page ? parseInt(query.page, 10) : 1;
|
|
51
|
+
const size = query.size ? parseInt(query.size, 10) : 10;
|
|
52
|
+
let alerts = machine.alerts();
|
|
53
|
+
if (query.acknowledged === 'true') {
|
|
54
|
+
alerts = alerts.filter((a) => {
|
|
55
|
+
return a.acknowledged === true;
|
|
56
|
+
});
|
|
57
|
+
} else if (query.acknowledged === 'false') {
|
|
58
|
+
alerts = alerts.filter((a) => {
|
|
59
|
+
return a.acknowledged === false;
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
const mapped = alerts.map((a) => {
|
|
63
|
+
return { id: a.id, message: a.message, timestamp: a.timestamp.toISOString(), object: a.object, acknowledged: a.acknowledged };
|
|
64
|
+
});
|
|
65
|
+
const paginated = pagination(page, size, mapped).result();
|
|
66
|
+
jsonResponse({ items: paginated.items, page: paginated.page, size: paginated.size, total: paginated.total }).send(res);
|
|
67
|
+
}
|
|
68
|
+
),
|
|
69
|
+
route(
|
|
70
|
+
'PATCH',
|
|
71
|
+
`${basePath}/machines/:machineId/alerts/:alertId`,
|
|
72
|
+
(req, res, params) => {
|
|
73
|
+
let body = '';
|
|
74
|
+
req.on('data', (chunk) => {
|
|
75
|
+
body += chunk;
|
|
76
|
+
});
|
|
77
|
+
req.on('end', () => {
|
|
78
|
+
const changes = JSON.parse(body);
|
|
79
|
+
const alert = findAlert(params.alertId);
|
|
80
|
+
if (!alert) {
|
|
81
|
+
errorResponse(
|
|
82
|
+
'NOT_FOUND',
|
|
83
|
+
`Alert '${params.alertId}' not found`,
|
|
84
|
+
404
|
|
85
|
+
).send(res);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (changes.acknowledged === true && !alert.acknowledged) {
|
|
89
|
+
alert.acknowledge();
|
|
90
|
+
}
|
|
91
|
+
jsonResponse({ id: alert.id, message: alert.message, timestamp: alert.timestamp.toISOString(), object: alert.object, acknowledged: true }).send(res);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
)
|
|
95
|
+
];
|
|
96
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import route from '../objects/route.js';
|
|
2
|
+
import sseResponse from '../objects/sseResponse.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Alert stream route factory.
|
|
6
|
+
* Creates SSE route for /machines/:machineId/alerts/stream.
|
|
7
|
+
* Streams alert_created and alert_updated 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 = alertStream('/api/v1', plant, clock);
|
|
16
|
+
*/
|
|
17
|
+
export default function alertStream(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/alerts/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, shop } = result;
|
|
40
|
+
const subscription = shop.alerts.stream((event) => {
|
|
41
|
+
if (event.alert.object === machine.name()) {
|
|
42
|
+
if (event.type === 'created') {
|
|
43
|
+
sse.emit('alert_created', {
|
|
44
|
+
id: event.alert.id,
|
|
45
|
+
message: event.alert.message,
|
|
46
|
+
timestamp: event.alert.timestamp.toISOString(),
|
|
47
|
+
object: event.alert.object,
|
|
48
|
+
acknowledged: event.alert.acknowledged
|
|
49
|
+
});
|
|
50
|
+
} else if (event.type === 'acknowledged') {
|
|
51
|
+
sse.emit('alert_updated', {
|
|
52
|
+
id: event.alert.id,
|
|
53
|
+
acknowledged: true
|
|
54
|
+
});
|
|
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
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import errorResponse from '../objects/errorResponse.js';
|
|
2
|
+
import jsonResponse from '../objects/jsonResponse.js';
|
|
3
|
+
import route from '../objects/route.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Machine routes factory.
|
|
7
|
+
* Creates routes for GET /machines and GET /machines/:machineId.
|
|
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 = machineRoute('/api/v1', plant);
|
|
15
|
+
*/
|
|
16
|
+
export default function machineRoute(basePath, plant) {
|
|
17
|
+
function all() {
|
|
18
|
+
return Object.values(plant.shops.get()).flatMap((shop) => {
|
|
19
|
+
return Object.values(shop.machines.get()).map((machine) => {
|
|
20
|
+
return { id: machine.name(), name: machine.name() };
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
function find(id) {
|
|
25
|
+
for (const shop of Object.values(plant.shops.get())) {
|
|
26
|
+
const machine = shop.machines.get()[id];
|
|
27
|
+
if (machine) {
|
|
28
|
+
return { id: machine.name(), name: machine.name() };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
function findMachine(id) {
|
|
34
|
+
for (const shop of Object.values(plant.shops.get())) {
|
|
35
|
+
const machine = shop.machines.get()[id];
|
|
36
|
+
if (machine) {
|
|
37
|
+
return machine;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
return [
|
|
43
|
+
route('GET', `${basePath}/machines`, (req, res) => {
|
|
44
|
+
jsonResponse({ items: all() }).send(res);
|
|
45
|
+
}),
|
|
46
|
+
route('GET', `${basePath}/machines/:machineId`, (req, res, params) => {
|
|
47
|
+
const machine = find(params.machineId);
|
|
48
|
+
if (!machine) {
|
|
49
|
+
errorResponse(
|
|
50
|
+
'NOT_FOUND',
|
|
51
|
+
`Machine '${params.machineId}' not found`,
|
|
52
|
+
404
|
|
53
|
+
).send(res);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
jsonResponse(machine).send(res);
|
|
57
|
+
}),
|
|
58
|
+
route('GET', `${basePath}/machines/:machineId/weight`, (req, res, params) => {
|
|
59
|
+
const machine = findMachine(params.machineId);
|
|
60
|
+
if (!machine) {
|
|
61
|
+
errorResponse('NOT_FOUND', `Machine '${params.machineId}' not found`, 404).send(res);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
jsonResponse({ amount: machine.chronology().get({ type: 'current' }).weight }).send(res);
|
|
65
|
+
}),
|
|
66
|
+
route('PUT', `${basePath}/machines/:machineId/weight`, (req, res, params) => {
|
|
67
|
+
const machine = findMachine(params.machineId);
|
|
68
|
+
if (!machine) {
|
|
69
|
+
errorResponse('NOT_FOUND', `Machine '${params.machineId}' not found`, 404).send(res);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
let body = '';
|
|
73
|
+
req.on('data', (chunk) => {
|
|
74
|
+
body += chunk;
|
|
75
|
+
});
|
|
76
|
+
req.on('end', () => {
|
|
77
|
+
const data = JSON.parse(body);
|
|
78
|
+
machine.reset(data.amount);
|
|
79
|
+
jsonResponse({ amount: machine.chronology().get({ type: 'current' }).weight }).send(res);
|
|
80
|
+
});
|
|
81
|
+
}),
|
|
82
|
+
route('POST', `${basePath}/machines/:machineId/load`, (req, res, params) => {
|
|
83
|
+
const machine = findMachine(params.machineId);
|
|
84
|
+
if (!machine) {
|
|
85
|
+
errorResponse('NOT_FOUND', `Machine '${params.machineId}' not found`, 404).send(res);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
let body = '';
|
|
89
|
+
req.on('data', (chunk) => {
|
|
90
|
+
body += chunk;
|
|
91
|
+
});
|
|
92
|
+
req.on('end', () => {
|
|
93
|
+
const data = JSON.parse(body);
|
|
94
|
+
machine.load(data.amount);
|
|
95
|
+
jsonResponse({ amount: data.amount }).send(res);
|
|
96
|
+
});
|
|
97
|
+
}),
|
|
98
|
+
route('POST', `${basePath}/machines/:machineId/dispense`, (req, res, params) => {
|
|
99
|
+
const machine = findMachine(params.machineId);
|
|
100
|
+
if (!machine) {
|
|
101
|
+
errorResponse('NOT_FOUND', `Machine '${params.machineId}' not found`, 404).send(res);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
let body = '';
|
|
105
|
+
req.on('data', (chunk) => {
|
|
106
|
+
body += chunk;
|
|
107
|
+
});
|
|
108
|
+
req.on('end', () => {
|
|
109
|
+
const data = JSON.parse(body);
|
|
110
|
+
machine.dispense(data.amount);
|
|
111
|
+
jsonResponse({ amount: data.amount }).send(res);
|
|
112
|
+
});
|
|
113
|
+
})
|
|
114
|
+
];
|
|
115
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import jsonResponse from '../objects/jsonResponse.js';
|
|
2
|
+
import route from '../objects/route.js';
|
|
3
|
+
import timeExpression from '../objects/timeExpression.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Measurement routes factory.
|
|
7
|
+
* Creates route for GET /machines/:machineId/measurements.
|
|
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 = measurementRoute('/api/v1', plant, clock);
|
|
16
|
+
*/
|
|
17
|
+
export default function measurementRoute(basePath, plant, clock) {
|
|
18
|
+
function beginning() {
|
|
19
|
+
return new Date(clock().getTime() - 30 * 24 * 60 * 60 * 1000);
|
|
20
|
+
}
|
|
21
|
+
function find(id) {
|
|
22
|
+
for (const shop of Object.values(plant.shops.get())) {
|
|
23
|
+
const machine = shop.machines.get()[id];
|
|
24
|
+
if (machine) {
|
|
25
|
+
return machine;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
return [
|
|
31
|
+
route(
|
|
32
|
+
'GET',
|
|
33
|
+
`${basePath}/machines/:machineId/measurements`,
|
|
34
|
+
async (req, res, params, query) => {
|
|
35
|
+
const machine = find(params.machineId);
|
|
36
|
+
if (!machine) {
|
|
37
|
+
jsonResponse({ items: [] }).send(res);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const requested = query.keys ? query.keys.split(',') : Object.keys(machine.sensors);
|
|
41
|
+
const keys = requested.filter((key) => {return machine.sensors[key]});
|
|
42
|
+
const fromExpr = query.from || 'now-1M';
|
|
43
|
+
const toExpr = query.to || 'now';
|
|
44
|
+
const from = timeExpression(fromExpr, clock, beginning).resolve();
|
|
45
|
+
const to = timeExpression(toExpr, clock, beginning).resolve();
|
|
46
|
+
const step = query.step ? parseInt(query.step, 10) * 1000 : 1000;
|
|
47
|
+
const range = { start: from, end: to };
|
|
48
|
+
const promises = keys.map(async (key) => {
|
|
49
|
+
const sensor = machine.sensors[key];
|
|
50
|
+
const measurements = await sensor.measurements(range, step);
|
|
51
|
+
const unit = measurements.length > 0 ? measurements[0].unit : '';
|
|
52
|
+
const values = measurements.map((m) => {return {
|
|
53
|
+
timestamp: m.timestamp.toISOString(),
|
|
54
|
+
value: m.value
|
|
55
|
+
}});
|
|
56
|
+
return { key, name: sensor.name(), unit, values };
|
|
57
|
+
});
|
|
58
|
+
const items = await Promise.all(promises);
|
|
59
|
+
jsonResponse({ items }).send(res);
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
];
|
|
63
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import route from '../objects/route.js';
|
|
2
|
+
import sseResponse from '../objects/sseResponse.js';
|
|
3
|
+
import timeExpression from '../objects/timeExpression.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Measurement stream route factory.
|
|
7
|
+
* Creates SSE route for /machines/:machineId/measurements/stream.
|
|
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 = measurementStream('/api/v1', plant, clock);
|
|
16
|
+
*/
|
|
17
|
+
export default function measurementStream(basePath, plant, clock) {
|
|
18
|
+
function beginning() {
|
|
19
|
+
return new Date(clock().getTime() - 30 * 24 * 60 * 60 * 1000);
|
|
20
|
+
}
|
|
21
|
+
function find(id) {
|
|
22
|
+
for (const shop of Object.values(plant.shops.get())) {
|
|
23
|
+
const machine = shop.machines.get()[id];
|
|
24
|
+
if (machine) {
|
|
25
|
+
return machine;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
return [
|
|
31
|
+
route(
|
|
32
|
+
'GET',
|
|
33
|
+
`${basePath}/machines/:machineId/measurements/stream`,
|
|
34
|
+
(req, res, params, query) => {
|
|
35
|
+
const sse = sseResponse(res, clock);
|
|
36
|
+
sse.heartbeat();
|
|
37
|
+
const machine = find(params.machineId);
|
|
38
|
+
if (!machine) {
|
|
39
|
+
sse.close();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const keys = query.keys ? query.keys.split(',') : Object.keys(machine.sensors);
|
|
43
|
+
const sinceExpr = query.since || 'now';
|
|
44
|
+
const since = timeExpression(sinceExpr, clock, beginning).resolve();
|
|
45
|
+
const step = query.step ? parseInt(query.step, 10) * 1000 : 1000;
|
|
46
|
+
const subscriptions = [];
|
|
47
|
+
keys.forEach((key) => {
|
|
48
|
+
if (machine.sensors[key]) {
|
|
49
|
+
const subscription = machine.sensors[key].stream(since, step, (item) => {
|
|
50
|
+
sse.emit('measurement', {
|
|
51
|
+
key,
|
|
52
|
+
timestamp: item.timestamp.toISOString(),
|
|
53
|
+
value: item.value
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
subscriptions.push(subscription);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
const heartbeat = setInterval(() => {
|
|
60
|
+
sse.heartbeat();
|
|
61
|
+
}, 30000);
|
|
62
|
+
req.on('close', () => {
|
|
63
|
+
clearInterval(heartbeat);
|
|
64
|
+
subscriptions.forEach((subscription) => {
|
|
65
|
+
subscription.cancel();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
];
|
|
71
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import cursor from '../objects/cursor.js';
|
|
2
|
+
import errorResponse from '../objects/errorResponse.js';
|
|
3
|
+
import jsonResponse from '../objects/jsonResponse.js';
|
|
4
|
+
import route from '../objects/route.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Converts melting domain object to API response format.
|
|
8
|
+
*
|
|
9
|
+
* @param {object} melting - melting domain object
|
|
10
|
+
* @returns {object} API response object
|
|
11
|
+
*/
|
|
12
|
+
function format(melting) {
|
|
13
|
+
const data = melting.chronology().get();
|
|
14
|
+
const result = {
|
|
15
|
+
id: melting.id(),
|
|
16
|
+
start: data.start.toISOString(),
|
|
17
|
+
initial: data.initial,
|
|
18
|
+
weight: data.weight,
|
|
19
|
+
loaded: data.loaded,
|
|
20
|
+
dispensed: data.dispensed
|
|
21
|
+
};
|
|
22
|
+
if (data.end) {
|
|
23
|
+
result.end = data.end.toISOString();
|
|
24
|
+
}
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Melting read routes factory.
|
|
30
|
+
* Creates routes for GET /machines/:machineId/meltings.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} basePath - base URL path
|
|
33
|
+
* @param {function} findMachine - function to find machine by ID
|
|
34
|
+
* @returns {array} array of route objects
|
|
35
|
+
*/
|
|
36
|
+
function readRoutes(basePath, findMachine) {
|
|
37
|
+
return [
|
|
38
|
+
route('GET', `${basePath}/machines/:machineId/meltings`, (req, res, params, query) => {
|
|
39
|
+
const result = findMachine(params.machineId);
|
|
40
|
+
if (!result) {
|
|
41
|
+
jsonResponse({ items: [], hasMore: false }).send(res);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const { machine, shop } = result;
|
|
45
|
+
const {after} = query;
|
|
46
|
+
const {before} = query;
|
|
47
|
+
const limit = query.limit ? parseInt(query.limit, 10) : 10;
|
|
48
|
+
const active = query.active === 'true';
|
|
49
|
+
const meltings = shop.meltings.query({ machine }).map(format).sort((a, b) => {
|
|
50
|
+
return new Date(b.start).getTime() - new Date(a.start).getTime();
|
|
51
|
+
});
|
|
52
|
+
const paginated = cursor(after, before, limit, meltings, active).result();
|
|
53
|
+
jsonResponse({ items: paginated.items, nextCursor: paginated.nextCursor, hasMore: paginated.hasMore }).send(res);
|
|
54
|
+
}),
|
|
55
|
+
route('GET', `${basePath}/machines/:machineId/meltings/:meltingId`, (req, res, params) => {
|
|
56
|
+
const result = findMachine(params.machineId);
|
|
57
|
+
if (!result) {
|
|
58
|
+
errorResponse('NOT_FOUND', `Melting '${params.meltingId}' not found`, 404).send(res);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const { machine, shop } = result;
|
|
62
|
+
const melting = shop.meltings.query({ machine }).find((m) => {
|
|
63
|
+
return m.id() === params.meltingId;
|
|
64
|
+
});
|
|
65
|
+
if (!melting) {
|
|
66
|
+
errorResponse('NOT_FOUND', `Melting '${params.meltingId}' not found`, 404).send(res);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
jsonResponse(format(melting)).send(res);
|
|
70
|
+
})
|
|
71
|
+
];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Melting write routes factory.
|
|
76
|
+
* Creates routes for POST/PUT /machines/:machineId/meltings.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} basePath - base URL path
|
|
79
|
+
* @param {function} findMachine - function to find machine by ID
|
|
80
|
+
* @returns {array} array of route objects
|
|
81
|
+
*/
|
|
82
|
+
function writeRoutes(basePath, findMachine) {
|
|
83
|
+
return [
|
|
84
|
+
route('POST', `${basePath}/machines/:machineId/meltings/start`, (req, res, params) => {
|
|
85
|
+
const result = findMachine(params.machineId);
|
|
86
|
+
if (!result) {
|
|
87
|
+
errorResponse('NOT_FOUND', `Machine '${params.machineId}' not found`, 404).send(res);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const { machine, shop } = result;
|
|
91
|
+
const active = shop.meltings.add(machine, {});
|
|
92
|
+
res.writeHead(201, { 'Content-Type': 'application/json' });
|
|
93
|
+
res.end(JSON.stringify(format(active)));
|
|
94
|
+
}),
|
|
95
|
+
route('POST', `${basePath}/machines/:machineId/meltings/:meltingId/stop`, (req, res, params) => {
|
|
96
|
+
const result = findMachine(params.machineId);
|
|
97
|
+
if (!result) {
|
|
98
|
+
errorResponse('NOT_FOUND', `Active melting '${params.meltingId}' not found`, 404).send(res);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const { shop } = result;
|
|
102
|
+
const melting = shop.meltings.query({ id: params.meltingId });
|
|
103
|
+
if (!melting || !melting.stop) {
|
|
104
|
+
errorResponse('NOT_FOUND', `Active melting '${params.meltingId}' not found`, 404).send(res);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const completed = melting.stop();
|
|
108
|
+
jsonResponse(format(completed)).send(res);
|
|
109
|
+
}),
|
|
110
|
+
route('POST', `${basePath}/machines/:machineId/meltings`, (req, res, params) => {
|
|
111
|
+
const result = findMachine(params.machineId);
|
|
112
|
+
if (!result) {
|
|
113
|
+
errorResponse('NOT_FOUND', `Machine '${params.machineId}' not found`, 404).send(res);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
let body = '';
|
|
117
|
+
req.on('data', (chunk) => {
|
|
118
|
+
body += chunk;
|
|
119
|
+
});
|
|
120
|
+
req.on('end', () => {
|
|
121
|
+
const data = JSON.parse(body);
|
|
122
|
+
const { machine, shop } = result;
|
|
123
|
+
const melting = shop.meltings.add(machine, data);
|
|
124
|
+
res.writeHead(201, { 'Content-Type': 'application/json' });
|
|
125
|
+
res.end(JSON.stringify(format(melting)));
|
|
126
|
+
});
|
|
127
|
+
}),
|
|
128
|
+
route('PUT', `${basePath}/machines/:machineId/meltings/:meltingId`, (req, res, params) => {
|
|
129
|
+
const result = findMachine(params.machineId);
|
|
130
|
+
if (!result) {
|
|
131
|
+
errorResponse('NOT_FOUND', `Melting '${params.meltingId}' not found`, 404).send(res);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
let body = '';
|
|
135
|
+
req.on('data', (chunk) => {
|
|
136
|
+
body += chunk;
|
|
137
|
+
});
|
|
138
|
+
req.on('end', () => {
|
|
139
|
+
const data = JSON.parse(body);
|
|
140
|
+
const { shop } = result;
|
|
141
|
+
const melting = shop.meltings.query({ id: params.meltingId });
|
|
142
|
+
if (!melting) {
|
|
143
|
+
errorResponse('NOT_FOUND', `Melting '${params.meltingId}' not found`, 404).send(res);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const updated = melting.update(data);
|
|
147
|
+
jsonResponse(format(updated)).send(res);
|
|
148
|
+
});
|
|
149
|
+
})
|
|
150
|
+
];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Melting routes factory.
|
|
155
|
+
* Creates routes for /machines/:machineId/meltings.
|
|
156
|
+
*
|
|
157
|
+
* @param {string} basePath - base URL path
|
|
158
|
+
* @param {object} plant - plant domain object from scada package
|
|
159
|
+
* @returns {array} array of route objects
|
|
160
|
+
*
|
|
161
|
+
* @example
|
|
162
|
+
* const routes = meltingRoute('/api/v1', plant);
|
|
163
|
+
*/
|
|
164
|
+
export default function meltingRoute(basePath, plant) {
|
|
165
|
+
function findMachine(id) {
|
|
166
|
+
for (const shop of Object.values(plant.shops.get())) {
|
|
167
|
+
const machine = shop.machines.get()[id];
|
|
168
|
+
if (machine) {
|
|
169
|
+
return { machine, shop };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
return [...readRoutes(basePath, findMachine), ...writeRoutes(basePath, findMachine)];
|
|
175
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import route from '../objects/route.js';
|
|
2
|
+
import sseResponse from '../objects/sseResponse.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Melting stream route factory.
|
|
6
|
+
* Creates SSE route for /machines/:machineId/meltings/stream.
|
|
7
|
+
*
|
|
8
|
+
* @param {string} basePath - base URL path
|
|
9
|
+
* @param {object} plant - plant domain object from scada package
|
|
10
|
+
* @param {function} clock - time provider
|
|
11
|
+
* @returns {array} array of route objects
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* const routes = meltingStream('/api/v1', plant, clock);
|
|
15
|
+
*/
|
|
16
|
+
export default function meltingStream(basePath, plant, clock) {
|
|
17
|
+
function findMachine(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/meltings/stream`,
|
|
30
|
+
(req, res, params) => {
|
|
31
|
+
const sse = sseResponse(res, clock);
|
|
32
|
+
sse.heartbeat();
|
|
33
|
+
const result = findMachine(params.machineId);
|
|
34
|
+
if (!result) {
|
|
35
|
+
sse.close();
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const { machine, shop } = result;
|
|
39
|
+
const subscription = shop.meltings.query({ stream: (event) => {
|
|
40
|
+
if (event.type === 'started') {
|
|
41
|
+
const m = event.melting;
|
|
42
|
+
const data = m.chronology().get();
|
|
43
|
+
sse.emit('melting_started', {
|
|
44
|
+
id: m.id(),
|
|
45
|
+
start: data.start.toISOString()
|
|
46
|
+
});
|
|
47
|
+
} else if (event.type === 'completed') {
|
|
48
|
+
const m = event.melting;
|
|
49
|
+
const data = m.chronology().get();
|
|
50
|
+
sse.emit('melting_ended', {
|
|
51
|
+
id: m.id(),
|
|
52
|
+
end: data.end.toISOString(),
|
|
53
|
+
initial: data.initial,
|
|
54
|
+
weight: data.weight,
|
|
55
|
+
loaded: data.loaded,
|
|
56
|
+
dispensed: data.dispensed
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}});
|
|
60
|
+
const heartbeat = setInterval(() => {
|
|
61
|
+
sse.heartbeat();
|
|
62
|
+
}, 30000);
|
|
63
|
+
req.on('close', () => {
|
|
64
|
+
clearInterval(heartbeat);
|
|
65
|
+
subscription.cancel();
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
];
|
|
70
|
+
}
|