@yarkivaev/scada-server 1.0.1 → 1.1.1

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.
@@ -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 payload = await response.json();
36
+ const result = await response.json();
22
37
  if (!response.ok) {
23
- throw payload;
38
+ throw result;
24
39
  }
25
- return payload;
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.1",
3
+ "version": "1.1.1",
4
4
  "description": "Composable SCADA server implementing Supervisor API",
5
5
  "repository": {
6
6
  "type": "git",
@@ -8,6 +8,10 @@
8
8
  },
9
9
  "type": "module",
10
10
  "main": "index.js",
11
+ "exports": {
12
+ ".": "./index.js",
13
+ "./client": "./client/index.js"
14
+ },
11
15
  "scripts": {
12
16
  "test": "mocha --exit 'test/unit/**/*.js'",
13
17
  "test:integration": "mocha --exit 'test/integration/**/*.js'",
@@ -19,6 +23,7 @@
19
23
  },
20
24
  "devDependencies": {
21
25
  "@eslint/js": "^9.39.2",
26
+ "@yarkivaev/scada": "^1.2.0",
22
27
  "c8": "^10.1.3",
23
28
  "eslint": "^9.39.2",
24
29
  "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
+ }