nodejs-poolcontroller 8.4.0 → 8.4.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.
Files changed (35) hide show
  1. package/.github/workflows/ghcr-publish.yml +1 -1
  2. package/157_issues.md +101 -0
  3. package/AGENTS.md +17 -1
  4. package/README.md +13 -2
  5. package/controller/Equipment.ts +49 -0
  6. package/controller/State.ts +8 -0
  7. package/controller/boards/AquaLinkBoard.ts +174 -2
  8. package/controller/boards/EasyTouchBoard.ts +44 -0
  9. package/controller/boards/IntelliCenterBoard.ts +360 -172
  10. package/controller/boards/NixieBoard.ts +7 -4
  11. package/controller/boards/SunTouchBoard.ts +1 -0
  12. package/controller/boards/SystemBoard.ts +39 -4
  13. package/controller/comms/Comms.ts +9 -3
  14. package/controller/comms/messages/Messages.ts +218 -24
  15. package/controller/comms/messages/config/EquipmentMessage.ts +34 -0
  16. package/controller/comms/messages/config/ExternalMessage.ts +1051 -989
  17. package/controller/comms/messages/config/GeneralMessage.ts +65 -0
  18. package/controller/comms/messages/config/OptionsMessage.ts +15 -2
  19. package/controller/comms/messages/config/PumpMessage.ts +427 -421
  20. package/controller/comms/messages/config/SecurityMessage.ts +37 -13
  21. package/controller/comms/messages/status/EquipmentStateMessage.ts +0 -218
  22. package/controller/comms/messages/status/HeaterStateMessage.ts +27 -15
  23. package/controller/comms/messages/status/NeptuneModbusStateMessage.ts +217 -0
  24. package/controller/comms/messages/status/VersionMessage.ts +67 -18
  25. package/controller/nixie/chemistry/ChemController.ts +65 -33
  26. package/controller/nixie/heaters/Heater.ts +10 -1
  27. package/controller/nixie/pumps/Pump.ts +145 -2
  28. package/docker-compose.yml +1 -0
  29. package/logger/Logger.ts +75 -64
  30. package/package.json +1 -1
  31. package/tsconfig.json +2 -1
  32. package/web/Server.ts +3 -1
  33. package/web/services/config/Config.ts +150 -1
  34. package/web/services/state/State.ts +21 -0
  35. package/web/services/state/StateSocket.ts +28 -0
package/logger/Logger.ts CHANGED
@@ -32,7 +32,7 @@ class Logger {
32
32
  if (!fs.existsSync(path.join(process.cwd(), '/logs'))) fs.mkdirSync(path.join(process.cwd(), '/logs'));
33
33
  this.pktPath = path.join(process.cwd(), '/logs', this.getPacketPath());
34
34
  this.captureForReplayBaseDir = path.join(process.cwd(), '/logs/', this.getLogTimestamp());
35
- /* this.captureForReplayPath = path.join(this.captureForReplayBaseDir, '/packetCapture.json'); */
35
+ this.captureForReplayPath = this.captureForReplayBaseDir;
36
36
  this.pkts = [];
37
37
  this.slMessages = [];
38
38
  }
@@ -49,6 +49,7 @@ class Logger {
49
49
  private pktTimer: NodeJS.Timeout;
50
50
  private currentTimestamp: string;
51
51
  private _captureInProgress: boolean = false;
52
+ public get captureInProgress(): boolean { return this._captureInProgress; }
52
53
  private getPacketPath(): string {
53
54
  // changed this to remove spaces from the name
54
55
  return 'packetLog(' + this.getLogTimestamp() + ').log';
@@ -279,7 +280,7 @@ class Logger {
279
280
  logger.info(`Starting Replay Capture.`);
280
281
  // start new replay directory
281
282
 
282
- if (!fs.existsSync(this.captureForReplayPath)) fs.mkdirSync(this.captureForReplayBaseDir, { recursive: true });
283
+ if (!fs.existsSync(this.captureForReplayBaseDir)) fs.mkdirSync(this.captureForReplayBaseDir, { recursive: true });
283
284
 
284
285
  // Create logs subdirectory for additional log files
285
286
  let logsSubDir = path.join(this.captureForReplayBaseDir, 'logs');
@@ -375,74 +376,84 @@ class Logger {
375
376
  this.transports.console.level = 'silly';
376
377
  }
377
378
  public async stopCaptureForReplayAsync(remLogs?: any[]):Promise<string> {
378
- return new Promise<string>(async (resolve, reject) => {
379
- try {
380
- // Get REM server configurations from config
381
- let configData = config.getSection();
382
- let remServers = [];
383
- if (configData.web && configData.web.interfaces) {
384
- for (let interfaceName in configData.web.interfaces) {
385
- let interfaceConfig = configData.web.interfaces[interfaceName];
386
- if (interfaceConfig.type === 'rem' && interfaceConfig.enabled) {
387
- remServers.push({
388
- name: interfaceConfig.name || interfaceName,
389
- uuid: interfaceConfig.uuid,
390
- host: interfaceConfig.options?.host || '',
391
- backup: true
392
- });
393
- }
379
+ try {
380
+ if (!this._captureInProgress) {
381
+ logger.warn(`stopCaptureForReplayAsync called with no active capture session; creating backup without capture logs`);
382
+ }
383
+ // Get REM server configurations from config
384
+ let configData = config.getSection();
385
+ let remServers = [];
386
+ if (configData.web && configData.web.interfaces) {
387
+ for (let interfaceName in configData.web.interfaces) {
388
+ let interfaceConfig = configData.web.interfaces[interfaceName];
389
+ if (interfaceConfig.type === 'rem' && interfaceConfig.enabled) {
390
+ remServers.push({
391
+ name: interfaceConfig.name || interfaceName,
392
+ uuid: interfaceConfig.uuid,
393
+ host: interfaceConfig.options?.host || '',
394
+ backup: true
395
+ });
394
396
  }
395
397
  }
398
+ }
396
399
 
397
- // Use the existing backup logic to create the base backup
398
- let backupOptions = {
399
- njsPC: true,
400
- servers: remServers,
401
- name: `Packet Capture ${this.currentTimestamp}`,
402
- automatic: false
403
- };
404
-
405
- let backupFile = await webApp.backupServer(backupOptions);
406
-
407
- // Add packet capture logs to the existing backup zip
408
- let jszip = require("jszip");
409
- let zip = await jszip.loadAsync(fs.readFileSync(backupFile.filePath));
410
-
411
- // Add packet capture logs to the njsPC/logs directory
412
- zip.file(`njsPC/logs/${this.getPacketPath()}`, fs.readFileSync(logger.pktPath));
413
- zip.file(`njsPC/logs/${this.getConsoleToFilePath()}`, fs.readFileSync(this.consoleToFilePath));
414
-
415
- // Add REM server logs if provided
416
- if (remLogs && remLogs.length > 0) {
417
- logger.info(`Adding ${remLogs.length} REM logs to backup`);
418
- for (let remLog of remLogs) {
419
- // Create logs directory for the REM server using the hardcoded name
420
- let logPath = `Relay Equipment Manager/logs/${remLog.logFileName}`;
421
- logger.info(`Adding REM log to backup: ${logPath} (size: ${remLog.logData.length} characters)`);
422
- zip.file(logPath, remLog.logData);
423
- }
424
- } else {
425
- logger.info(`No REM logs provided to add to backup`);
400
+ // Use the existing backup logic to create the base backup.
401
+ const ts = this.currentTimestamp || this.getLogTimestamp();
402
+ let backupOptions = {
403
+ njsPC: true,
404
+ servers: remServers,
405
+ name: `Packet Capture ${ts}`,
406
+ automatic: false
407
+ };
408
+ let backupFile = await webApp.backupServer(backupOptions);
409
+ // Add packet capture logs to the existing backup zip
410
+ let jszip = require("jszip");
411
+ let zip = await jszip.loadAsync(fs.readFileSync(backupFile.filePath));
412
+
413
+ // Add packet capture logs to the njsPC/logs directory if present.
414
+ if (typeof logger.pktPath === 'string' && logger.pktPath.length > 0 && fs.existsSync(logger.pktPath)) {
415
+ zip.file(`njsPC/logs/${path.basename(logger.pktPath)}`, fs.readFileSync(logger.pktPath));
416
+ } else {
417
+ logger.warn(`Packet capture log file unavailable during stopCaptureForReplayAsync; skipping packet log attachment`);
418
+ }
419
+ if (typeof this.consoleToFilePath === 'string' && this.consoleToFilePath.length > 0 && fs.existsSync(this.consoleToFilePath)) {
420
+ zip.file(`njsPC/logs/${path.basename(this.consoleToFilePath)}`, fs.readFileSync(this.consoleToFilePath));
421
+ } else {
422
+ logger.warn(`Console capture log file unavailable during stopCaptureForReplayAsync; skipping console log attachment`);
423
+ }
424
+
425
+ // Add REM server logs if provided.
426
+ if (remLogs && remLogs.length > 0) {
427
+ logger.info(`Adding ${remLogs.length} REM logs to backup`);
428
+ for (let remLog of remLogs) {
429
+ // Create logs directory for the REM server using the hardcoded name.
430
+ let logPath = `Relay Equipment Manager/logs/${remLog.logFileName}`;
431
+ logger.info(`Adding REM log to backup: ${logPath} (size: ${remLog.logData.length} characters)`);
432
+ zip.file(logPath, remLog.logData);
426
433
  }
427
-
428
- // Generate the updated zip
429
- await zip.generateAsync({type:'nodebuffer'}).then(content => {
430
- fs.writeFileSync(backupFile.filePath, content);
431
- });
432
-
433
- // Restore original logging configuration
434
- this.cfg = config.getSection('log');
435
- logger._logger.remove(this.transports.file);
436
- this.transports.console.level = this.cfg.app.level;
437
- this._captureInProgress = false;
438
-
439
- resolve(backupFile.filePath);
434
+ } else {
435
+ logger.info(`No REM logs provided to add to backup`);
440
436
  }
441
- catch (err) {
442
- this._captureInProgress = false;
443
- reject(err.message);
437
+
438
+ // Generate the updated zip.
439
+ await zip.generateAsync({ type: 'nodebuffer' }).then(content => {
440
+ fs.writeFileSync(backupFile.filePath, content);
441
+ });
442
+ // Restore original logging configuration.
443
+ this.cfg = config.getSection('log');
444
+ if (typeof this.transports.file !== 'undefined') {
445
+ logger._logger.remove(this.transports.file);
446
+ this.transports.file.close();
447
+ this.transports.file = undefined;
444
448
  }
445
- });
449
+ this.transports.console.level = this.cfg.app.level;
450
+ this._captureInProgress = false;
451
+ return backupFile.filePath;
452
+ }
453
+ catch (err) {
454
+ this._captureInProgress = false;
455
+ return Promise.reject(err instanceof Error ? err.message : `${err}`);
456
+ }
446
457
  }
447
458
  }
448
459
  export var logger = new Logger();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodejs-poolcontroller",
3
- "version": "8.4.0",
3
+ "version": "8.4.1",
4
4
  "description": "nodejs-poolController",
5
5
  "main": "app.js",
6
6
  "author": {
package/tsconfig.json CHANGED
@@ -13,7 +13,8 @@
13
13
  "allowJs": false,
14
14
  "allowSyntheticDefaultImports": false,
15
15
  "esModuleInterop": false,
16
- "jsx": "react"
16
+ "jsx": "react",
17
+ "skipLibCheck": true
17
18
  },
18
19
  "include": [
19
20
  "web/**/*",
package/web/Server.ts CHANGED
@@ -645,12 +645,14 @@ export class HttpServer extends ProtoServer {
645
645
  private replayInboundMessage(mdata: any) {
646
646
  try {
647
647
  let msg: Inbound = new Inbound();
648
- msg.direction = mdata.direction;
648
+ if (typeof mdata.direction !== 'undefined') msg.direction = mdata.direction;
649
649
  msg.header = mdata.header;
650
650
  msg.payload = mdata.payload;
651
651
  msg.preamble = mdata.preamble;
652
652
  msg.protocol = mdata.protocol;
653
653
  msg.term = mdata.term;
654
+ if (typeof mdata.portId === 'number') msg.portId = mdata.portId;
655
+ msg.scope = 'replay';
654
656
  if (msg.isValid) msg.process();
655
657
  }
656
658
  catch (err) {
@@ -34,7 +34,99 @@ import { ScreenLogicComms, sl } from "../../../controller/comms/ScreenLogic";
34
34
  import { screenlogic } from "node-screenlogic";
35
35
 
36
36
  export class ConfigRoute {
37
+ private static securitySessions: Map<string, any> = new Map<string, any>();
38
+ private static isOcpWriteSecurityEnforced(): boolean {
39
+ // Intentionally hard-disabled until OCP security semantics are fully understood.
40
+ // This avoids accidentally locking users out of configuration writes.
41
+ return false;
42
+ }
43
+ private static getClientKey(req: express.Request): string {
44
+ const forwarded = (req.headers['x-forwarded-for'] || '') as string;
45
+ const forwardedIp = forwarded.split(',')[0].trim();
46
+ const connIp = ((req.connection as any) || {}).remoteAddress || '';
47
+ return forwardedIp || req.ip || connIp || 'unknown';
48
+ }
49
+ private static getSecuritySession(req: express.Request): any {
50
+ const key = ConfigRoute.getClientKey(req);
51
+ return ConfigRoute.securitySessions.get(key);
52
+ }
53
+ private static clearSecuritySession(req: express.Request): any {
54
+ const key = ConfigRoute.getClientKey(req);
55
+ ConfigRoute.securitySessions.delete(key);
56
+ return {
57
+ isAuthenticated: false,
58
+ isAdmin: false,
59
+ roleId: 0,
60
+ roleName: '',
61
+ ip: key
62
+ };
63
+ }
64
+ private static createSecuritySession(req: express.Request, role: any): any {
65
+ const key = ConfigRoute.getClientKey(req);
66
+ const session = {
67
+ isAuthenticated: true,
68
+ isAdmin: role.id === 1 || typeof role.name === 'string' && role.name.toLowerCase().indexOf('admin') >= 0,
69
+ roleId: role.id || 0,
70
+ roleName: role.name || '',
71
+ ip: key,
72
+ updatedAt: new Date().toISOString()
73
+ };
74
+ ConfigRoute.securitySessions.set(key, session);
75
+ return session;
76
+ }
77
+ private static getRoleForPin(pin: string): any {
78
+ const normalizedPin = (pin || '').toString().replace(/\D/g, '');
79
+ if (normalizedPin.length === 0) return undefined;
80
+ const roles = sys.security.roles.toArray();
81
+ for (let i = 0; i < roles.length; i++) {
82
+ const rolePin = ((roles[i] as any).pin || '').toString().replace(/\D/g, '');
83
+ if (rolePin.length > 0 && rolePin === normalizedPin) return roles[i];
84
+ }
85
+ return undefined;
86
+ }
87
+ private static validateWriteAccess(req: express.Request): { allowed: boolean; reason?: string; session?: any } {
88
+ if (!ConfigRoute.isOcpWriteSecurityEnforced()) return { allowed: true };
89
+ if (!sys.security.enabled) return { allowed: true };
90
+ const session = ConfigRoute.getSecuritySession(req);
91
+ if (typeof session === 'undefined' || !session.isAuthenticated) {
92
+ return { allowed: false, reason: 'Security is enabled; log in with an administrator PIN to change configuration.' };
93
+ }
94
+ if (!session.isAdmin) return { allowed: false, reason: 'Guest sessions are read-only.' };
95
+ return { allowed: true, session: session };
96
+ }
97
+ private static getSessionResponse(req: express.Request): any {
98
+ const session = ConfigRoute.getSecuritySession(req);
99
+ return {
100
+ enabled: sys.security.enabled,
101
+ session: typeof session === 'undefined' ? {
102
+ isAuthenticated: false,
103
+ isAdmin: false,
104
+ roleId: 0,
105
+ roleName: '',
106
+ ip: ConfigRoute.getClientKey(req)
107
+ } : session
108
+ };
109
+ }
37
110
  public static initRoutes(app: express.Application) {
111
+ app.use('/config', (req, res, next) => {
112
+ const method = (req.method || '').toUpperCase();
113
+ if (method !== 'PUT' && method !== 'POST' && method !== 'DELETE') return next();
114
+ const reqPath = req.path || req.url || '';
115
+ if (
116
+ reqPath.startsWith('/security/login') ||
117
+ reqPath.startsWith('/security/logout') ||
118
+ reqPath.startsWith('/security/session')
119
+ ) {
120
+ return next();
121
+ }
122
+ const access = ConfigRoute.validateWriteAccess(req);
123
+ if (access.allowed) return next();
124
+ return res.status(403).send({
125
+ error: 'FORBIDDEN',
126
+ message: access.reason,
127
+ security: ConfigRoute.getSessionResponse(req)
128
+ });
129
+ });
38
130
  app.get('/config/body/:body/heatModes', (req, res) => {
39
131
  return res.status(200).send(sys.bodies.getItemById(parseInt(req.params.body, 10)).getHeatModes());
40
132
  });
@@ -65,6 +157,28 @@ export class ConfigRoute {
65
157
  };
66
158
  return res.status(200).send(opts);
67
159
  });
160
+ app.get('/config/options/security', (req, res) => {
161
+ return res.status(200).send({
162
+ security: sys.security.get(true),
163
+ session: ConfigRoute.getSessionResponse(req).session
164
+ });
165
+ });
166
+ app.get('/config/options/alerts', (req, res) => {
167
+ return res.status(200).send({
168
+ alerts: sys.alerts.get(true),
169
+ // Existing app-side alert-related options still live in general options.
170
+ poolOptions: {
171
+ cooldownDelay: sys.general.options.cooldownDelay,
172
+ heaterStartDelay: sys.general.options.heaterStartDelay,
173
+ valveDelayTime: sys.general.options.valveDelayTime,
174
+ manualPriority: sys.general.options.manualPriority
175
+ },
176
+ runtime: {
177
+ chemControllers: state.chemControllers.getExtended(),
178
+ chemDosers: state.chemDosers.getExtended()
179
+ }
180
+ });
181
+ });
68
182
  app.get('/config/options/rs485', async (req, res, next) => {
69
183
  try {
70
184
  let opts = { ports: [], local: [], screenlogic: {} }
@@ -245,6 +359,7 @@ export class ConfigRoute {
245
359
  maxHeaters: sys.equipment.maxHeaters,
246
360
  heaters: sys.heaters.get(),
247
361
  heaterTypes: sys.board.valueMaps.heaterTypes.toArray(),
362
+ equipmentMasters: sys.board.valueMaps.equipmentMaster.toArray(),
248
363
  // Align with `/config/body/:id/heatModes` (body picklist). This ensures any board-specific
249
364
  // filtering (e.g. IntelliCenter v3 preferred-mode suppression) is reflected consistently.
250
365
  // Future improvement should return valid modes per body.
@@ -307,6 +422,7 @@ export class ConfigRoute {
307
422
  // waterFlow: sys.board.valueMaps.chemControllerWaterFlow.toArray(), // remove
308
423
  controllers: sys.chemControllers.get(),
309
424
  maxChemControllers: sys.equipment.maxChemControllers,
425
+ intellichemStandaloneSupported: sys.controllerType === ControllerType.Nixie,
310
426
  doserTypes: sys.board.valueMaps.chemDoserTypes.toArray(),
311
427
  chlorinators: sys.chlorinators.get(),
312
428
  };
@@ -860,6 +976,35 @@ export class ConfigRoute {
860
976
  sys.board.circuits.setIntelliBriteColors(new LightGroup(grp));
861
977
  return res.status(200).send('OK');
862
978
  }); */
979
+ app.get('/config/security/session', (req, res) => {
980
+ return res.status(200).send(ConfigRoute.getSessionResponse(req));
981
+ });
982
+ app.put('/config/security/login', (req, res) => {
983
+ if (!sys.security.enabled) {
984
+ return res.status(409).send({
985
+ error: 'SECURITY_DISABLED',
986
+ message: 'Panel security is disabled.',
987
+ security: ConfigRoute.getSessionResponse(req)
988
+ });
989
+ }
990
+ const role = ConfigRoute.getRoleForPin(((req.body || {}).pin || '').toString());
991
+ if (typeof role === 'undefined') {
992
+ return res.status(401).send({
993
+ error: 'INVALID_PIN',
994
+ message: 'PIN does not match a configured security role.'
995
+ });
996
+ }
997
+ return res.status(200).send({
998
+ enabled: sys.security.enabled,
999
+ session: ConfigRoute.createSecuritySession(req, role)
1000
+ });
1001
+ });
1002
+ app.put('/config/security/logout', (req, res) => {
1003
+ return res.status(200).send({
1004
+ enabled: sys.security.enabled,
1005
+ session: ConfigRoute.clearSecuritySession(req)
1006
+ });
1007
+ });
863
1008
  app.get('/config', (req, res) => {
864
1009
  return res.status(200).send(sys.getSection('all'));
865
1010
  });
@@ -927,7 +1072,11 @@ export class ConfigRoute {
927
1072
  app.get('/app/config/stopPacketCapture', async (req, res, next) => {
928
1073
  try {
929
1074
  let file = await stopPacketCaptureAsync();
930
- res.download(file);
1075
+ if (typeof file !== 'string' || file.length === 0 || !fs.existsSync(file)) {
1076
+ logger.warn(`stopPacketCapture did not produce a valid backup file path`);
1077
+ return res.status(409).send('Packet capture is not active or no capture file is available.');
1078
+ }
1079
+ return res.download(file);
931
1080
  }
932
1081
  catch (err) { next(err); }
933
1082
  });
@@ -399,6 +399,27 @@ export class StateRoute {
399
399
  }
400
400
  catch (err) { next(err); }
401
401
  });
402
+ app.put('/state/light/setBrightness', async (req, res, next) => {
403
+ try {
404
+ let cstate = await sys.board.circuits.setDimmerLevelAsync(
405
+ parseInt(req.body.id, 10),
406
+ parseInt(typeof req.body.level !== 'undefined' ? req.body.level : req.body.brightness, 10)
407
+ );
408
+ return res.status(200).send(cstate.get(true));
409
+ }
410
+ catch (err) { next(err); }
411
+ });
412
+ app.put('/state/light/setColor', async (req, res, next) => {
413
+ try {
414
+ let cstate = await sys.board.circuits.setLightColorAsync(parseInt(req.body.id, 10), {
415
+ red: parseInt(typeof req.body.red !== 'undefined' ? req.body.red : req.body.r, 10),
416
+ green: parseInt(typeof req.body.green !== 'undefined' ? req.body.green : req.body.g, 10),
417
+ blue: parseInt(typeof req.body.blue !== 'undefined' ? req.body.blue : req.body.b, 10)
418
+ });
419
+ return res.status(200).send(cstate.get(true));
420
+ }
421
+ catch (err) { next(err); }
422
+ });
402
423
  app.put('/state/feature/setState', async (req, res, next) => {
403
424
  try {
404
425
  let isOn = utils.makeBool(typeof req.body.isOn !== 'undefined' ? req.body.isOn : req.body.state);
@@ -300,6 +300,13 @@ export class StateSocket {
300
300
  }
301
301
  catch (err) { next(err); }
302
302
  });
303
+ app.put('/state/light/setTheme', async (req, res, next) => {
304
+ try {
305
+ let theme = await state.circuits.setLightThemeAsync(parseInt(req.body.id, 10), parseInt(req.body.theme, 10));
306
+ return res.status(200).send(theme);
307
+ }
308
+ catch (err) { next(err); }
309
+ });
303
310
 
304
311
  app.put('/state/circuit/setDimmerLevel', async (req, res, next) => {
305
312
  try {
@@ -308,6 +315,27 @@ export class StateSocket {
308
315
  }
309
316
  catch (err) { next(err); }
310
317
  });
318
+ app.put('/state/light/setBrightness', async (req, res, next) => {
319
+ try {
320
+ let circuit = await sys.board.circuits.setDimmerLevelAsync(
321
+ parseInt(req.body.id, 10),
322
+ parseInt(typeof req.body.level !== 'undefined' ? req.body.level : req.body.brightness, 10)
323
+ );
324
+ return res.status(200).send(circuit);
325
+ }
326
+ catch (err) { next(err); }
327
+ });
328
+ app.put('/state/light/setColor', async (req, res, next) => {
329
+ try {
330
+ let circuit = await sys.board.circuits.setLightColorAsync(parseInt(req.body.id, 10), {
331
+ red: parseInt(typeof req.body.red !== 'undefined' ? req.body.red : req.body.r, 10),
332
+ green: parseInt(typeof req.body.green !== 'undefined' ? req.body.green : req.body.g, 10),
333
+ blue: parseInt(typeof req.body.blue !== 'undefined' ? req.body.blue : req.body.b, 10)
334
+ });
335
+ return res.status(200).send(circuit);
336
+ }
337
+ catch (err) { next(err); }
338
+ });
311
339
  app.put('/state/feature/setState', async (req, res, next) => {
312
340
  try {
313
341
  await state.features.setFeatureStateAsync(req.body.id, req.body.state);