Compare commits

..

196 Commits

Author SHA1 Message Date
Mikayla
18e4e309a7 Update README.md 2023-03-05 12:47:21 -05:00
Mikayla
55362d4e66 Merge pull request #189 from MikaylaFischler/devel
Beta Release
2023-03-05 12:36:11 -05:00
Mikayla Fischler
8b1e7cb933 added energy bar to turbine overview 2023-03-05 12:35:36 -05:00
Mikayla Fischler
66deabcf5d HIGH CHARGE on induction matrix is now yellow not red 2023-03-05 11:52:03 -05:00
Mikayla Fischler
2a681d1d37 disable debug prints and update ccmsi version for release 2023-03-04 23:01:24 -05:00
Mikayla Fischler
9eddab2c23 #188 refactored RPS dmg_high to high_dmg 2023-03-04 22:32:13 -05:00
Mikayla Fischler
83dc1064f7 #188 refactored RPS no_cool to low_cool 2023-03-04 22:19:53 -05:00
Mikayla Fischler
c9f1bddb36 #188 refactored RPS dmg_crit to dmg_high 2023-03-04 21:55:40 -05:00
Mikayla Fischler
85a9532962 #186 fixed incorrect constant usage, add RCS flow low to flow stability holdoff when not using a boiler 2023-03-04 21:35:54 -05:00
Mikayla Fischler
edb5d8b96f #186 different steam feed mismatch and RCS flow low tolerances for water vs sodium cooling 2023-03-04 21:19:35 -05:00
Mikayla Fischler
0279ecdec9 #186 second attempt at improving damage status text 2023-03-04 19:49:56 -05:00
Mikayla Fischler
9a500d53d8 #187 installer bugfix 2023-03-04 14:40:49 -05:00
Mikayla Fischler
5b7a11d157 #187 installer bugfix 2023-03-04 14:38:42 -05:00
Mikayla Fischler
57ccb73efe #187 installer bugfix 2023-03-04 14:21:42 -05:00
Mikayla Fischler
d494abe8af #187 improved installer version check 2023-03-04 14:19:17 -05:00
Mikayla Fischler
2e9f52dc89 #187 added installer version to manifest 2023-03-04 14:13:47 -05:00
Mikayla Fischler
8c236eca85 #186 fixed bug with facility update returning, improved damage status message 2023-03-04 13:38:41 -05:00
Mikayla Fischler
be8e8d767c #186 fixed ccmsi insufficient space update overwriting config 2023-03-04 12:49:05 -05:00
Mikayla Fischler
94fb02a46b #186 fixed ccmsi install/update insufficient space confirm 2023-03-04 12:44:37 -05:00
Mikayla Fischler
3586d335a6 #186 don't includes assigned monitors in list of monitors to assign 2023-03-04 12:27:38 -05:00
Mikayla Fischler
f7828dd05b #186 F_ALARM use emergency+ level 2023-03-04 11:46:59 -05:00
Mikayla Fischler
d01a6d548f #186 fixed radiation warning condition 2023-03-04 11:40:06 -05:00
Mikayla Fischler
b12f3206e2 #186 additional messages for radiation alarm/warning with added urgency/level-specific messages 2023-03-04 02:05:36 -05:00
Mikayla Fischler
0e5113918c #186 improved sv config validation, changed waste high thresholds, fixed monitored max burn not showing as active, fixed redstone R_ENABLE and U_ALARM, changed RPS high waste trip to 95% 2023-03-04 01:37:15 -05:00
Mikayla Fischler
11115633cf #186 fixed manifest size in install_manifest.json, fixed unit display not connected prompt, added message about bad cooling config 2023-03-02 22:29:50 -05:00
Mikayla Fischler
58cf383c91 #185 disable auto mode changing if auto mode is active regardless of assignment 2023-03-01 22:37:28 -05:00
Mikayla Fischler
3f15ae6b6f #179 remove recolor option from coordinator config 2023-02-27 23:59:46 -05:00
Mikayla Fischler
0d7fde635d updated readme 2023-02-27 23:52:18 -05:00
Mikayla Fischler
ae3315e4a0 #180 include manifest size in sizes 2023-02-27 23:51:26 -05:00
Mikayla Fischler
523d478739 changed trip time warning to 750ms 2023-02-26 14:49:16 -05:00
Mikayla Fischler
2b8f71fc43 status message cleanup and some updated comments 2023-02-26 14:22:25 -05:00
Mikayla Fischler
b150072234 #177 correctly set Water Level Low 2023-02-26 14:17:35 -05:00
Mikayla Fischler
fbb992ff12 #173 dump excess steam on opening emergency coolant 2023-02-25 14:11:40 -05:00
Mikayla Fischler
523ac91c3b fixed coordinator RCS annunciator dimensions 2023-02-25 13:25:23 -05:00
Mikayla Fischler
bd1625c42e #166 removed sounder test code from GUI 2023-02-25 12:51:37 -05:00
Mikayla Fischler
7508acb1a7 #174 fixed sounder not resuming on supervisor reconnect with same alarm states 2023-02-25 12:20:03 -05:00
Mikayla
6eee0d0c72 Merge pull request #175 from MikaylaFischler/118-code-cleanup-pass
#118 Code Cleanup
2023-02-25 12:08:06 -05:00
Mikayla Fischler
446fff04da #118 PLC RPS fuel check fixed 2023-02-25 12:07:25 -05:00
Mikayla Fischler
4f285cf2b5 #118 safety/constants common file 2023-02-25 02:25:35 -05:00
Mikayla Fischler
16d6372d7b #118 bugfixes with cleanup 2023-02-24 23:59:39 -05:00
Mikayla Fischler
b7895080cb #118 supervisor cleanup 2023-02-24 23:36:16 -05:00
Mikayla Fischler
38ac552613 #118 graphics cleanup 2023-02-24 19:50:01 -05:00
Mikayla Fischler
225ed7baa1 #118 removed some coordinator nodiscard tags 2023-02-22 23:20:59 -05:00
Mikayla Fischler
4340518ecf #118 coordinator code cleanup 2023-02-22 23:09:47 -05:00
Mikayla Fischler
79494f0587 #118 RTU/PLC code cleanup 2023-02-21 23:50:43 -05:00
Mikayla Fischler
ce0198f389 #118 PLC code cleanup 2023-02-21 16:57:33 -05:00
Mikayla Fischler
82ea35168b #118 type cleanup 2023-02-21 12:40:34 -05:00
Mikayla Fischler
424097973d #118 refactored RTU unit types 2023-02-21 12:27:16 -05:00
Mikayla Fischler
7247d8a828 #118 refactored fluid 2023-02-21 11:32:56 -05:00
Mikayla Fischler
a07086907e #118 refactored DUMPING_MODE 2023-02-21 11:30:49 -05:00
Mikayla Fischler
7c64a66dd3 #118 refactored rps_status_t 2023-02-21 11:29:04 -05:00
Mikayla Fischler
6e0dde3f30 #118 refactoring of comms types 2023-02-21 11:05:57 -05:00
Mikayla Fischler
34cac6a8b8 #118 cleanup started of scada-common 2023-02-21 10:31:05 -05:00
Mikayla Fischler
e2d2a0f1dc #172 fixed bug with full builds not being sent 2023-02-20 14:50:20 -05:00
Mikayla Fischler
8df67245c5 #171 unit auto SCRAM and improvements to emergency coolant control 2023-02-20 12:08:51 -05:00
Mikayla Fischler
1be57aaf13 #140 partial build packet updates 2023-02-20 00:49:37 -05:00
Mikayla Fischler
c4f6c1b289 #159 fixed RTU facility level redstone linking 2023-02-19 22:41:32 -05:00
Mikayla Fischler
c9526ba601 #117 installer v0.9e fixed missing newlines on reinstalling message 2023-02-19 21:55:32 -05:00
Mikayla Fischler
00263b2feb #117 installer v0.9d prevent updating when installation isn't present 2023-02-19 21:52:43 -05:00
Mikayla Fischler
d74a2db8e9 #117 installer v0.9c fixes to check list 2023-02-19 20:45:48 -05:00
Mikayla Fischler
632e96c8b3 #117 installer v0.9b cleanup and improvements to check list 2023-02-19 20:43:39 -05:00
Mikayla Fischler
279a40e335 #117 installer v0.9a added support for different targets 2023-02-19 20:17:03 -05:00
Mikayla Fischler
e6632c3bd9 #117 installer v0.8b fixed to folder deletion, check command, and preserving comms version in manifest 2023-02-19 19:56:12 -05:00
Mikayla Fischler
950ad2931f #117 installer v0.8a fixes to deletion of directories and check command 2023-02-19 19:41:32 -05:00
Mikayla Fischler
bc38a9ea27 #117 installer v0.8 fixed purge, added check 2023-02-19 19:30:03 -05:00
Mikayla Fischler
960c016f4c #117 installer v0.7 fixed bug with checking local manifest 2023-02-19 19:18:06 -05:00
Mikayla Fischler
fa6524d934 #117 installer v0.6 saving manifest after operation, checking install state before proceeding 2023-02-19 19:14:47 -05:00
Mikayla Fischler
726d15b48f #117 installer v0.5 fixed colors and move not working 2023-02-19 18:52:21 -05:00
Mikayla Fischler
df57e1859e #117 installer v0.4 with package version checking for skips, fixes to file overwriting 2023-02-19 18:49:04 -05:00
Mikayla Fischler
0493f572a2 #117 installer v0.3 with colors and fixes 2023-02-19 17:15:26 -05:00
Mikayla Fischler
1dea5b1b7a #117 removed util dependency from installer, whoops 2023-02-19 12:56:53 -05:00
Mikayla Fischler
72eb2432cc #117 installation files, first pass 2023-02-19 12:54:02 -05:00
Mikayla Fischler
052b2f3848 improved RCS flow low detection 2023-02-19 12:37:07 -05:00
Mikayla Fischler
35dfd61df1 #169 startup rate high; also changed how clearing ASCRAM status and updating indicators works 2023-02-19 12:20:16 -05:00
Mikayla Fischler
cc5ea0dbb0 #159 linked up redstone I/O 2023-02-19 00:14:27 -05:00
Mikayla Fischler
caa6cc81b1 #163 changed formed/faulted display priority on coordinator for RTUs 2023-02-18 17:37:28 -05:00
Mikayla Fischler
9f95801bfc moved supervisor unit/facility files out of sessions folder 2023-02-18 17:36:44 -05:00
Mikayla Fischler
c18e7ef4d0 display turbine generation as rate instead of charge 2023-02-16 20:48:40 -05:00
Mikayla Fischler
5e65ca636e #164 reporting comms version mismatches 2023-02-15 19:59:58 -05:00
Mikayla Fischler
2babd67198 #162 #168 status indicator for emergency coolant, display number of connected RTUs, added RCS hardware fault and radiation warning indicators 2023-02-15 19:52:28 -05:00
Mikayla Fischler
199ce53f52 #160 #161 linked up ASCRAM lights and added ASCRAM radiation condition 2023-02-14 22:55:40 -05:00
Mikayla Fischler
8ebdf2686b #118 created constructors for basic types 2023-02-14 15:15:34 -05:00
Mikayla Fischler
9d5a55bf58 fixed the commit just now that broke status data to coordinator 2023-02-13 22:14:47 -05:00
Mikayla Fischler
655213e174 updated license 2023-02-13 22:11:45 -05:00
Mikayla Fischler
1fe2acb5c5 #144 added radiation monitor integration; displays, unit alarms, connection states, other bugfixes 2023-02-13 22:11:31 -05:00
Mikayla Fischler
ef27da8daf fixed incorrect text for boiler status on coordinator 2023-02-13 18:53:24 -05:00
Mikayla Fischler
5751c320b1 only report not formed if its a multiblock 2023-02-13 18:53:00 -05:00
Mikayla Fischler
2affe1b31c #139 emergency coolant enabled on RPS low coolant 2023-02-13 18:20:48 -05:00
Mikayla Fischler
ccd9f4b6cc #158 fixed race conditions and cleaned up ascram logic 2023-02-13 18:08:32 -05:00
Mikayla Fischler
fdf75350c0 #146 increased minimum timeout 2023-02-13 12:29:59 -05:00
Mikayla Fischler
9784b4e165 #146 increased timeout times and added to config files 2023-02-13 12:27:22 -05:00
Mikayla Fischler
4d40d08a7a #157 fixed bug with RTU remount messages 2023-02-12 13:06:44 -05:00
Mikayla Fischler
42ff61a8a1 #155 gen rate mode pausing on units no longer being ready 2023-02-11 14:27:29 -05:00
Mikayla Fischler
ff1bd02739 #20 process target charge level 2023-02-11 00:21:00 -05:00
Mikayla Fischler
da9eead2d5 #19 #156 gain changes for generation rate control, fixed plc ready checks 2023-02-10 20:26:25 -05:00
Mikayla Fischler
44d5cec1f8 #19 decent rate PID gains, fixed blade counting and added checks, bugfix with PLC reconnects not being in auto mode, logging cleanups 2023-02-09 22:52:10 -05:00
Mikayla Fischler
37f7319494 #154 increased auto burn rate precision 2023-02-08 20:26:13 -05:00
Mikayla Fischler
ee739c214d #19 gen rate target process control working, some tweaks will be needed as I term is unstable due to limiting decimal precision 2023-02-07 23:47:58 -05:00
Mikayla Fischler
07ee792163 #153 facility alarm acknowledge button 2023-02-07 18:44:34 -05:00
Mikayla Fischler
678dafa62f #152 supervisor cleanups and improvements to alarms 2023-02-07 17:51:55 -05:00
Mikayla Fischler
6c09772a74 #76 added trusted connection ranges for modem messages 2023-02-07 17:31:22 -05:00
Mikayla Fischler
1d3a1672c8 #102 #21 auto control loop with induction matrix and unit alarm checks and handling 2023-02-07 00:32:50 -05:00
Mikayla Fischler
1100051585 #151 improved RCS alarm behavior 2023-02-05 13:04:42 -05:00
Mikayla Fischler
c77993d3a0 bottom align process control panel and induction matrix view 2023-02-05 12:15:41 -05:00
Mikayla Fischler
3e74d6c998 #101 initial coordinator control interface completed 2023-02-05 02:07:54 -05:00
Mikayla Fischler
b5c70b0d37 fixed process controller assuming ramp complete if burn rate setpoint was identical to setpoint before process control start 2023-02-04 13:47:00 -05:00
Mikayla Fischler
ba8bfb6e14 #101 fixed averages and display them 2023-02-03 21:05:21 -05:00
Mikayla Fischler
a117d5ee97 #150 save and automatically set priority groups, added checks to set waste and set group commands, restore waste mode control if operation failed 2023-02-03 16:40:58 -05:00
Mikayla Fischler
72791d042b #149 validate display sizes on startup 2023-02-03 15:19:00 -05:00
Mikayla Fischler
53e4576547 some coordinator code cleanup and refactoring 2023-02-02 23:07:09 -05:00
Mikayla Fischler
2e78aa895d #101 #102 burn rate process mode functional 2023-02-02 22:58:51 -05:00
Mikayla Fischler
eb8aab175f #148 okay turns out that variable was important, ramping now works as intended, correctly 2023-02-02 22:51:21 -05:00
Mikayla Fischler
5721231ffd #148 fixed burn rate ramping again for real this time 2023-02-02 22:04:26 -05:00
Mikayla Fischler
846f9685ad #148 fixed burn rate ramping, adjusted auto burn rate ramping 2023-02-02 20:17:23 -05:00
Mikayla Fischler
fe71615c12 #101 #102 work on bugfixes; disable unit controls while in auto mode 2023-02-01 21:55:02 -05:00
Mikayla Fischler
e9562a140c #143 #103 #101 #102 work in progress auto control, added coordinator controls, save/auto load configuration, auto enable/disable on reactor PLC for auto control (untested) 2023-01-26 18:26:26 -05:00
Mikayla Fischler
e808ee2be0 #137 save/recall waste configuration with config file 2023-01-23 20:47:45 -05:00
Mikayla Fischler
8abac3fdcb refactoring and adjusted spinbox and hazard button elements 2023-01-23 15:10:41 -05:00
Mikayla Fischler
4145949ba7 #141 setting unit limits with coordinator 2023-01-15 13:11:46 -05:00
Mikayla Fischler
b7d4bc3a5b #142 fixed bug with setting burn rates 2023-01-13 14:03:47 -05:00
Mikayla Fischler
a1c1125d54 fixed bug with automatic limit update 2023-01-03 17:03:20 -05:00
Mikayla Fischler
41838ee340 #102 #20 #19 #21 work in progress on auto control, added control loop, started auto scram checks, implemented limiting and balancing, re-organized for priority groups 2023-01-03 16:50:31 -05:00
Mikayla Fischler
6fe257d1d7 #138 fixed bug with dmesg output resetting to default if log file is recycled 2022-12-18 14:11:25 -05:00
Mikayla Fischler
ca2983506e #24 coordinator/supervisor setting process groups and unit burn rate limits 2022-12-18 13:56:04 -05:00
Mikayla Fischler
93a0dedcb1 #24 GUI for unit displays to set unit group 2022-12-13 15:18:29 -05:00
Mikayla Fischler
a591cab338 color reactor coolant bars based on coolant type 2022-12-11 10:51:45 -05:00
Mikayla Fischler
a633f5b4c3 #132 expanded unit displays to use 4x4 monitors 2022-12-10 23:56:07 -05:00
Mikayla Fischler
6517f78c1c #129 induction matrix view 2022-12-10 15:44:11 -05:00
Mikayla Fischler
03f0216d51 #130 facility data object, some code cleanup, comms protocol changed from 1.0.1 to 1.1.0 2022-12-10 13:58:17 -05:00
Mikayla Fischler
41913441d5 RTU support for non reactor specific devices 2022-12-07 23:17:11 -05:00
Mikayla Fischler
2a99d1d385 #136 send rps trip cause with status, moved rps is_tripped to rps status from main status, increased plc status send rate to 2 Hz 2022-12-07 12:59:21 -05:00
Mikayla Fischler
52603e3579 #131 first pass of unit status text 2022-12-06 23:39:35 -05:00
Mikayla Fischler
c23ddaf5ea #135 added clock and supervisor trip time to coordinator main view 2022-12-06 11:40:13 -05:00
Mikayla Fischler
6bdde02268 #131 start of unit status text, added updating coordinator waste processing option on reconnect 2022-12-05 16:17:09 -05:00
Mikayla Fischler
5224dcbd25 reconnect alarm sounder speaker on peripheral reconnect 2022-12-04 14:36:29 -05:00
Mikayla Fischler
9475700930 added sounder volume to config 2022-12-04 14:29:39 -05:00
Mikayla Fischler
4030fdc5c9 #77 alarm sounder 2022-12-04 13:59:10 -05:00
Mikayla Fischler
518ee8272a updated modbustest 2022-11-30 23:32:29 -05:00
Mikayla Fischler
e1d7c7b1c0 #134 #104 redstone RTU integration with supervisor unit, waste routing implemented, changed how redstone I/O works (again, should be good now), modbus fixes 2022-11-30 23:31:14 -05:00
Mikayla Fischler
9c27ac7ae6 bugfix with reset/ack button mappings on coordinator GUI 2022-11-27 22:53:44 -05:00
Mikayla Fischler
afb3b0957e bugfix for RTU re-formed detection 2022-11-27 22:44:47 -05:00
Mikayla Fischler
d4ae18eee7 #10 #133 alarm system logic and display, change to comms to support alarm actions, get_x get_y to graphics elements, bugfixes to coord establish and rtu establish, flashing trilight and alarm light indicators 2022-11-26 16:18:31 -05:00
Mikayla Fischler
f68c38ccee cleanup of requires 2022-11-24 22:49:35 -05:00
Mikayla Fischler
5628df56a2 removed hardcoded push button padding 2022-11-24 14:20:11 -05:00
Mikayla Fischler
3685e25713 likely finalized color palette, removed color map from unit displays 2022-11-21 21:32:45 -05:00
Mikayla Fischler
657cd15c59 #127 uncommitted changes for annunciator changes 2022-11-17 12:04:30 -05:00
Mikayla Fischler
29793ba7c4 #128 element changes and show number after setting min/max for spinbox 2022-11-17 12:00:00 -05:00
Mikayla Fischler
9c32074b56 #128 limit max burn rate control to actual max burn rate 2022-11-17 11:58:14 -05:00
Mikayla Fischler
c93a386e74 #127 adjusted annunciator rate/feed checks 2022-11-17 11:20:53 -05:00
Mikayla Fischler
6fcd18e17a #125 moved environmental loss on boilers from build to state category 2022-11-14 21:50:32 -05:00
Mikayla Fischler
7c39e8c72b #126 fixed RTU builds not being sent to coordinator at the correct times 2022-11-14 21:43:02 -05:00
Mikayla Fischler
9761228b8e #124 debug stack trace on error 2022-11-13 15:56:27 -05:00
Mikayla Fischler
e679b5a25a #122 versioned comms protocol with unified establish protocol 2022-11-13 14:13:30 -05:00
Mikayla Fischler
1a01bec7e4 #123 RTU startup without devices, fixed repeat RTU advert handling, added PPM virtual devices, fixed log out of space detection, updated RTU type conversion functions in comms 2022-11-12 01:35:31 -05:00
Mikayla Fischler
f940c136bf fixes to rtu modbus 2022-11-11 23:49:45 -05:00
Mikayla Fischler
8e28dbf2a6 #120 fixed steam dump indicator, fixed index tags 2022-11-11 16:59:28 -05:00
Mikayla Fischler
8b65bf4852 fixed rps alarm packet length check 2022-11-11 16:46:38 -05:00
Mikayla Fischler
ffeff86507 adjusted containment integrity to just be damage percent, moved up radiation indicator 2022-11-11 16:32:14 -05:00
Mikayla Fischler
af57c3b1fc automatic reactor scram functionality for future use 2022-11-11 16:15:44 -05:00
Mikayla Fischler
c221ffa129 #81 handle force disabled 2022-11-11 15:45:46 -05:00
Mikayla Fischler
83cf645da4 #107, #121 RTU build changes, formed handling 2022-11-11 14:59:53 -05:00
Mikayla Fischler
bc63a06b09 someone had PFE in an induction matrix so now i've gotta support some bigger numbers in the power format 2022-11-10 12:00:23 -05:00
Mikayla Fischler
806b217d58 #100 interactive reactor controls (start, scram, reset) 2022-11-06 18:41:52 -05:00
Mikayla Fischler
aaab34f1a8 #115, #116 multiple bugfixes with reactor PLC code 2022-11-05 12:44:40 -04:00
Mikayla
2851331fda Update issue templates 2022-11-04 13:07:00 -04:00
Mikayla Fischler
1828920873 #110, #114 no longer use mekanism energy helper functions as those are event consuming 2022-11-02 17:00:33 -04:00
Mikayla Fischler
c620310e51 #113 power formatting on turbine energy in main overview 2022-11-02 14:47:18 -04:00
Mikayla Fischler
54264f5149 #111 support unformed reactors 2022-11-02 13:45:52 -04:00
Mikayla Fischler
d87dfb9ebd #112 fixed bug with flasher 2022-11-02 12:02:52 -04:00
Mikayla Fischler
004c960e4d #106 fixes to reactor isFormed support 2022-10-25 23:45:59 -04:00
Mikayla Fischler
57bac57e3f adjusted TCD unserviced call delay 2022-10-25 13:30:41 -04:00
Mikayla Fischler
b2be3ef5fc #106 reactor formed support and remounting 2022-10-25 13:29:57 -04:00
Mikayla Fischler
a02fb6f691 #110 periodically call unserviced TCD callbacks 2022-10-23 12:21:17 -04:00
Mikayla Fischler
307bf6e2c8 added util timer functions, tweaks to flasher and some debug prints for #110 2022-10-23 01:41:02 -04:00
Mikayla Fischler
d202a49011 #108 resolved TCD race condition 2022-10-21 15:15:56 -04:00
Mikayla Fischler
93286174d4 some sneaky semicolons 2022-10-20 13:59:35 -04:00
Mikayla Fischler
788fae44aa #105 single coordinator configuration 2022-10-20 13:53:39 -04:00
Mikayla Fischler
2f55ad76f2 round burn rate to prevent weird floating point issues, added debug prints 2022-10-20 13:27:33 -04:00
Mikayla Fischler
1bf8fe557c flasher callback now private function 2022-10-20 12:23:00 -04:00
Mikayla Fischler
6d5af98310 graphics element enable/disable, click indication on hazard buttons 2022-10-20 12:22:45 -04:00
Mikayla Fischler
ab757e14a7 #100 work in progress on command acks for reactive buttons 2022-10-20 12:22:03 -04:00
Mikayla Fischler
bfa87815fa #90 flashing GUI indicator lights 2022-10-12 16:37:11 -04:00
Mikayla Fischler
77dc7ec0c9 fixed rps reset infinte retry, improved time delta calculations, added last_update to rtu device databases 2022-10-07 11:43:18 -04:00
Mikayla Fischler
5dfbe650c6 #93 don't send out-of-range burn rates (won't get a good ack), fixed unit command packet ordering 2022-10-07 11:28:56 -04:00
Mikayla Fischler
529951f998 automatically show current burn rate in burn rate spinbox 2022-10-07 11:21:17 -04:00
Mikayla Fischler
573c263548 same ppm fault check as with scram for enabling an enabled reactor 2022-10-07 10:29:25 -04:00
Mikayla Fischler
d4da6a7f3a fixed up types/names for hazard button 2022-10-07 10:28:46 -04:00
Mikayla Fischler
9d60777223 #93 added reset RPS command to iocontrol/gui 2022-10-07 10:19:37 -04:00
Mikayla Fischler
62ac993dae #93, #94, unit commands and range/type checks on unit IDs on PLC/RTU connections 2022-10-06 13:54:52 -04:00
Mikayla Fischler
c02479b52e #99 updating/sending builds 2022-10-02 21:17:13 -04:00
Mikayla Fischler
1b553ad495 #83 additional reactor structure fields, bugfix to rps alarm on sv, removed spam-prone rps error messages 2022-09-30 17:33:35 -04:00
Mikayla Fischler
7a90ea7e4e #87 check if the reactor is active on startup/reconnect before scram'ing, rps now ignores scram errors if the error is due to the reactor being inactive 2022-09-29 11:02:03 -04:00
Mikayla Fischler
4f7775ccb6 check for table type before checking length, added power conversion/formatting helpers 2022-09-22 21:31:07 -04:00
Mikayla Fischler
50be7f9ca2 #97 fixed issue where traffic on other channels gets processed if channels are left open 2022-09-22 20:42:06 -04:00
Mikayla Fischler
a87e557d2d updated readme, removed #29 from known issues due to updating to requiring 10.1+ 2022-09-21 17:30:20 -04:00
Mikayla Fischler
36557fc345 code cleanup, type hints, bugfixes, and #98 removal of support for mek 10.0 RTU peripherals 2022-09-21 15:53:51 -04:00
118 changed files with 12550 additions and 4873 deletions

27
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,27 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots and Logs**
If applicable, add screenshots to help explain your problem. Please include a text snippet from the log.txt files if possible, otherwise include a screenshot.
**Additional context**
Add any other context about the problem here.

View File

@@ -0,0 +1,17 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Additional context**
Add any other context or screenshots about the feature request here.

1
.gitignore vendored
View File

@@ -1 +1,2 @@
_notes/ _notes/
program.sh

View File

@@ -12,6 +12,12 @@
"settings", "settings",
"window", "window",
"read", "read",
"periphemu" "periphemu",
"mekanismEnergyHelper",
"_HOST",
"http"
],
"Lua.diagnostics.disable": [
"duplicate-set-field"
] ]
} }

View File

@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2022 Mikayla Fischler Copyright © 2022 - 2023 Mikayla Fischler
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View File

@@ -1,13 +1,27 @@
# cc-mek-scada # cc-mek-scada
Configurable ComputerCraft SCADA system for multi-reactor control of Mekanism fission reactors with a GUI, automatic safety features, waste processing control, and more! Configurable ComputerCraft SCADA system for multi-reactor control of Mekanism fission reactors with a GUI, automatic safety features, waste processing control, and more!
This requires CC: Tweaked and Mekanism v10.0+ (10.1 recommended for full feature set). Mod Requirements:
- CC: Tweaked
- Mekanism v10.1+
Mod Recommendations:
- Advanced Peripherals (adds the capability to detect environmental radiation levels)
v10.1+ is required due the complete support of CC:Tweaked added in Mekanism v10.1
There was also an apparent bug with boilers disconnecting and reconnecting when active in my test world on 10.0.24, so it may not even have been an option to fully implement this with support for 10.0.
## Installation
You can install this on a ComputerCraft computer using either:
* `wget https://raw.githubusercontent.com/MikaylaFischler/cc-mek-scada/main/ccmsi.lua`
* `pastebin get iUMjgW0C ccmsi.lua`
## [SCADA](https://en.wikipedia.org/wiki/SCADA) ## [SCADA](https://en.wikipedia.org/wiki/SCADA)
> Supervisory control and data acquisition (SCADA) is a control system architecture comprising computers, networked data communications and graphical user interfaces for high-level supervision of machines and processes. It also covers sensors and other devices, such as programmable logic controllers, which interface with process plant or machinery. > Supervisory control and data acquisition (SCADA) is a control system architecture comprising computers, networked data communications and graphical user interfaces for high-level supervision of machines and processes. It also covers sensors and other devices, such as programmable logic controllers, which interface with process plant or machinery.
This project implements concepts of a SCADA system in ComputerCraft (because why not? ..okay don't answer that). I recommend reviewing that linked wikipedia page on SCADA if you want to understand the concepts used here. This project implements concepts of a SCADA system in ComputerCraft (because why not? ..okay don't answer that). I recommend reviewing that linked wikipedia page on SCADA if you *want* to understand the concepts used here.
![Architecture](https://upload.wikimedia.org/wikipedia/commons/thumb/1/10/Functional_levels_of_a_Distributed_Control_System.svg/1000px-Functional_levels_of_a_Distributed_Control_System.svg.png) ![Architecture](https://upload.wikimedia.org/wikipedia/commons/thumb/1/10/Functional_levels_of_a_Distributed_Control_System.svg/1000px-Functional_levels_of_a_Distributed_Control_System.svg.png)
@@ -35,7 +49,7 @@ The RTU control code is relatively unique, as instead of having instructions be
### PLCs ### PLCs
PLCs are advanced devices that allow for both reporting and control to/from the SCADA system in addition to programed behaviors independent of the SCADA system. Currently there is only one type of PLC, and that is the reactor PLC. This is responsible for reporting on and controlling the reactor as a part of the SCADA system, and independently regulating the safety of the reactor. It checks the status for multiple hazard scenarios and shuts down the reactor if any condition is satisfied. PLCs are advanced devices that allow for both reporting and control to/from the SCADA system in addition to programed behaviors independent of the SCADA system. Currently there is only one type of PLC, and that is the reactor PLC. This is responsible for reporting on and controlling the reactor as a part of the SCADA system, and independently regulating the safety of the reactor. It checks the status for multiple hazard scenarios and shuts down the reactor if any condition is met.
There can and should only be one of these per reactor. A single Advanced Computer will act as the PLC, with either a direct connection (physical contact) or a wired modem connection to the reactor logic port. There can and should only be one of these per reactor. A single Advanced Computer will act as the PLC, with either a direct connection (physical contact) or a wired modem connection to the reactor logic port.
@@ -53,9 +67,8 @@ TBD, I am planning on AES symmetric encryption for security + HMAC to prevent re
This is somewhat important here as otherwise anyone can just control your setup, which is undeseriable. Unlike normal Minecraft PVP chaos, it would be very difficult to identify who is messing with your system, as with an Ender Modem they can do it from effectively anywhere and the server operators would have to check every computer's filesystem to find suspicious code. This is somewhat important here as otherwise anyone can just control your setup, which is undeseriable. Unlike normal Minecraft PVP chaos, it would be very difficult to identify who is messing with your system, as with an Ender Modem they can do it from effectively anywhere and the server operators would have to check every computer's filesystem to find suspicious code.
The only other possible security mitigation for commanding (no effect on monitoring) is to enforce a maximum authorized transmission range (which I will probably also do, or maybe fall back to), as modem message events contain the transmission distance. The other security mitigation for commanding (no effect on monitoring) is to enforce a maximum authorized transmission range, which has been added as a configurable feature.
## Known Issues ## Known Issues
GitHub issue \#29: None yet since the switch to requiring 10.1+!
It appears that with Mekanism 10.0, a boiler peripheral may rapidly disconnect/reconnect constantly while running. This will prevent that RTU from operating correctly while also filling up the log file. This may be due to a very specific version interaction of CC: Tweaked and Mekansim, so you are welcome to try this on Mekanism 10.0 servers, but do be aware it may not work.

670
ccmsi.lua Normal file
View File

@@ -0,0 +1,670 @@
--
-- ComputerCraft Mekanism SCADA System Installer Utility
--
--[[
Copyright © 2023 Mikayla Fischler
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the “Software”), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
]]--
local function println(message) print(tostring(message)) end
local function print(message) term.write(tostring(message)) end
local CCMSI_VERSION = "v1.0"
local install_dir = "/.install-cache"
local repo_path = "http://raw.githubusercontent.com/MikaylaFischler/cc-mek-scada/"
local opts = { ... }
local mode = nil
local app = nil
-- record the local installation manifest
---@param manifest table
---@param dependencies table
local function write_install_manifest(manifest, dependencies)
local versions = {}
for key, value in pairs(manifest.versions) do
local is_dependency = false
for _, dependency in pairs(dependencies) do
if key == "bootloader" and dependency == "system" then
is_dependency = true
break
end
end
if key == app or key == "comms" or is_dependency then versions[key] = value end
end
manifest.versions = versions
local imfile = fs.open("install_manifest.json", "w")
imfile.write(textutils.serializeJSON(manifest))
imfile.close()
end
--
-- get and validate command line options
--
println("-- CC Mekanism SCADA Installer " .. CCMSI_VERSION .. " --")
if #opts == 0 or opts[1] == "help" then
println("usage: ccmsi <mode> <app> <tag/branch>")
println("<mode>")
term.setTextColor(colors.lightGray)
println(" check - check latest versions avilable")
term.setTextColor(colors.yellow)
println(" ccmsi check <tag/branch> for target")
term.setTextColor(colors.lightGray)
println(" install - fresh install, overwrites config")
println(" update - update files EXCEPT for config/logs")
println(" remove - delete files EXCEPT for config/logs")
println(" purge - delete files INCLUDING config/logs")
term.setTextColor(colors.white)
println("<app>")
term.setTextColor(colors.lightGray)
println(" reactor-plc - reactor PLC firmware")
println(" rtu - RTU firmware")
println(" supervisor - supervisor server application")
println(" coordinator - coordinator application")
println(" pocket - pocket application")
term.setTextColor(colors.white)
println("<tag/branch>")
term.setTextColor(colors.yellow)
println(" second parameter when used with check")
term.setTextColor(colors.lightGray)
println(" note: defaults to main")
println(" target GitHub tag or branch name")
return
else
for _, v in pairs({ "check", "install", "update", "remove", "purge" }) do
if opts[1] == v then
mode = v
break
end
end
if mode == nil then
println("unrecognized mode")
return
end
for _, v in pairs({ "reactor-plc", "rtu", "supervisor", "coordinator", "pocket" }) do
if opts[2] == v then
app = v
break
end
end
if app == nil and mode ~= "check" then
println("unrecognized application")
return
end
end
--
-- run selected mode
--
if mode == "check" then
-------------------------
-- GET REMOTE MANIFEST --
-------------------------
if opts[2] then repo_path = repo_path .. opts[2] .. "/" else repo_path = repo_path .. "main/" end
local install_manifest = repo_path .. "install_manifest.json"
local response, error = http.get(install_manifest)
if response == nil then
term.setTextColor(colors.orange)
println("failed to get installation manifest from GitHub, cannot update or install")
term.setTextColor(colors.red)
println("HTTP error: " .. error)
term.setTextColor(colors.white)
return
end
local ok, manifest = pcall(function () return textutils.unserializeJSON(response.readAll()) end)
if not ok then
term.setTextColor(colors.red)
println("error parsing remote installation manifest")
term.setTextColor(colors.white)
return
end
------------------------
-- GET LOCAL MANIFEST --
------------------------
local imfile = fs.open("install_manifest.json", "r")
local local_ok = false
local local_manifest = {}
if imfile ~= nil then
local_ok, local_manifest = pcall(function () return textutils.unserializeJSON(imfile.readAll()) end)
imfile.close()
end
if not local_ok then
term.setTextColor(colors.yellow)
println("failed to load local installation information")
term.setTextColor(colors.white)
local_manifest = { versions = { installer = CCMSI_VERSION } }
else
local_manifest.versions.installer = CCMSI_VERSION
end
-- list all versions
for key, value in pairs(manifest.versions) do
term.setTextColor(colors.purple)
print(string.format("%-14s", "[" .. key .. "]"))
if key == "installer" or (local_ok and (local_manifest.versions[key] ~= nil)) then
term.setTextColor(colors.blue)
print(local_manifest.versions[key])
if value ~= local_manifest.versions[key] then
term.setTextColor(colors.white)
print(" (")
term.setTextColor(colors.cyan)
print(value)
term.setTextColor(colors.white)
println(" available)")
else
term.setTextColor(colors.green)
println(" (up to date)")
end
else
term.setTextColor(colors.lightGray)
print("not installed")
term.setTextColor(colors.white)
print(" (latest ")
term.setTextColor(colors.cyan)
print(value)
term.setTextColor(colors.white)
println(")")
end
end
elseif mode == "install" or mode == "update" then
-------------------------
-- GET REMOTE MANIFEST --
-------------------------
if opts[3] then repo_path = repo_path .. opts[3] .. "/" else repo_path = repo_path .. "main/" end
local install_manifest = repo_path .. "install_manifest.json"
local response, error = http.get(install_manifest)
if response == nil then
term.setTextColor(colors.orange)
println("failed to get installation manifest from GitHub, cannot update or install")
term.setTextColor(colors.red)
println("HTTP error: " .. error)
term.setTextColor(colors.white)
return
end
local ok, manifest = pcall(function () return textutils.unserializeJSON(response.readAll()) end)
if not ok then
term.setTextColor(colors.red)
println("error parsing remote installation manifest")
term.setTextColor(colors.white)
end
------------------------
-- GET LOCAL MANIFEST --
------------------------
local imfile = fs.open("install_manifest.json", "r")
local local_ok = false
local local_manifest = {}
if imfile ~= nil then
local_ok, local_manifest = pcall(function () return textutils.unserializeJSON(imfile.readAll()) end)
imfile.close()
end
local local_app_version = nil
local local_comms_version = nil
local local_boot_version = nil
-- try to find local versions
if not local_ok then
if mode == "update" then
term.setTextColor(colors.red)
println("failed to load local installation information, cannot update")
term.setTextColor(colors.white)
return
end
else
local_app_version = local_manifest.versions[app]
local_comms_version = local_manifest.versions.comms
local_boot_version = local_manifest.versions.bootloader
if local_manifest.versions[app] == nil then
term.setTextColor(colors.red)
println("another application is already installed, please purge it before installing a new application")
term.setTextColor(colors.white)
return
end
local_manifest.versions.installer = CCMSI_VERSION
if manifest.versions.installer ~= CCMSI_VERSION then
term.setTextColor(colors.yellow)
println("a newer version of the installer is available, consider downloading it")
term.setTextColor(colors.white)
end
end
local remote_app_version = manifest.versions[app]
local remote_comms_version = manifest.versions.comms
local remote_boot_version = manifest.versions.bootloader
term.setTextColor(colors.green)
if mode == "install" then
println("installing " .. app .. " files...")
elseif mode == "update" then
println("updating " .. app .. " files... (keeping old config.lua)")
end
term.setTextColor(colors.white)
-- display bootloader version change information
if local_boot_version ~= nil then
if local_boot_version ~= remote_boot_version then
print("[bootldr] updating ")
term.setTextColor(colors.blue)
print(local_boot_version)
term.setTextColor(colors.white)
print(" \xbb ")
term.setTextColor(colors.blue)
println(remote_boot_version)
term.setTextColor(colors.white)
elseif mode == "install" then
print("[bootldr] reinstalling ")
term.setTextColor(colors.blue)
println(local_boot_version)
term.setTextColor(colors.white)
end
else
print("[bootldr] new install of ")
term.setTextColor(colors.blue)
println(remote_boot_version)
term.setTextColor(colors.white)
end
-- display app version change information
if local_app_version ~= nil then
if local_app_version ~= remote_app_version then
print("[" .. app .. "] updating ")
term.setTextColor(colors.blue)
print(local_app_version)
term.setTextColor(colors.white)
print(" \xbb ")
term.setTextColor(colors.blue)
println(remote_app_version)
term.setTextColor(colors.white)
elseif mode == "install" then
print("[" .. app .. "] reinstalling ")
term.setTextColor(colors.blue)
println(local_app_version)
term.setTextColor(colors.white)
end
else
print("[" .. app .. "] new install of ")
term.setTextColor(colors.blue)
println(remote_app_version)
term.setTextColor(colors.white)
end
-- display comms version change information
if local_comms_version ~= nil then
if local_comms_version ~= remote_comms_version then
print("[comms] updating ")
term.setTextColor(colors.blue)
print(local_comms_version)
term.setTextColor(colors.white)
print(" \xbb ")
term.setTextColor(colors.blue)
println(remote_comms_version)
term.setTextColor(colors.white)
print("[comms] ")
term.setTextColor(colors.yellow)
println("other devices on the network will require an update")
term.setTextColor(colors.white)
elseif mode == "install" then
print("[comms] reinstalling ")
term.setTextColor(colors.blue)
println(local_comms_version)
term.setTextColor(colors.white)
end
else
print("[comms] new install of ")
term.setTextColor(colors.blue)
println(remote_comms_version)
term.setTextColor(colors.white)
end
--------------------------
-- START INSTALL/UPDATE --
--------------------------
local space_required = manifest.sizes.manifest
local space_available = fs.getFreeSpace("/")
local single_file_mode = false
local file_list = manifest.files
local size_list = manifest.sizes
local dependencies = manifest.depends[app]
local config_file = app .. "/config.lua"
table.insert(dependencies, app)
for _, dependency in pairs(dependencies) do
local size = size_list[dependency]
space_required = space_required + size
end
-- check space constraints
if space_available < space_required then
single_file_mode = true
term.setTextColor(colors.yellow)
println("WARNING: Insufficient space available for a full download!")
term.setTextColor(colors.white)
println("Files can be downloaded one by one, so if you are replacing a current install this will not be a problem unless installation fails.")
println("Do you wish to continue? (y/N)")
local confirm = read()
if confirm ~= "y" and confirm ~= "Y" then
println("installation cancelled")
return
end
end
---@diagnostic disable-next-line: undefined-field
os.sleep(2)
local success = true
if not single_file_mode then
if fs.exists(install_dir) then
fs.delete(install_dir)
fs.makeDir(install_dir)
end
-- download all dependencies
for _, dependency in pairs(dependencies) do
if mode == "update" and ((dependency == "system" and local_boot_version == remote_boot_version) or (local_app_version == remote_app_version)) then
-- skip system package if unchanged, skip app package if not changed
-- skip packages that have no version if app version didn't change
term.setTextColor(colors.white)
print("skipping download of unchanged package ")
term.setTextColor(colors.blue)
println(dependency)
else
term.setTextColor(colors.white)
print("downloading package ")
term.setTextColor(colors.blue)
println(dependency)
term.setTextColor(colors.lightGray)
local files = file_list[dependency]
for _, file in pairs(files) do
println("GET " .. file)
local dl, err = http.get(repo_path .. file)
if dl == nil then
term.setTextColor(colors.red)
println("GET HTTP Error " .. err)
success = false
break
else
local handle = fs.open(install_dir .. "/" .. file, "w")
handle.write(dl.readAll())
handle.close()
end
end
end
end
-- copy in downloaded files (installation)
if success then
for _, dependency in pairs(dependencies) do
if mode == "update" and ((dependency == "system" and local_boot_version == remote_boot_version) or (local_app_version == remote_app_version)) then
-- skip system package if unchanged, skip app package if not changed
-- skip packages that have no version if app version didn't change
term.setTextColor(colors.white)
print("skipping install of unchanged package ")
term.setTextColor(colors.blue)
println(dependency)
else
term.setTextColor(colors.white)
print("installing package ")
term.setTextColor(colors.blue)
println(dependency)
term.setTextColor(colors.lightGray)
local files = file_list[dependency]
for _, file in pairs(files) do
if mode == "install" or file ~= config_file then
local temp_file = install_dir .. "/" .. file
if fs.exists(file) then fs.delete(file) end
fs.move(temp_file, file)
end
end
end
end
end
fs.delete(install_dir)
if success then
-- if we made it here, then none of the file system functions threw exceptions
-- that means everything is OK
write_install_manifest(manifest, dependencies)
term.setTextColor(colors.green)
if mode == "install" then
println("installation completed successfully")
else
println("update completed successfully")
end
else
if mode == "install" then
term.setTextColor(colors.red)
println("installation failed")
else
term.setTextColor(colors.orange)
println("update failed, existing files unmodified")
end
end
else
-- go through all files and replace one by one
for _, dependency in pairs(dependencies) do
if mode == "update" and ((dependency == "system" and local_boot_version == remote_boot_version) or (local_app_version == remote_app_version)) then
-- skip system package if unchanged, skip app package if not changed
-- skip packages that have no version if app version didn't change
term.setTextColor(colors.white)
print("skipping install of unchanged package ")
term.setTextColor(colors.blue)
println(dependency)
else
term.setTextColor(colors.white)
print("installing package ")
term.setTextColor(colors.blue)
println(dependency)
term.setTextColor(colors.lightGray)
local files = file_list[dependency]
for _, file in pairs(files) do
if mode == "install" or file ~= config_file then
println("GET " .. file)
local dl, err = http.get(repo_path .. file)
if dl == nil then
println("GET HTTP Error " .. err)
success = false
break
else
local handle = fs.open("/" .. file, "w")
handle.write(dl.readAll())
handle.close()
end
end
end
end
end
if success then
-- if we made it here, then none of the file system functions threw exceptions
-- that means everything is OK
write_install_manifest(manifest, dependencies)
term.setTextColor(colors.green)
if mode == "install" then
println("installation completed successfully")
else
println("update completed successfully")
end
else
term.setTextColor(colors.red)
if mode == "install" then
println("installation failed, files may have been skipped")
else
println("update failed, files may have been skipped")
end
end
end
elseif mode == "remove" or mode == "purge" then
local imfile = fs.open("install_manifest.json", "r")
local ok = false
local manifest = {}
if imfile ~= nil then
ok, manifest = pcall(function () return textutils.unserializeJSON(imfile.readAll()) end)
imfile.close()
end
if not ok then
term.setTextColor(colors.red)
println("error parsing local installation manifest")
term.setTextColor(colors.white)
return
elseif mode == "remove" and manifest.versions[app] == nil then
term.setTextColor(colors.red)
println(app .. " is not installed")
term.setTextColor(colors.white)
return
end
term.setTextColor(colors.orange)
if mode == "remove" then
println("removing all " .. app .. " files except for config.lua and log.txt...")
elseif mode == "purge" then
println("purging all " .. app .. " files...")
end
---@diagnostic disable-next-line: undefined-field
os.sleep(2)
local file_list = manifest.files
local dependencies = manifest.depends[app]
local config_file = app .. "/config.lua"
table.insert(dependencies, app)
term.setTextColor(colors.lightGray)
-- delete log file if purging
if mode == "purge" and fs.exists(config_file) then
local log_deleted = pcall(function ()
local config = require(app .. ".config")
if fs.exists(config.LOG_PATH) then
fs.delete(config.LOG_PATH)
println("deleted log file " .. config.LOG_PATH)
end
end)
if not log_deleted then
term.setTextColor(colors.red)
println("failed to delete log file")
term.setTextColor(colors.lightGray)
---@diagnostic disable-next-line: undefined-field
os.sleep(1)
end
end
-- delete all files except config unless purging
for _, dependency in pairs(dependencies) do
local files = file_list[dependency]
for _, file in pairs(files) do
if mode == "purge" or file ~= config_file then
if fs.exists(file) then
fs.delete(file)
println("deleted " .. file)
end
end
end
-- delete folders that we should be deleteing
if mode == "purge" or dependency ~= app then
local folder = files[1]
while true do
local dir = fs.getDir(folder)
if dir == "" or dir == ".." then
break
else
folder = dir
end
end
if fs.isDir(folder) then
fs.delete(folder)
println("deleted directory " .. folder)
end
elseif dependency == app then
for _, folder in pairs(files) do
while true do
local dir = fs.getDir(folder)
if dir == "" or dir == ".." or dir == app then
break
else
folder = dir
end
end
if folder ~= app and fs.isDir(folder) then
fs.delete(folder)
println("deleted app subdirectory " .. folder)
end
end
end
end
-- only delete manifest if purging
if mode == "purge" then
fs.delete("install_manifest.json")
println("deleted install_manifest.json")
else
-- remove all data from versions list to show nothing is installed
manifest.versions = {}
imfile = fs.open("install_manifest.json", "w")
imfile.write(textutils.serializeJSON(manifest))
imfile.close()
end
term.setTextColor(colors.green)
println("done!")
end
term.setTextColor(colors.white)

View File

@@ -4,13 +4,17 @@ local apisessions = {}
function apisessions.handle_packet(packet) function apisessions.handle_packet(packet)
end end
function apisessions.check_all_watchdogs() -- attempt to identify which session's watchdog timer fired
end ---@param timer_event number
function apisessions.check_all_watchdogs(timer_event)
function apisessions.close_all()
end end
-- delete all closed sessions
function apisessions.free_all_closed() function apisessions.free_all_closed()
end end
-- close all open connections
function apisessions.close_all()
end
return apisessions return apisessions

View File

@@ -6,19 +6,26 @@ config.SCADA_SV_PORT = 16100
config.SCADA_SV_LISTEN = 16101 config.SCADA_SV_LISTEN = 16101
-- listen port for SCADA coordinator API access -- listen port for SCADA coordinator API access
config.SCADA_API_LISTEN = 16200 config.SCADA_API_LISTEN = 16200
-- max trusted modem message distance (0 to disable check)
config.TRUSTED_RANGE = 0
-- time in seconds (>= 2) before assuming a remote device is no longer active
config.COMMS_TIMEOUT = 5
-- expected number of reactor units, used only to require that number of unit monitors -- expected number of reactor units, used only to require that number of unit monitors
config.NUM_UNITS = 4 config.NUM_UNITS = 4
-- graphics color
config.RECOLOR = true -- alarm sounder volume (0.0 to 3.0, 1.0 being standard max volume, this is the option given to to speaker.play())
-- note: alarm sine waves are at half saturation, so that multiple will be required to reach full scale
config.SOUNDER_VOLUME = 1.0
-- true for 24 hour time on main view screen
config.TIME_24_HOUR = true
-- log path -- log path
config.LOG_PATH = "/log.txt" config.LOG_PATH = "/log.txt"
-- log mode -- log mode
-- 0 = APPEND (adds to existing file on start) -- 0 = APPEND (adds to existing file on start)
-- 1 = NEW (replaces existing file on start) -- 1 = NEW (replaces existing file on start)
config.LOG_MODE = 0 config.LOG_MODE = 0
-- crypto config
config.SECURE = true
-- must be common between all devices
config.PASSWORD = "testpassword!"
return config return config

View File

@@ -1,26 +1,33 @@
local comms = require("scada-common.comms") local comms = require("scada-common.comms")
local log = require("scada-common.log") local log = require("scada-common.log")
local ppm = require("scada-common.ppm") local ppm = require("scada-common.ppm")
local util = require("scada-common.util") local util = require("scada-common.util")
local apisessions = require("coordinator.apisessions") local apisessions = require("coordinator.apisessions")
local iocontrol = require("coordinator.iocontrol") local iocontrol = require("coordinator.iocontrol")
local process = require("coordinator.process")
local dialog = require("coordinator.ui.dialog") local dialog = require("coordinator.ui.dialog")
local coordinator = {}
local print = util.print local print = util.print
local println = util.println local println = util.println
local print_ts = util.print_ts local print_ts = util.print_ts
local println_ts = util.println_ts local println_ts = util.println_ts
local PROTOCOLS = comms.PROTOCOLS local PROTOCOL = comms.PROTOCOL
local SCADA_MGMT_TYPES = comms.SCADA_MGMT_TYPES local DEVICE_TYPE = comms.DEVICE_TYPE
local SCADA_CRDN_TYPES = comms.SCADA_CRDN_TYPES local ESTABLISH_ACK = comms.ESTABLISH_ACK
local SCADA_MGMT_TYPE = comms.SCADA_MGMT_TYPE
local SCADA_CRDN_TYPE = comms.SCADA_CRDN_TYPE
local UNIT_COMMAND = comms.UNIT_COMMAND
local FAC_COMMAND = comms.FAC_COMMAND
local coordinator = {}
-- request the user to select a monitor -- request the user to select a monitor
---@nodiscard
---@param names table available monitors ---@param names table available monitors
---@return boolean|string|nil
local function ask_monitor(names) local function ask_monitor(names)
println("available monitors:") println("available monitors:")
for i = 1, #names do for i = 1, #names do
@@ -40,6 +47,7 @@ end
-- configure monitor layout -- configure monitor layout
---@param num_units integer number of units expected ---@param num_units integer number of units expected
---@return boolean success, monitors_struct? monitors
function coordinator.configure_monitors(num_units) function coordinator.configure_monitors(num_units)
---@class monitors_struct ---@class monitors_struct
local monitors = { local monitors = {
@@ -51,27 +59,42 @@ function coordinator.configure_monitors(num_units)
local monitors_avail = ppm.get_monitor_list() local monitors_avail = ppm.get_monitor_list()
local names = {} local names = {}
local available = {}
-- get all interface names -- get all interface names
for iface, _ in pairs(monitors_avail) do for iface, _ in pairs(monitors_avail) do
table.insert(names, iface) table.insert(names, iface)
table.insert(available, iface)
end end
-- we need a certain number of monitors (1 per unit + 1 primary display) -- we need a certain number of monitors (1 per unit + 1 primary display)
if #names < num_units + 1 then local num_displays_needed = num_units + 1
println("not enough monitors connected (need " .. num_units + 1 .. ")") if #names < num_displays_needed then
log.warning("insufficient monitors present (need " .. num_units + 1 .. ")") local message = "not enough monitors connected (need " .. num_displays_needed .. ")"
println(message)
log.warning(message)
return false return false
end end
-- attempt to load settings -- attempt to load settings
settings.load("/coord.settings") if not settings.load("/coord.settings") then
log.warning("configure_monitors(): failed to load coordinator settings file (may not exist yet)")
else
local _primary = settings.get("PRIMARY_DISPLAY")
local _unitd = settings.get("UNIT_DISPLAYS")
-- filter out already assigned monitors
util.filter_table(available, function (x) return x ~= _primary end)
if type(_unitd) == "table" then
util.filter_table(available, function (x) return not util.table_contains(_unitd, x) end)
end
end
--------------------- ---------------------
-- PRIMARY DISPLAY -- -- PRIMARY DISPLAY --
--------------------- ---------------------
local iface_primary_display = settings.get("PRIMARY_DISPLAY") local iface_primary_display = settings.get("PRIMARY_DISPLAY") ---@type boolean|string|nil
if not util.table_contains(names, iface_primary_display) then if not util.table_contains(names, iface_primary_display) then
println("primary display is not connected") println("primary display is not connected")
@@ -80,15 +103,15 @@ function coordinator.configure_monitors(num_units)
iface_primary_display = nil iface_primary_display = nil
end end
while iface_primary_display == nil and #names > 0 do while iface_primary_display == nil and #available > 0 do
-- lets get a monitor -- lets get a monitor
iface_primary_display = ask_monitor(names) iface_primary_display = ask_monitor(available)
end end
if iface_primary_display == false then return false end if type(iface_primary_display) ~= "string" then return false end
settings.set("PRIMARY_DISPLAY", iface_primary_display) settings.set("PRIMARY_DISPLAY", iface_primary_display)
util.filter_table(names, function (x) return x ~= iface_primary_display end) util.filter_table(available, function (x) return x ~= iface_primary_display end)
monitors.primary = ppm.get_periph(iface_primary_display) monitors.primary = ppm.get_periph(iface_primary_display)
monitors.primary_name = iface_primary_display monitors.primary_name = iface_primary_display
@@ -104,10 +127,10 @@ function coordinator.configure_monitors(num_units)
for i = 1, num_units do for i = 1, num_units do
local display = nil local display = nil
while display == nil and #names > 0 do while display == nil and #available > 0 do
-- lets get a monitor -- lets get a monitor
println("please select monitor for unit " .. i) println("please select monitor for unit #" .. i)
display = ask_monitor(names) display = ask_monitor(available)
end end
if display == false then return false end if display == false then return false end
@@ -117,18 +140,18 @@ function coordinator.configure_monitors(num_units)
else else
-- make sure all displays are connected -- make sure all displays are connected
for i = 1, num_units do for i = 1, num_units do
---@diagnostic disable-next-line: need-check-nil
local display = unit_displays[i] local display = unit_displays[i]
if not util.table_contains(names, display) then if not util.table_contains(names, display) then
local response = dialog.ask_y_n("unit display " .. i .. " is not connected, would you like to change it?", true) println("unit #" .. i .. " display is not connected")
local response = dialog.ask_y_n("would you like to change it", true)
if response == false then return false end if response == false then return false end
display = nil display = nil
end end
while display == nil and #names > 0 do while display == nil and #available > 0 do
-- lets get a monitor -- lets get a monitor
display = ask_monitor(names) display = ask_monitor(available)
end end
if display == false then return false end if display == false then return false end
@@ -138,7 +161,9 @@ function coordinator.configure_monitors(num_units)
end end
settings.set("UNIT_DISPLAYS", unit_displays) settings.set("UNIT_DISPLAYS", unit_displays)
settings.save("/coord.settings") if not settings.save("/coord.settings") then
log.warning("configure_monitors(): failed to save coordinator settings file")
end
for i = 1, #unit_displays do for i = 1, #unit_displays do
monitors.unit_displays[i] = ppm.get_periph(unit_displays[i]) monitors.unit_displays[i] = ppm.get_periph(unit_displays[i])
@@ -173,55 +198,59 @@ function coordinator.log_sys(message) log_dmesg(message, "SYSTEM") end
function coordinator.log_boot(message) log_dmesg(message, "BOOT") end function coordinator.log_boot(message) log_dmesg(message, "BOOT") end
function coordinator.log_comms(message) log_dmesg(message, "COMMS") end function coordinator.log_comms(message) log_dmesg(message, "COMMS") end
-- log a message for communications connecting, providing access to progress indication control functions
---@nodiscard
---@param message string ---@param message string
---@return function update, function done ---@return function update, function done
function coordinator.log_comms_connecting(message) return log_dmesg(message, "COMMS", true) end function coordinator.log_comms_connecting(message)
local update, done = log_dmesg(message, "COMMS", true)
---@cast update function
---@cast done function
return update, done
end
-- coordinator communications -- coordinator communications
---@param version string ---@nodiscard
---@param modem table ---@param version string coordinator version
---@param sv_port integer ---@param modem table modem device
---@param sv_listen integer ---@param sv_port integer port of configured supervisor
---@param api_listen integer ---@param sv_listen integer listening port for supervisor replys
---@param api_listen integer listening port for pocket API
---@param range integer trusted device connection range
---@param sv_watchdog watchdog ---@param sv_watchdog watchdog
function coordinator.comms(version, modem, sv_port, sv_listen, api_listen, sv_watchdog) function coordinator.comms(version, modem, sv_port, sv_listen, api_listen, range, sv_watchdog)
local self = { local self = {
sv_linked = false, sv_linked = false,
sv_seq_num = 0, sv_seq_num = 0,
sv_r_seq_num = nil, sv_r_seq_num = nil,
modem = modem, sv_config_err = false,
connected = false connected = false,
last_est_ack = ESTABLISH_ACK.ALLOW
} }
---@class coord_comms comms.set_trusted_range(range)
local public = {}
-- PRIVATE FUNCTIONS -- -- PRIVATE FUNCTIONS --
-- open all channels -- configure modem channels
local function _open_channels() local function _conf_channels()
if not self.modem.isOpen(sv_listen) then modem.closeAll()
self.modem.open(sv_listen) modem.open(sv_listen)
end modem.open(api_listen)
if not self.modem.isOpen(api_listen) then
self.modem.open(api_listen)
end
end end
-- open at construct time _conf_channels()
_open_channels()
-- send a packet to the supervisor -- send a packet to the supervisor
---@param msg_type SCADA_MGMT_TYPES|SCADA_CRDN_TYPES ---@param msg_type SCADA_MGMT_TYPE|SCADA_CRDN_TYPE
---@param msg table ---@param msg table
local function _send_sv(protocol, msg_type, msg) local function _send_sv(protocol, msg_type, msg)
local s_pkt = comms.scada_packet() local s_pkt = comms.scada_packet()
local pkt = nil ---@type mgmt_packet|crdn_packet local pkt = nil ---@type mgmt_packet|crdn_packet
if protocol == PROTOCOLS.SCADA_MGMT then if protocol == PROTOCOL.SCADA_MGMT then
pkt = comms.mgmt_packet() pkt = comms.mgmt_packet()
elseif protocol == PROTOCOLS.SCADA_CRDN then elseif protocol == PROTOCOL.SCADA_CRDN then
pkt = comms.crdn_packet() pkt = comms.crdn_packet()
else else
return return
@@ -230,39 +259,42 @@ function coordinator.comms(version, modem, sv_port, sv_listen, api_listen, sv_wa
pkt.make(msg_type, msg) pkt.make(msg_type, msg)
s_pkt.make(self.sv_seq_num, protocol, pkt.raw_sendable()) s_pkt.make(self.sv_seq_num, protocol, pkt.raw_sendable())
self.modem.transmit(sv_port, sv_listen, s_pkt.raw_sendable()) modem.transmit(sv_port, sv_listen, s_pkt.raw_sendable())
self.sv_seq_num = self.sv_seq_num + 1 self.sv_seq_num = self.sv_seq_num + 1
end end
-- attempt connection establishment -- attempt connection establishment
local function _send_establish() local function _send_establish()
_send_sv(PROTOCOLS.SCADA_CRDN, SCADA_CRDN_TYPES.ESTABLISH, { version }) _send_sv(PROTOCOL.SCADA_MGMT, SCADA_MGMT_TYPE.ESTABLISH, { comms.version, version, DEVICE_TYPE.CRDN })
end end
-- keep alive ack -- keep alive ack
---@param srv_time integer ---@param srv_time integer
local function _send_keep_alive_ack(srv_time) local function _send_keep_alive_ack(srv_time)
_send_sv(PROTOCOLS.SCADA_MGMT, SCADA_MGMT_TYPES.KEEP_ALIVE, { srv_time, util.time() }) _send_sv(PROTOCOL.SCADA_MGMT, SCADA_MGMT_TYPE.KEEP_ALIVE, { srv_time, util.time() })
end end
-- PUBLIC FUNCTIONS -- -- PUBLIC FUNCTIONS --
---@class coord_comms
local public = {}
-- reconnect a newly connected modem -- reconnect a newly connected modem
---@param modem table ---@param new_modem table
---@diagnostic disable-next-line: redefined-local function public.reconnect_modem(new_modem)
function public.reconnect_modem(modem) modem = new_modem
self.modem = modem _conf_channels()
_open_channels()
end end
-- close the connection to the server -- close the connection to the server
function public.close() function public.close()
sv_watchdog.cancel() sv_watchdog.cancel()
self.sv_linked = false self.sv_linked = false
_send_sv(PROTOCOLS.SCADA_MGMT, SCADA_MGMT_TYPES.CLOSE, {}) _send_sv(PROTOCOL.SCADA_MGMT, SCADA_MGMT_TYPE.CLOSE, {})
end end
-- attempt to connect to the subervisor -- attempt to connect to the subervisor
---@nodiscard
---@param timeout_s number timeout in seconds ---@param timeout_s number timeout in seconds
---@param tick_dmesg_waiting function callback to tick dmesg waiting ---@param tick_dmesg_waiting function callback to tick dmesg waiting
---@param task_done function callback to show done on dmesg ---@param task_done function callback to show done on dmesg
@@ -277,7 +309,7 @@ function coordinator.comms(version, modem, sv_port, sv_listen, api_listen, sv_wa
clock.start() clock.start()
while (util.time_s() - start) < timeout_s and not self.sv_linked do while (util.time_s() - start) < timeout_s and (not self.sv_linked) and (not self.sv_config_err) do
local event, p1, p2, p3, p4, p5 = util.pull_event() local event, p1, p2, p3, p4, p5 = util.pull_event()
if event == "timer" and clock.is_clock(p1) then if event == "timer" and clock.is_clock(p1) then
@@ -288,7 +320,7 @@ function coordinator.comms(version, modem, sv_port, sv_listen, api_listen, sv_wa
elseif event == "modem_message" then elseif event == "modem_message" then
-- handle message -- handle message
local packet = public.parse_packet(p1, p2, p3, p4, p5) local packet = public.parse_packet(p1, p2, p3, p4, p5)
if packet ~= nil and packet.type == SCADA_CRDN_TYPES.ESTABLISH then if packet ~= nil and packet.type == SCADA_MGMT_TYPE.ESTABLISH then
public.handle_packet(packet) public.handle_packet(packet)
end end
elseif event == "terminate" then elseif event == "terminate" then
@@ -301,11 +333,45 @@ function coordinator.comms(version, modem, sv_port, sv_listen, api_listen, sv_wa
if terminated then if terminated then
coordinator.log_comms("supervisor connection attempt cancelled by user") coordinator.log_comms("supervisor connection attempt cancelled by user")
elseif self.sv_config_err then
coordinator.log_comms("supervisor cooling configuration invalid, check supervisor config file")
elseif not self.sv_linked then
if self.last_est_ack == ESTABLISH_ACK.DENY then
coordinator.log_comms("supervisor connection attempt denied")
elseif self.last_est_ack == ESTABLISH_ACK.COLLISION then
coordinator.log_comms("supervisor connection failed due to collision")
elseif self.last_est_ack == ESTABLISH_ACK.BAD_VERSION then
coordinator.log_comms("supervisor connection failed due to version mismatch")
else
coordinator.log_comms("supervisor connection failed with no valid response")
end
end end
return self.sv_linked return self.sv_linked
end end
-- send a facility command
---@param cmd FAC_COMMAND command
function public.send_fac_command(cmd)
_send_sv(PROTOCOL.SCADA_CRDN, SCADA_CRDN_TYPE.FAC_CMD, { cmd })
end
-- send the auto process control configuration with a start command
---@param config coord_auto_config configuration
function public.send_auto_start(config)
_send_sv(PROTOCOL.SCADA_CRDN, SCADA_CRDN_TYPE.FAC_CMD, {
FAC_COMMAND.START, config.mode, config.burn_target, config.charge_target, config.gen_target, config.limits
})
end
-- send a unit command
---@param cmd UNIT_COMMAND command
---@param unit integer unit ID
---@param option any? optional option options for the optional options (like burn rate) (does option still look like a word?)
function public.send_unit_command(cmd, unit, option)
_send_sv(PROTOCOL.SCADA_CRDN, SCADA_CRDN_TYPE.UNIT_CMD, { cmd, unit, option })
end
-- parse a packet -- parse a packet
---@param side string ---@param side string
---@param sender integer ---@param sender integer
@@ -322,19 +388,19 @@ function coordinator.comms(version, modem, sv_port, sv_listen, api_listen, sv_wa
if s_pkt.is_valid() then if s_pkt.is_valid() then
-- get as SCADA management packet -- get as SCADA management packet
if s_pkt.protocol() == PROTOCOLS.SCADA_MGMT then if s_pkt.protocol() == PROTOCOL.SCADA_MGMT then
local mgmt_pkt = comms.mgmt_packet() local mgmt_pkt = comms.mgmt_packet()
if mgmt_pkt.decode(s_pkt) then if mgmt_pkt.decode(s_pkt) then
pkt = mgmt_pkt.get() pkt = mgmt_pkt.get()
end end
-- get as coordinator packet -- get as coordinator packet
elseif s_pkt.protocol() == PROTOCOLS.SCADA_CRDN then elseif s_pkt.protocol() == PROTOCOL.SCADA_CRDN then
local crdn_pkt = comms.crdn_packet() local crdn_pkt = comms.crdn_packet()
if crdn_pkt.decode(s_pkt) then if crdn_pkt.decode(s_pkt) then
pkt = crdn_pkt.get() pkt = crdn_pkt.get()
end end
-- get as coordinator API packet -- get as coordinator API packet
elseif s_pkt.protocol() == PROTOCOLS.COORD_API then elseif s_pkt.protocol() == PROTOCOL.COORD_API then
local capi_pkt = comms.capi_packet() local capi_pkt = comms.capi_packet()
if capi_pkt.decode(s_pkt) then if capi_pkt.decode(s_pkt) then
pkt = capi_pkt.get() pkt = capi_pkt.get()
@@ -348,14 +414,20 @@ function coordinator.comms(version, modem, sv_port, sv_listen, api_listen, sv_wa
end end
-- handle a packet -- handle a packet
---@param packet mgmt_frame|crdn_frame|capi_frame ---@param packet mgmt_frame|crdn_frame|capi_frame|nil
function public.handle_packet(packet) function public.handle_packet(packet)
if packet ~= nil then if packet ~= nil then
local protocol = packet.scada_frame.protocol() local protocol = packet.scada_frame.protocol()
local l_port = packet.scada_frame.local_port()
if protocol == PROTOCOLS.COORD_API then if l_port == api_listen then
apisessions.handle_packet(packet) if protocol == PROTOCOL.COORD_API then
else ---@cast packet capi_frame
apisessions.handle_packet(packet)
else
log.debug("illegal packet type " .. protocol .. " on api listening channel", true)
end
elseif l_port == sv_listen then
-- check sequence number -- check sequence number
if self.sv_r_seq_num == nil then if self.sv_r_seq_num == nil then
self.sv_r_seq_num = packet.scada_frame.seq_num() self.sv_r_seq_num = packet.scada_frame.seq_num()
@@ -370,87 +442,227 @@ function coordinator.comms(version, modem, sv_port, sv_listen, api_listen, sv_wa
sv_watchdog.feed() sv_watchdog.feed()
-- handle packet -- handle packet
if protocol == PROTOCOLS.SCADA_CRDN then if protocol == PROTOCOL.SCADA_CRDN then
if packet.type == SCADA_CRDN_TYPES.ESTABLISH then ---@cast packet crdn_frame
if self.sv_linked then
if packet.type == SCADA_CRDN_TYPE.INITIAL_BUILDS then
if packet.length == 2 then
-- record builds
local fac_builds = iocontrol.record_facility_builds(packet.data[1])
local unit_builds = iocontrol.record_unit_builds(packet.data[2])
if fac_builds and unit_builds then
-- acknowledge receipt of builds
_send_sv(PROTOCOL.SCADA_CRDN, SCADA_CRDN_TYPE.INITIAL_BUILDS, {})
else
log.debug("received invalid INITIAL_BUILDS packet")
end
else
log.debug("INITIAL_BUILDS packet length mismatch")
end
elseif packet.type == SCADA_CRDN_TYPE.FAC_BUILDS then
if packet.length == 1 then
-- record facility builds
if iocontrol.record_facility_builds(packet.data[1]) then
-- acknowledge receipt of builds
_send_sv(PROTOCOL.SCADA_CRDN, SCADA_CRDN_TYPE.FAC_BUILDS, {})
else
log.debug("received invalid FAC_BUILDS packet")
end
else
log.debug("FAC_BUILDS packet length mismatch")
end
elseif packet.type == SCADA_CRDN_TYPE.FAC_STATUS then
-- update facility status
if not iocontrol.update_facility_status(packet.data) then
log.debug("received invalid FAC_STATUS packet")
end
elseif packet.type == SCADA_CRDN_TYPE.FAC_CMD then
-- facility command acknowledgement
if packet.length >= 2 then
local cmd = packet.data[1]
local ack = packet.data[2] == true
if cmd == FAC_COMMAND.SCRAM_ALL then
iocontrol.get_db().facility.scram_ack(ack)
elseif cmd == FAC_COMMAND.STOP then
iocontrol.get_db().facility.stop_ack(ack)
elseif cmd == FAC_COMMAND.START then
if packet.length == 7 then
process.start_ack_handle({ table.unpack(packet.data, 2) })
else
log.debug("SCADA_CRDN process start (with configuration) ack echo packet length mismatch")
end
elseif cmd == FAC_COMMAND.ACK_ALL_ALARMS then
iocontrol.get_db().facility.ack_alarms_ack(ack)
else
log.debug(util.c("received facility command ack with unknown command ", cmd))
end
else
log.debug("SCADA_CRDN facility command ack packet length mismatch")
end
elseif packet.type == SCADA_CRDN_TYPE.UNIT_BUILDS then
-- record builds
if packet.length == 1 then
if iocontrol.record_unit_builds(packet.data[1]) then
-- acknowledge receipt of builds
_send_sv(PROTOCOL.SCADA_CRDN, SCADA_CRDN_TYPE.UNIT_BUILDS, {})
else
log.debug("received invalid UNIT_BUILDS packet")
end
else
log.debug("UNIT_BUILDS packet length mismatch")
end
elseif packet.type == SCADA_CRDN_TYPE.UNIT_STATUSES then
-- update statuses
if not iocontrol.update_unit_statuses(packet.data) then
log.error("received invalid UNIT_STATUSES packet")
end
elseif packet.type == SCADA_CRDN_TYPE.UNIT_CMD then
-- unit command acknowledgement
if packet.length == 3 then
local cmd = packet.data[1]
local unit_id = packet.data[2]
local ack = packet.data[3] == true
local unit = iocontrol.get_db().units[unit_id] ---@type ioctl_unit
if unit ~= nil then
if cmd == UNIT_COMMAND.SCRAM then
unit.scram_ack(ack)
elseif cmd == UNIT_COMMAND.START then
unit.start_ack(ack)
elseif cmd == UNIT_COMMAND.RESET_RPS then
unit.reset_rps_ack(ack)
elseif cmd == UNIT_COMMAND.SET_BURN then
unit.set_burn_ack(ack)
elseif cmd == UNIT_COMMAND.SET_WASTE then
unit.set_waste_ack(ack)
elseif cmd == UNIT_COMMAND.ACK_ALL_ALARMS then
unit.ack_alarms_ack(ack)
elseif cmd == UNIT_COMMAND.SET_GROUP then
-- UI will be updated to display current group if changed successfully
else
log.debug(util.c("received unit command ack with unknown command ", cmd))
end
else
log.debug(util.c("received unit command ack with unknown unit ", unit_id))
end
else
log.debug("SCADA_CRDN unit command ack packet length mismatch")
end
else
log.warning("received unknown SCADA_CRDN packet type " .. packet.type)
end
else
log.debug("discarding SCADA_CRDN packet before linked")
end
elseif protocol == PROTOCOL.SCADA_MGMT then
---@cast packet mgmt_frame
if packet.type == SCADA_MGMT_TYPE.ESTABLISH then
-- connection with supervisor established -- connection with supervisor established
if packet.length > 1 then if packet.length == 2 then
-- get configuration local est_ack = packet.data[1]
local config = packet.data[2]
---@class facility_conf if est_ack == ESTABLISH_ACK.ALLOW then
local conf = { if type(config) == "table" and #config > 1 then
num_units = packet.data[1], -- get configuration
defs = {} -- boilers and turbines
}
if (packet.length - 1) == (conf.num_units * 2) then ---@class facility_conf
-- record sequence of pairs of [#boilers, #turbines] per unit local conf = {
for i = 2, packet.length do num_units = config[1], ---@type integer
table.insert(conf.defs, packet.data[i]) defs = {} -- boilers and turbines
}
if (#config - 1) == (conf.num_units * 2) then
-- record sequence of pairs of [#boilers, #turbines] per unit
for i = 2, #config do
table.insert(conf.defs, config[i])
end
-- init io controller
iocontrol.init(conf, public)
self.sv_linked = true
self.sv_config_err = false
else
self.sv_config_err = true
log.warning("invalid supervisor configuration definitions received, establish failed")
end
else
log.debug("invalid supervisor configuration table received, establish failed")
end
else
log.debug("SCADA_MGMT establish packet reply (len = 2) unsupported")
end
self.last_est_ack = est_ack
elseif packet.length == 1 then
local est_ack = packet.data[1]
if est_ack == ESTABLISH_ACK.DENY then
if self.last_est_ack ~= est_ack then
log.info("supervisor connection denied")
end
elseif est_ack == ESTABLISH_ACK.COLLISION then
if self.last_est_ack ~= est_ack then
log.info("supervisor connection denied due to collision")
end
elseif est_ack == ESTABLISH_ACK.BAD_VERSION then
if self.last_est_ack ~= est_ack then
log.info("supervisor comms version mismatch")
end
else
log.debug("SCADA_MGMT establish packet reply (len = 1) unsupported")
end
self.last_est_ack = est_ack
else
log.debug("SCADA_MGMT establish packet length mismatch")
end
elseif self.sv_linked then
if packet.type == SCADA_MGMT_TYPE.KEEP_ALIVE then
-- keep alive request received, echo back
if packet.length == 1 then
local timestamp = packet.data[1]
local trip_time = util.time() - timestamp
if trip_time > 750 then
log.warning("coord KEEP_ALIVE trip time > 750ms (" .. trip_time .. "ms)")
end end
-- init io controller -- log.debug("coord RTT = " .. trip_time .. "ms")
iocontrol.init(conf)
self.sv_linked = true iocontrol.get_db().facility.ps.publish("sv_ping", trip_time)
_send_keep_alive_ack(timestamp)
else else
log.debug("supervisor conn establish packet length mismatch") log.debug("SCADA keep alive packet length mismatch")
end end
elseif packet.type == SCADA_MGMT_TYPE.CLOSE then
-- handle session close
sv_watchdog.cancel()
self.sv_linked = false
println_ts("server connection closed by remote host")
log.info("server connection closed by remote host")
else else
log.debug("supervisor conn establish packet length mismatch") log.debug("received unknown SCADA_MGMT packet type " .. packet.type)
end end
elseif packet.type == SCADA_CRDN_TYPES.STRUCT_BUILDS then
-- record builds
if iocontrol.record_builds(packet.data) then
-- acknowledge receipt of builds
_send_sv(PROTOCOLS.SCADA_CRDN, SCADA_CRDN_TYPES.STRUCT_BUILDS, {})
else
log.error("received invalid build packet")
end
elseif packet.type == SCADA_CRDN_TYPES.UNIT_STATUSES then
-- update statuses
if not iocontrol.update_statuses(packet.data) then
log.error("received invalid unit statuses packet")
end
elseif packet.type == SCADA_CRDN_TYPES.COMMAND_UNIT then
elseif packet.type == SCADA_CRDN_TYPES.ALARM then
else else
log.warning("received unknown SCADA_CRDN packet type " .. packet.type) log.debug("discarding non-link SCADA_MGMT packet before linked")
end
elseif protocol == PROTOCOLS.SCADA_MGMT then
if packet.type == SCADA_MGMT_TYPES.KEEP_ALIVE then
-- keep alive request received, echo back
if packet.length == 1 then
local timestamp = packet.data[1]
local trip_time = util.time() - timestamp
if trip_time > 500 then
log.warning("coord KEEP_ALIVE trip time > 500ms (" .. trip_time .. "ms)")
end
-- log.debug("coord RTT = " .. trip_time .. "ms")
_send_keep_alive_ack(timestamp)
else
log.debug("SCADA keep alive packet length mismatch")
end
elseif packet.type == SCADA_MGMT_TYPES.CLOSE then
-- handle session close
sv_watchdog.cancel()
self.sv_linked = false
println_ts("server connection closed by remote host")
log.warning("server connection closed by remote host")
else
log.warning("received unknown SCADA_MGMT packet type " .. packet.type)
end end
else else
-- should be unreachable assuming packet is from parse_packet() log.debug("illegal packet type " .. protocol .. " on supervisor listening channel", true)
log.error("illegal packet type " .. protocol, true)
end end
else
log.debug("received packet on unconfigured channel " .. l_port, true)
end end
end end
end end
-- check if the coordinator is still linked to the supervisor -- check if the coordinator is still linked to the supervisor
---@nodiscard
function public.is_linked() return self.sv_linked end function public.is_linked() return self.sv_linked end
return public return public

View File

@@ -1,5 +1,17 @@
local psil = require("scada-common.psil") --
local log = require("scada-common.log") -- I/O Control for Supervisor/Coordinator Integration
--
local log = require("scada-common.log")
local psil = require("scada-common.psil")
local types = require("scada-common.types")
local util = require("scada-common.util")
local process = require("coordinator.process")
local sounder = require("coordinator.sounder")
local ALARM_STATE = types.ALARM_STATE
local PROCESS = types.PROCESS
local iocontrol = {} local iocontrol = {}
@@ -8,19 +20,62 @@ local io = {}
-- initialize the coordinator IO controller -- initialize the coordinator IO controller
---@param conf facility_conf configuration ---@param conf facility_conf configuration
function iocontrol.init(conf) ---@param comms coord_comms comms reference
function iocontrol.init(conf, comms)
---@class ioctl_facility
io.facility = { io.facility = {
scram = false, num_units = conf.num_units, ---@type integer
num_units = conf.num_units, all_sys_ok = false,
ps = psil.create() rtu_count = 0,
auto_ready = false,
auto_active = false,
auto_ramping = false,
auto_saturated = false,
auto_scram = false,
---@type ascram_status
ascram_status = {
matrix_dc = false,
matrix_fill = false,
crit_alarm = false,
radiation = false,
gen_fault = false
},
radiation = types.new_zero_radiation_reading(),
save_cfg_ack = function (success) end, ---@param success boolean
start_ack = function (success) end, ---@param success boolean
stop_ack = function (success) end, ---@param success boolean
scram_ack = function (success) end, ---@param success boolean
ack_alarms_ack = function (success) end, ---@param success boolean
ps = psil.create(),
induction_ps_tbl = {},
induction_data_tbl = {},
env_d_ps = psil.create(),
env_d_data = {}
} }
-- create induction tables (currently only 1 is supported)
for _ = 1, conf.num_units do
local data = {} ---@type imatrix_session_db
table.insert(io.facility.induction_ps_tbl, psil.create())
table.insert(io.facility.induction_data_tbl, data)
end
io.units = {} io.units = {}
for i = 1, conf.num_units do for i = 1, conf.num_units do
---@class ioctl_entry local function ack(alarm) process.ack_alarm(i, alarm) end
local function reset(alarm) process.reset_alarm(i, alarm) end
---@class ioctl_unit
local entry = { local entry = {
unit_id = i, ---@type integer ---@type integer
initialized = false, unit_id = i,
num_boilers = 0, num_boilers = 0,
num_turbines = 0, num_turbines = 0,
@@ -28,14 +83,61 @@ function iocontrol.init(conf)
control_state = false, control_state = false,
burn_rate_cmd = 0.0, burn_rate_cmd = 0.0,
waste_control = 0, waste_control = 0,
radiation = types.new_zero_radiation_reading(),
---@fixme debug stubs to be linked into comms later? a_group = 0, -- auto control group
start = function () print("UNIT " .. i .. ": start") end,
scram = function () print("UNIT " .. i .. ": SCRAM") end,
set_burn = function (rate) print("UNIT " .. i .. ": set burn rate to " .. rate) end,
reactor_ps = psil.create(), start = function () process.start(i) end,
reactor_data = {}, ---@type reactor_db scram = function () process.scram(i) end,
reset_rps = function () process.reset_rps(i) end,
ack_alarms = function () process.ack_all_alarms(i) end,
set_burn = function (rate) process.set_rate(i, rate) end, ---@param rate number burn rate
set_waste = function (mode) process.set_waste(i, mode) end, ---@param mode integer waste processing mode
set_group = function (grp) process.set_group(i, grp) end, ---@param grp integer|0 group ID or 0
start_ack = function (success) end, ---@param success boolean
scram_ack = function (success) end, ---@param success boolean
reset_rps_ack = function (success) end, ---@param success boolean
ack_alarms_ack = function (success) end, ---@param success boolean
set_burn_ack = function (success) end, ---@param success boolean
set_waste_ack = function (success) end, ---@param success boolean
alarm_callbacks = {
c_breach = { ack = function () ack(1) end, reset = function () reset(1) end },
radiation = { ack = function () ack(2) end, reset = function () reset(2) end },
r_lost = { ack = function () ack(3) end, reset = function () reset(3) end },
dmg_crit = { ack = function () ack(4) end, reset = function () reset(4) end },
damage = { ack = function () ack(5) end, reset = function () reset(5) end },
over_temp = { ack = function () ack(6) end, reset = function () reset(6) end },
high_temp = { ack = function () ack(7) end, reset = function () reset(7) end },
waste_leak = { ack = function () ack(8) end, reset = function () reset(8) end },
waste_high = { ack = function () ack(9) end, reset = function () reset(9) end },
rps_trans = { ack = function () ack(10) end, reset = function () reset(10) end },
rcs_trans = { ack = function () ack(11) end, reset = function () reset(11) end },
t_trip = { ack = function () ack(12) end, reset = function () reset(12) end }
},
---@type alarms
alarms = {
ALARM_STATE.INACTIVE, -- containment breach
ALARM_STATE.INACTIVE, -- containment radiation
ALARM_STATE.INACTIVE, -- reactor lost
ALARM_STATE.INACTIVE, -- damage critical
ALARM_STATE.INACTIVE, -- reactor taking damage
ALARM_STATE.INACTIVE, -- reactor over temperature
ALARM_STATE.INACTIVE, -- reactor high temperature
ALARM_STATE.INACTIVE, -- waste leak
ALARM_STATE.INACTIVE, -- waste level high
ALARM_STATE.INACTIVE, -- RPS transient
ALARM_STATE.INACTIVE, -- RCS transient
ALARM_STATE.INACTIVE -- turbine trip
},
annunciator = {}, ---@type annunciator
unit_ps = psil.create(),
reactor_data = {}, ---@type reactor_db
boiler_ps_tbl = {}, boiler_ps_tbl = {},
boiler_data_tbl = {}, boiler_data_tbl = {},
@@ -44,14 +146,16 @@ function iocontrol.init(conf)
turbine_data_tbl = {} turbine_data_tbl = {}
} }
-- create boiler tables
for _ = 1, conf.defs[(i * 2) - 1] do for _ = 1, conf.defs[(i * 2) - 1] do
local data = {} ---@type boiler_session_db|boilerv_session_db local data = {} ---@type boilerv_session_db
table.insert(entry.boiler_ps_tbl, psil.create()) table.insert(entry.boiler_ps_tbl, psil.create())
table.insert(entry.boiler_data_tbl, data) table.insert(entry.boiler_data_tbl, data)
end end
-- create turbine tables
for _ = 1, conf.defs[i * 2] do for _ = 1, conf.defs[i * 2] do
local data = {} ---@type turbine_session_db|turbinev_session_db local data = {} ---@type turbinev_session_db
table.insert(entry.turbine_ps_tbl, psil.create()) table.insert(entry.turbine_ps_tbl, psil.create())
table.insert(entry.turbine_data_tbl, data) table.insert(entry.turbine_data_tbl, data)
end end
@@ -61,222 +165,605 @@ function iocontrol.init(conf)
table.insert(io.units, entry) table.insert(io.units, entry)
end end
-- pass IO control here since it can't be require'd due to a require loop
process.init(io, comms)
end end
-- populate structure builds -- populate facility structure builds
---@param build table
---@return boolean valid
function iocontrol.record_facility_builds(build)
local valid = true
if type(build) == "table" then
local fac = io.facility
-- induction matricies
if type(build.induction) == "table" then
for id, matrix in pairs(build.induction) do
if type(fac.induction_data_tbl[id]) == "table" then
fac.induction_data_tbl[id].formed = matrix[1] ---@type boolean
fac.induction_data_tbl[id].build = matrix[2] ---@type table
fac.induction_ps_tbl[id].publish("formed", matrix[1])
for key, val in pairs(fac.induction_data_tbl[id].build) do
fac.induction_ps_tbl[id].publish(key, val)
end
else
log.debug(util.c("iocontrol.record_facility_builds: invalid induction matrix id ", id))
valid = false
end
end
end
else
log.debug("facility builds not a table")
valid = false
end
return valid
end
-- populate unit structure builds
---@param builds table ---@param builds table
---@return boolean valid ---@return boolean valid
function iocontrol.record_builds(builds) function iocontrol.record_unit_builds(builds)
if #builds ~= #io.units then local valid = true
log.error("number of provided unit builds does not match expected number of units")
return false
else
for i = 1, #builds do
local unit = io.units[i] ---@type ioctl_entry
local build = builds[i]
-- note: if not all units and RTUs are connected, some will be nil
for id, build in pairs(builds) do
local unit = io.units[id] ---@type ioctl_unit
local log_header = util.c("iocontrol.record_unit_builds[UNIT ", id, "]: ")
if type(build) ~= "table" then
log.debug(log_header .. "build not a table")
valid = false
elseif type(unit) ~= "table" then
log.debug(log_header .. "invalid unit id")
valid = false
else
-- reactor build -- reactor build
unit.reactor_data.mek_struct = build.reactor if type(build.reactor) == "table" then
for key, val in pairs(unit.reactor_data.mek_struct) do unit.reactor_data.mek_struct = build.reactor ---@type mek_struct
unit.reactor_ps.publish(key, val) for key, val in pairs(unit.reactor_data.mek_struct) do
unit.unit_ps.publish(key, val)
end
if (type(unit.reactor_data.mek_struct.length) == "number") and (unit.reactor_data.mek_struct.length ~= 0) and
(type(unit.reactor_data.mek_struct.width) == "number") and (unit.reactor_data.mek_struct.width ~= 0) then
unit.unit_ps.publish("size", { unit.reactor_data.mek_struct.length, unit.reactor_data.mek_struct.width })
end
end end
-- boiler builds -- boiler builds
for id, boiler in pairs(build.boilers) do if type(build.boilers) == "table" then
unit.boiler_data_tbl[id] = { for b_id, boiler in pairs(build.boilers) do
formed = boiler[2], ---@type boolean|nil if type(unit.boiler_data_tbl[b_id]) == "table" then
build = boiler[1] ---@type table unit.boiler_data_tbl[b_id].formed = boiler[1] ---@type boolean
} unit.boiler_data_tbl[b_id].build = boiler[2] ---@type table
unit.boiler_ps_tbl[id].publish("formed", boiler[2]) unit.boiler_ps_tbl[b_id].publish("formed", boiler[1])
for key, val in pairs(unit.boiler_data_tbl[id].build) do for key, val in pairs(unit.boiler_data_tbl[b_id].build) do
unit.boiler_ps_tbl[id].publish(key, val) unit.boiler_ps_tbl[b_id].publish(key, val)
end
else
log.debug(util.c(log_header, "invalid boiler id ", b_id))
valid = false
end
end end
end end
-- turbine builds -- turbine builds
for id, turbine in pairs(build.turbines) do if type(build.turbines) == "table" then
unit.turbine_data_tbl[id] = { for t_id, turbine in pairs(build.turbines) do
formed = turbine[2], ---@type boolean|nil if type(unit.turbine_data_tbl[t_id]) == "table" then
build = turbine[1] ---@type table unit.turbine_data_tbl[t_id].formed = turbine[1] ---@type boolean
} unit.turbine_data_tbl[t_id].build = turbine[2] ---@type table
unit.turbine_ps_tbl[id].publish("formed", turbine[2]) unit.turbine_ps_tbl[t_id].publish("formed", turbine[1])
for key, val in pairs(unit.turbine_data_tbl[id].build) do for key, val in pairs(unit.turbine_data_tbl[t_id].build) do
unit.turbine_ps_tbl[id].publish(key, val) unit.turbine_ps_tbl[t_id].publish(key, val)
end
else
log.debug(util.c(log_header, "invalid turbine id ", t_id))
valid = false
end
end end
end end
end end
end end
return true return valid
end
-- update facility status
---@param status table
---@return boolean valid
function iocontrol.update_facility_status(status)
local valid = true
local log_header = util.c("iocontrol.update_facility_status: ")
if type(status) ~= "table" then
log.debug(util.c(log_header, "status not a table"))
valid = false
else
local fac = io.facility
-- auto control status information
local ctl_status = status[1]
if type(ctl_status) == "table" and #ctl_status == 14 then
fac.all_sys_ok = ctl_status[1]
fac.auto_ready = ctl_status[2]
if type(ctl_status[3]) == "number" then
fac.auto_active = ctl_status[3] > PROCESS.INACTIVE
else
fac.auto_active = false
valid = false
end
fac.auto_ramping = ctl_status[4]
fac.auto_saturated = ctl_status[5]
fac.auto_scram = ctl_status[6]
fac.ascram_status.matrix_dc = ctl_status[7]
fac.ascram_status.matrix_fill = ctl_status[8]
fac.ascram_status.crit_alarm = ctl_status[9]
fac.ascram_status.radiation = ctl_status[10]
fac.ascram_status.gen_fault = ctl_status[11]
fac.status_line_1 = ctl_status[12]
fac.status_line_2 = ctl_status[13]
fac.ps.publish("all_sys_ok", fac.all_sys_ok)
fac.ps.publish("auto_ready", fac.auto_ready)
fac.ps.publish("auto_active", fac.auto_active)
fac.ps.publish("auto_ramping", fac.auto_ramping)
fac.ps.publish("auto_saturated", fac.auto_saturated)
fac.ps.publish("auto_scram", fac.auto_scram)
fac.ps.publish("as_matrix_dc", fac.ascram_status.matrix_dc)
fac.ps.publish("as_matrix_fill", fac.ascram_status.matrix_fill)
fac.ps.publish("as_crit_alarm", fac.ascram_status.crit_alarm)
fac.ps.publish("as_radiation", fac.ascram_status.radiation)
fac.ps.publish("as_gen_fault", fac.ascram_status.gen_fault)
fac.ps.publish("status_line_1", fac.status_line_1)
fac.ps.publish("status_line_2", fac.status_line_2)
local group_map = ctl_status[14]
if (type(group_map) == "table") and (#group_map == fac.num_units) then
local names = { "Manual", "Primary", "Secondary", "Tertiary", "Backup" }
for i = 1, #group_map do
io.units[i].a_group = group_map[i]
io.units[i].unit_ps.publish("auto_group_id", group_map[i])
io.units[i].unit_ps.publish("auto_group", names[group_map[i] + 1])
end
end
else
log.debug(log_header .. "control status not a table or length mismatch")
valid = false
end
-- RTU statuses
local rtu_statuses = status[2]
fac.rtu_count = 0
if type(rtu_statuses) == "table" then
-- connected RTU count
fac.rtu_count = rtu_statuses.count
-- power statistics
if type(rtu_statuses.power) == "table" then
fac.induction_ps_tbl[1].publish("avg_charge", rtu_statuses.power[1])
fac.induction_ps_tbl[1].publish("avg_inflow", rtu_statuses.power[2])
fac.induction_ps_tbl[1].publish("avg_outflow", rtu_statuses.power[3])
else
log.debug(log_header .. "power statistics list not a table")
valid = false
end
-- induction matricies statuses
if type(rtu_statuses.induction) == "table" then
for id = 1, #fac.induction_ps_tbl do
if rtu_statuses.induction[id] == nil then
-- disconnected
fac.induction_ps_tbl[id].publish("computed_status", 1)
end
end
for id, matrix in pairs(rtu_statuses.induction) do
if type(fac.induction_data_tbl[id]) == "table" then
local rtu_faulted = matrix[1] ---@type boolean
fac.induction_data_tbl[id].formed = matrix[2] ---@type boolean
fac.induction_data_tbl[id].state = matrix[3] ---@type table
fac.induction_data_tbl[id].tanks = matrix[4] ---@type table
local data = fac.induction_data_tbl[id] ---@type imatrix_session_db
fac.induction_ps_tbl[id].publish("formed", data.formed)
fac.induction_ps_tbl[id].publish("faulted", rtu_faulted)
if data.formed then
if rtu_faulted then
fac.induction_ps_tbl[id].publish("computed_status", 3) -- faulted
elseif data.tanks.energy_fill >= 0.99 then
fac.induction_ps_tbl[id].publish("computed_status", 6) -- full
elseif data.tanks.energy_fill <= 0.01 then
fac.induction_ps_tbl[id].publish("computed_status", 5) -- empty
else
fac.induction_ps_tbl[id].publish("computed_status", 4) -- on-line
end
else
fac.induction_ps_tbl[id].publish("computed_status", 2) -- not formed
end
for key, val in pairs(fac.induction_data_tbl[id].state) do
fac.induction_ps_tbl[id].publish(key, val)
end
for key, val in pairs(fac.induction_data_tbl[id].tanks) do
fac.induction_ps_tbl[id].publish(key, val)
end
else
log.debug(util.c(log_header, "invalid induction matrix id ", id))
end
end
else
log.debug(log_header .. "induction matrix list not a table")
valid = false
end
-- environment detector status
if type(rtu_statuses.rad_mon) == "table" then
if #rtu_statuses.rad_mon > 0 then
local rad_mon = rtu_statuses.rad_mon[1]
local rtu_faulted = rad_mon[1] ---@type boolean
fac.radiation = rad_mon[2] ---@type number
fac.ps.publish("rad_computed_status", util.trinary(rtu_faulted, 2, 3))
fac.ps.publish("radiation", fac.radiation)
else
fac.radiation = types.new_zero_radiation_reading()
fac.ps.publish("rad_computed_status", 1)
end
else
log.debug(log_header .. "radiation monitor list not a table")
valid = false
end
else
log.debug(log_header .. "rtu statuses not a table")
valid = false
end
fac.ps.publish("rtu_count", fac.rtu_count)
end
return valid
end end
-- update unit statuses -- update unit statuses
---@param statuses table ---@param statuses table
---@return boolean valid ---@return boolean valid
function iocontrol.update_statuses(statuses) function iocontrol.update_unit_statuses(statuses)
if #statuses ~= #io.units then local valid = true
log.error("number of provided unit statuses does not match expected number of units")
return false if type(statuses) ~= "table" then
log.debug("iocontrol.update_unit_statuses: unit statuses not a table")
valid = false
elseif #statuses ~= #io.units then
log.debug("iocontrol.update_unit_statuses: number of provided unit statuses does not match expected number of units")
valid = false
else else
local burn_rate_sum = 0.0
-- get all unit statuses
for i = 1, #statuses do for i = 1, #statuses do
local unit = io.units[i] ---@type ioctl_entry local log_header = util.c("iocontrol.update_unit_statuses[unit ", i, "]: ")
local unit = io.units[i] ---@type ioctl_unit
local status = statuses[i] local status = statuses[i]
-- reactor PLC status if type(status) ~= "table" or #status ~= 5 then
log.debug(log_header .. "invalid status entry in unit statuses (not a table or invalid length)")
local reactor_status = status[1] valid = false
if #reactor_status == 0 then
unit.reactor_ps.publish("computed_status", 1) -- disconnected
else else
local mek_status = reactor_status[1] -- reactor PLC status
local rps_status = reactor_status[2] local reactor_status = status[1]
local gen_status = reactor_status[3]
unit.reactor_data.last_status_update = gen_status[1] if type(reactor_status) ~= "table" then
unit.reactor_data.control_state = gen_status[2] reactor_status = {}
unit.reactor_data.rps_tripped = gen_status[3] log.debug(log_header .. "reactor status not a table")
unit.reactor_data.rps_trip_cause = gen_status[4] end
unit.reactor_data.degraded = gen_status[5]
unit.reactor_data.rps_status = rps_status ---@type rps_status if #reactor_status == 0 then
unit.reactor_data.mek_status = mek_status ---@type mek_status unit.unit_ps.publish("computed_status", 1) -- disconnected
elseif #reactor_status == 3 then
local mek_status = reactor_status[1]
local rps_status = reactor_status[2]
local gen_status = reactor_status[3]
if unit.reactor_data.mek_status.status then if #gen_status == 6 then
unit.reactor_ps.publish("computed_status", 3) -- running unit.reactor_data.last_status_update = gen_status[1]
else unit.reactor_data.control_state = gen_status[2]
if unit.reactor_data.degraded then unit.reactor_data.rps_tripped = gen_status[3]
unit.reactor_ps.publish("computed_status", 5) -- faulted unit.reactor_data.rps_trip_cause = gen_status[4]
elseif unit.reactor_data.rps_tripped and unit.reactor_data.rps_trip_cause ~= "manual" then unit.reactor_data.no_reactor = gen_status[5]
unit.reactor_ps.publish("computed_status", 4) -- SCRAM unit.reactor_data.formed = gen_status[6]
else else
unit.reactor_ps.publish("computed_status", 2) -- disabled log.debug(log_header .. "reactor general status length mismatch")
end
end
for key, val in pairs(unit.reactor_data) do
if key ~= "rps_status" and key ~= "mek_struct" and key ~= "mek_status" then
unit.reactor_ps.publish(key, val)
end
end
for key, val in pairs(unit.reactor_data.rps_status) do
unit.reactor_ps.publish(key, val)
end
for key, val in pairs(unit.reactor_data.mek_status) do
unit.reactor_ps.publish(key, val)
end
end
-- annunciator
local annunciator = status[2] ---@type annunciator
for key, val in pairs(annunciator) do
if key == "TurbineTrip" then
-- split up turbine trip table for all turbines and a general OR combination
local trips = val
local any = false
for id = 1, #trips do
any = any or trips[id]
unit.turbine_ps_tbl[id].publish(key, trips[id])
end end
unit.reactor_ps.publish("TurbineTrip", any) unit.reactor_data.rps_status = rps_status ---@type rps_status
elseif key == "BoilerOnline" or key == "HeatingRateLow" then unit.reactor_data.mek_status = mek_status ---@type mek_status
-- split up array for all boilers
for id = 1, #val do -- if status hasn't been received, mek_status = {}
unit.boiler_ps_tbl[id].publish(key, val[id]) if type(unit.reactor_data.mek_status.act_burn_rate) == "number" then
burn_rate_sum = burn_rate_sum + unit.reactor_data.mek_status.act_burn_rate
end end
elseif key == "TurbineOnline" or key == "SteamDumpOpen" or key == "TurbineOverSpeed" then
-- split up array for all turbines if unit.reactor_data.mek_status.status then
for id = 1, #val do unit.unit_ps.publish("computed_status", 5) -- running
unit.turbine_ps_tbl[id].publish(key, val[id]) else
if unit.reactor_data.no_reactor then
unit.unit_ps.publish("computed_status", 3) -- faulted
elseif not unit.reactor_data.formed then
unit.unit_ps.publish("computed_status", 2) -- multiblock not formed
elseif unit.reactor_data.rps_status.force_dis then
unit.unit_ps.publish("computed_status", 7) -- reactor force disabled
elseif unit.reactor_data.rps_tripped and unit.reactor_data.rps_trip_cause ~= "manual" then
unit.unit_ps.publish("computed_status", 6) -- SCRAM
else
unit.unit_ps.publish("computed_status", 4) -- disabled
end
end
for key, val in pairs(unit.reactor_data) do
if key ~= "rps_status" and key ~= "mek_struct" and key ~= "mek_status" then
unit.unit_ps.publish(key, val)
end
end
if type(unit.reactor_data.rps_status) == "table" then
for key, val in pairs(unit.reactor_data.rps_status) do
unit.unit_ps.publish(key, val)
end
end
if type(unit.reactor_data.mek_status) == "table" then
for key, val in pairs(unit.reactor_data.mek_status) do
unit.unit_ps.publish(key, val)
end
end end
elseif type(val) == "table" then
-- we missed one of the tables?
log.error("unrecognized table found in annunciator list, this is a bug", true)
else else
-- non-table fields log.debug(log_header .. "reactor status length mismatch")
unit.reactor_ps.publish(key, val) valid = false
end end
end
-- RTU statuses -- RTU statuses
local rtu_statuses = status[2]
local rtu_statuses = status[3] if type(rtu_statuses) == "table" then
-- boiler statuses
if type(rtu_statuses.boilers) == "table" then
for id = 1, #unit.boiler_ps_tbl do
if rtu_statuses.boilers[i] == nil then
-- disconnected
unit.boiler_ps_tbl[id].publish("computed_status", 1)
end
end
-- boiler statuses for id, boiler in pairs(rtu_statuses.boilers) do
if type(unit.boiler_data_tbl[id]) == "table" then
local rtu_faulted = boiler[1] ---@type boolean
unit.boiler_data_tbl[id].formed = boiler[2] ---@type boolean
unit.boiler_data_tbl[id].state = boiler[3] ---@type table
unit.boiler_data_tbl[id].tanks = boiler[4] ---@type table
for id = 1, #unit.boiler_data_tbl do local data = unit.boiler_data_tbl[id] ---@type boilerv_session_db
if rtu_statuses.boilers[i] == nil then
-- disconnected
unit.boiler_ps_tbl[id].publish("computed_status", 1)
end
end
for id, boiler in pairs(rtu_statuses.boilers) do unit.boiler_ps_tbl[id].publish("formed", data.formed)
unit.boiler_data_tbl[id].state = boiler[1] ---@type table unit.boiler_ps_tbl[id].publish("faulted", rtu_faulted)
unit.boiler_data_tbl[id].tanks = boiler[2] ---@type table
local data = unit.boiler_data_tbl[id] ---@type boiler_session_db|boilerv_session_db if rtu_faulted then
unit.boiler_ps_tbl[id].publish("computed_status", 3) -- faulted
elseif data.formed then
if data.state.boil_rate > 0 then
unit.boiler_ps_tbl[id].publish("computed_status", 5) -- active
else
unit.boiler_ps_tbl[id].publish("computed_status", 4) -- idle
end
else
unit.boiler_ps_tbl[id].publish("computed_status", 2) -- not formed
end
if data.state.boil_rate > 0 then for key, val in pairs(unit.boiler_data_tbl[id].state) do
unit.boiler_ps_tbl[id].publish("computed_status", 3) -- active unit.boiler_ps_tbl[id].publish(key, val)
end
for key, val in pairs(unit.boiler_data_tbl[id].tanks) do
unit.boiler_ps_tbl[id].publish(key, val)
end
else
log.debug(util.c(log_header, "invalid boiler id ", id))
valid = false
end
end
else
log.debug(log_header .. "boiler list not a table")
valid = false
end
-- turbine statuses
if type(rtu_statuses.turbines) == "table" then
for id = 1, #unit.turbine_ps_tbl do
if rtu_statuses.turbines[i] == nil then
-- disconnected
unit.turbine_ps_tbl[id].publish("computed_status", 1)
end
end
for id, turbine in pairs(rtu_statuses.turbines) do
if type(unit.turbine_data_tbl[id]) == "table" then
local rtu_faulted = turbine[1] ---@type boolean
unit.turbine_data_tbl[id].formed = turbine[2] ---@type boolean
unit.turbine_data_tbl[id].state = turbine[3] ---@type table
unit.turbine_data_tbl[id].tanks = turbine[4] ---@type table
local data = unit.turbine_data_tbl[id] ---@type turbinev_session_db
unit.turbine_ps_tbl[id].publish("formed", data.formed)
unit.turbine_ps_tbl[id].publish("faulted", rtu_faulted)
if rtu_faulted then
unit.turbine_ps_tbl[id].publish("computed_status", 3) -- faulted
elseif data.formed then
if data.tanks.energy_fill >= 0.99 then
unit.turbine_ps_tbl[id].publish("computed_status", 6) -- trip
elseif data.state.flow_rate < 100 then
unit.turbine_ps_tbl[id].publish("computed_status", 4) -- idle
else
unit.turbine_ps_tbl[id].publish("computed_status", 5) -- active
end
else
unit.turbine_ps_tbl[id].publish("computed_status", 2) -- not formed
end
for key, val in pairs(unit.turbine_data_tbl[id].state) do
unit.turbine_ps_tbl[id].publish(key, val)
end
for key, val in pairs(unit.turbine_data_tbl[id].tanks) do
unit.turbine_ps_tbl[id].publish(key, val)
end
else
log.debug(util.c(log_header, "invalid turbine id ", id))
valid = false
end
end
else
log.debug(log_header .. "turbine list not a table")
valid = false
end
-- environment detector status
if type(rtu_statuses.rad_mon) == "table" then
if #rtu_statuses.rad_mon > 0 then
local rad_mon = rtu_statuses.rad_mon[1]
local rtu_faulted = rad_mon[1] ---@type boolean
unit.radiation = rad_mon[2] ---@type number
unit.unit_ps.publish("radiation", unit.radiation)
else
unit.radiation = types.new_zero_radiation_reading()
end
else
log.debug(log_header .. "radiation monitor list not a table")
valid = false
end
else else
unit.boiler_ps_tbl[id].publish("computed_status", 2) -- idle log.debug(log_header .. "rtu list not a table")
valid = false
end end
for key, val in pairs(unit.boiler_data_tbl[id].state) do -- annunciator
unit.boiler_ps_tbl[id].publish(key, val) unit.annunciator = status[3]
if type(unit.annunciator) ~= "table" then
unit.annunciator = {}
log.debug(log_header .. "annunciator state not a table")
valid = false
end end
for key, val in pairs(unit.boiler_data_tbl[id].tanks) do for key, val in pairs(unit.annunciator) do
unit.boiler_ps_tbl[id].publish(key, val) if key == "TurbineTrip" then
-- split up turbine trip table for all turbines and a general OR combination
local trips = val
local any = false
for id = 1, #trips do
any = any or trips[id]
unit.turbine_ps_tbl[id].publish(key, trips[id])
end
unit.unit_ps.publish("TurbineTrip", any)
elseif key == "BoilerOnline" or key == "HeatingRateLow" or key == "WaterLevelLow" then
-- split up array for all boilers
for id = 1, #val do
unit.boiler_ps_tbl[id].publish(key, val[id])
end
elseif key == "TurbineOnline" or key == "SteamDumpOpen" or key == "TurbineOverSpeed" then
-- split up array for all turbines
for id = 1, #val do
unit.turbine_ps_tbl[id].publish(key, val[id])
end
elseif type(val) == "table" then
-- we missed one of the tables?
log.debug(log_header .. "unrecognized table found in annunciator list, this is a bug")
valid = false
else
-- non-table fields
unit.unit_ps.publish(key, val)
end
end end
end
-- turbine statuses -- alarms
local alarm_states = status[4]
for id = 1, #unit.turbine_ps_tbl do if type(alarm_states) == "table" then
if rtu_statuses.turbines[i] == nil then for id = 1, #alarm_states do
-- disconnected local state = alarm_states[id]
unit.turbine_ps_tbl[id].publish("computed_status", 1)
end
end
for id, turbine in pairs(rtu_statuses.turbines) do unit.alarms[id] = state
unit.turbine_data_tbl[id].state = turbine[1] ---@type table
unit.turbine_data_tbl[id].tanks = turbine[2] ---@type table
local data = unit.turbine_data_tbl[id] ---@type turbine_session_db|turbinev_session_db if state == types.ALARM_STATE.TRIPPED or state == types.ALARM_STATE.ACKED then
unit.unit_ps.publish("Alarm_" .. id, 2)
if data.tanks.steam_fill >= 0.99 then elseif state == types.ALARM_STATE.RING_BACK then
unit.turbine_ps_tbl[id].publish("computed_status", 4) -- trip unit.unit_ps.publish("Alarm_" .. id, 3)
elseif data.state.flow_rate < 100 then else
unit.turbine_ps_tbl[id].publish("computed_status", 2) -- idle unit.unit_ps.publish("Alarm_" .. id, 1)
end
end
else else
unit.turbine_ps_tbl[id].publish("computed_status", 3) -- active log.debug(log_header .. "alarm states not a table")
valid = false
end end
for key, val in pairs(unit.turbine_data_tbl[id].state) do -- unit state fields
unit.turbine_ps_tbl[id].publish(key, val) local unit_state = status[5]
end
for key, val in pairs(unit.turbine_data_tbl[id].tanks) do if type(unit_state) == "table" then
unit.turbine_ps_tbl[id].publish(key, val) if #unit_state == 5 then
unit.unit_ps.publish("U_StatusLine1", unit_state[1])
unit.unit_ps.publish("U_StatusLine2", unit_state[2])
unit.unit_ps.publish("U_WasteMode", unit_state[3])
unit.unit_ps.publish("U_AutoReady", unit_state[4])
unit.unit_ps.publish("U_AutoDegraded", unit_state[5])
else
log.debug(log_header .. "unit state length mismatch")
valid = false
end
else
log.debug(log_header .. "unit state not a table")
valid = false
end end
end end
end end
io.facility.ps.publish("burn_sum", burn_rate_sum)
-- update alarm sounder
sounder.eval(io.units)
end end
return true return valid
end end
-- get the IO controller database -- get the IO controller database

273
coordinator/process.lua Normal file
View File

@@ -0,0 +1,273 @@
--
-- Process Control Management
--
local comms = require("scada-common.comms")
local log = require("scada-common.log")
local types = require("scada-common.types")
local util = require("scada-common.util")
local FAC_COMMAND = comms.FAC_COMMAND
local UNIT_COMMAND = comms.UNIT_COMMAND
local PROCESS = types.PROCESS
---@class process_controller
local process = {}
local self = {
io = nil, ---@type ioctl
comms = nil, ---@type coord_comms
---@class coord_auto_config
config = {
mode = PROCESS.INACTIVE,
burn_target = 0.0,
charge_target = 0.0,
gen_target = 0.0,
limits = {}
}
}
--------------------------
-- UNIT COMMAND CONTROL --
--------------------------
-- initialize the process controller
---@param iocontrol ioctl iocontrl system
---@param coord_comms coord_comms coordinator communications
function process.init(iocontrol, coord_comms)
self.io = iocontrol
self.comms = coord_comms
for i = 1, self.io.facility.num_units do
self.config.limits[i] = 0.1
end
-- load settings
if not settings.load("/coord.settings") then
log.error("process.init(): failed to load coordinator settings file")
end
local config = settings.get("PROCESS") ---@type coord_auto_config|nil
if type(config) == "table" then
self.config.mode = config.mode
self.config.burn_target = config.burn_target
self.config.charge_target = config.charge_target
self.config.gen_target = config.gen_target
self.config.limits = config.limits
self.io.facility.ps.publish("process_mode", self.config.mode)
self.io.facility.ps.publish("process_burn_target", self.config.burn_target)
self.io.facility.ps.publish("process_charge_target", self.config.charge_target)
self.io.facility.ps.publish("process_gen_target", self.config.gen_target)
for id = 1, math.min(#self.config.limits, self.io.facility.num_units) do
local unit = self.io.units[id] ---@type ioctl_unit
unit.unit_ps.publish("burn_limit", self.config.limits[id])
end
log.info("PROCESS: loaded auto control settings from coord.settings")
end
local waste_mode = settings.get("WASTE_MODES") ---@type table|nil
if type(waste_mode) == "table" then
for id, mode in pairs(waste_mode) do
self.comms.send_unit_command(UNIT_COMMAND.SET_WASTE, id, mode)
end
log.info("PROCESS: loaded waste mode settings from coord.settings")
end
local prio_groups = settings.get("PRIORITY_GROUPS") ---@type table|nil
if type(prio_groups) == "table" then
for id, group in pairs(prio_groups) do
self.comms.send_unit_command(UNIT_COMMAND.SET_GROUP, id, group)
end
log.info("PROCESS: loaded priority groups settings from coord.settings")
end
end
-- facility SCRAM command
function process.fac_scram()
self.comms.send_fac_command(FAC_COMMAND.SCRAM_ALL)
log.debug("PROCESS: FAC SCRAM ALL")
end
-- facility alarm acknowledge command
function process.fac_ack_alarms()
self.comms.send_fac_command(FAC_COMMAND.ACK_ALL_ALARMS)
log.debug("PROCESS: FAC ACK ALL ALARMS")
end
-- start reactor
---@param id integer unit ID
function process.start(id)
self.io.units[id].control_state = true
self.comms.send_unit_command(UNIT_COMMAND.START, id)
log.debug(util.c("PROCESS: UNIT[", id, "] START"))
end
-- SCRAM reactor
---@param id integer unit ID
function process.scram(id)
self.io.units[id].control_state = false
self.comms.send_unit_command(UNIT_COMMAND.SCRAM, id)
log.debug(util.c("PROCESS: UNIT[", id, "] SCRAM"))
end
-- reset reactor protection system
---@param id integer unit ID
function process.reset_rps(id)
self.comms.send_unit_command(UNIT_COMMAND.RESET_RPS, id)
log.debug(util.c("PROCESS: UNIT[", id, "] RESET RPS"))
end
-- set burn rate
---@param id integer unit ID
---@param rate number burn rate
function process.set_rate(id, rate)
self.comms.send_unit_command(UNIT_COMMAND.SET_BURN, id, rate)
log.debug(util.c("PROCESS: UNIT[", id, "] SET BURN ", rate))
end
-- set waste mode
---@param id integer unit ID
---@param mode integer waste mode
function process.set_waste(id, mode)
-- publish so that if it fails then it gets reset
self.io.units[id].unit_ps.publish("U_WasteMode", mode)
self.comms.send_unit_command(UNIT_COMMAND.SET_WASTE, id, mode)
log.debug(util.c("PROCESS: UNIT[", id, "] SET WASTE ", mode))
local waste_mode = settings.get("WASTE_MODES") ---@type table|nil
if type(waste_mode) ~= "table" then waste_mode = {} end
waste_mode[id] = mode
settings.set("WASTE_MODES", waste_mode)
if not settings.save("/coord.settings") then
log.error("process.set_waste(): failed to save coordinator settings file")
end
end
-- acknowledge all alarms
---@param id integer unit ID
function process.ack_all_alarms(id)
self.comms.send_unit_command(UNIT_COMMAND.ACK_ALL_ALARMS, id)
log.debug(util.c("PROCESS: UNIT[", id, "] ACK ALL ALARMS"))
end
-- acknowledge an alarm
---@param id integer unit ID
---@param alarm integer alarm ID
function process.ack_alarm(id, alarm)
self.comms.send_unit_command(UNIT_COMMAND.ACK_ALARM, id, alarm)
log.debug(util.c("PROCESS: UNIT[", id, "] ACK ALARM ", alarm))
end
-- reset an alarm
---@param id integer unit ID
---@param alarm integer alarm ID
function process.reset_alarm(id, alarm)
self.comms.send_unit_command(UNIT_COMMAND.RESET_ALARM, id, alarm)
log.debug(util.c("PROCESS: UNIT[", id, "] RESET ALARM ", alarm))
end
-- assign a unit to a group
---@param unit_id integer unit ID
---@param group_id integer|0 group ID or 0 for independent
function process.set_group(unit_id, group_id)
self.comms.send_unit_command(UNIT_COMMAND.SET_GROUP, unit_id, group_id)
log.debug(util.c("PROCESS: UNIT[", unit_id, "] SET GROUP ", group_id))
local prio_groups = settings.get("PRIORITY_GROUPS") ---@type table|nil
if type(prio_groups) ~= "table" then prio_groups = {} end
prio_groups[unit_id] = group_id
settings.set("PRIORITY_GROUPS", prio_groups)
if not settings.save("/coord.settings") then
log.error("process.set_group(): failed to save coordinator settings file")
end
end
--------------------------
-- AUTO PROCESS CONTROL --
--------------------------
-- stop automatic process control
function process.stop_auto()
self.comms.send_fac_command(FAC_COMMAND.STOP)
log.debug("PROCESS: STOP AUTO CTL")
end
-- start automatic process control
function process.start_auto()
self.comms.send_auto_start(self.config)
log.debug("PROCESS: START AUTO CTL")
end
-- save process control settings
---@param mode PROCESS control mode
---@param burn_target number burn rate target
---@param charge_target number charge target
---@param gen_target number generation rate target
---@param limits table unit burn rate limits
function process.save(mode, burn_target, charge_target, gen_target, limits)
-- attempt to load settings
if not settings.load("/coord.settings") then
log.warning("process.save(): failed to load coordinator settings file")
end
-- config table
self.config = {
mode = mode,
burn_target = burn_target,
charge_target = charge_target,
gen_target = gen_target,
limits = limits
}
-- save config
settings.set("PROCESS", self.config)
local saved = settings.save("/coord.settings")
if not saved then
log.warning("process.save(): failed to save coordinator settings file")
end
self.io.facility.save_cfg_ack(saved)
end
-- handle a start command acknowledgement
---@param response table ack and configuration reply
function process.start_ack_handle(response)
local ack = response[1]
self.config.mode = response[2]
self.config.burn_target = response[3]
self.config.charge_target = response[4]
self.config.gen_target = response[5]
for i = 1, #response[6] do
self.config.limits[i] = response[6][i]
end
self.io.facility.ps.publish("auto_mode", self.config.mode)
self.io.facility.ps.publish("burn_target", self.config.burn_target)
self.io.facility.ps.publish("charge_target", self.config.charge_target)
self.io.facility.ps.publish("gen_target", self.config.gen_target)
self.io.facility.start_ack(ack)
end
return process

View File

@@ -1,12 +1,17 @@
local log = require("scada-common.log") --
-- Graphics Rendering Control
--
local iocontrol = require("coordinator.iocontrol") local log = require("scada-common.log")
local util = require("scada-common.util")
local style = require("coordinator.ui.style") local style = require("coordinator.ui.style")
local main_view = require("coordinator.ui.layout.main_view") local main_view = require("coordinator.ui.layout.main_view")
local unit_view = require("coordinator.ui.layout.unit_view") local unit_view = require("coordinator.ui.layout.unit_view")
local flasher = require("graphics.flasher")
local renderer = {} local renderer = {}
-- render engine -- render engine
@@ -22,29 +27,18 @@ local ui = {
unit_layouts = {} unit_layouts = {}
} }
-- reset a display to the "default", but set text scale to 0.5 -- init a display to the "default", but set text scale to 0.5
---@param monitor table monitor ---@param monitor table monitor
---@param recolor? boolean override default color palette local function _init_display(monitor)
local function _reset_display(monitor, recolor)
monitor.setTextScale(0.5) monitor.setTextScale(0.5)
monitor.setTextColor(colors.white) monitor.setTextColor(colors.white)
monitor.setBackgroundColor(colors.black) monitor.setBackgroundColor(colors.black)
monitor.clear() monitor.clear()
monitor.setCursorPos(1, 1) monitor.setCursorPos(1, 1)
if recolor then -- set overridden colors
-- set overridden colors for i = 1, #style.colors do
for i = 1, #style.colors do monitor.setPaletteColor(style.colors[i].c, style.colors[i].hex)
monitor.setPaletteColor(style.colors[i].c, style.colors[i].hex)
end
else
-- reset all colors
for _, val in pairs(colors) do
-- colors api has constants and functions, just get color constants
if type(val) == "number" then
monitor.setPaletteColor(val, term.nativePaletteColor(val))
end
end
end end
end end
@@ -55,6 +49,7 @@ function renderer.set_displays(monitors)
end end
-- check if the renderer is configured to use a given monitor peripheral -- check if the renderer is configured to use a given monitor peripheral
---@nodiscard
---@param periph table peripheral ---@param periph table peripheral
---@return boolean is_used ---@return boolean is_used
function renderer.is_monitor_used(periph) function renderer.is_monitor_used(periph)
@@ -73,18 +68,42 @@ function renderer.is_monitor_used(periph)
return false return false
end end
-- reset all displays in use by the renderer -- init all displays in use by the renderer
---@param recolor? boolean true to use color palette from style function renderer.init_displays()
function renderer.reset(recolor) -- init primary monitor
-- reset primary monitor _init_display(engine.monitors.primary)
_reset_display(engine.monitors.primary, recolor)
-- reset unit displays -- init unit displays
for _, monitor in pairs(engine.monitors.unit_displays) do for _, monitor in pairs(engine.monitors.unit_displays) do
_reset_display(monitor, recolor) _init_display(monitor)
end end
end end
-- check main display width
---@nodiscard
---@return boolean width_okay
function renderer.validate_main_display_width()
local w, _ = engine.monitors.primary.getSize()
return w == 164
end
-- check display sizes
---@nodiscard
---@return boolean valid all unit display dimensions OK
function renderer.validate_unit_display_sizes()
local valid = true
for id, monitor in pairs(engine.monitors.unit_displays) do
local w, h = monitor.getSize()
if w ~= 79 or h ~= 52 then
log.warning(util.c("RENDERER: unit ", id, " display resolution not 79 wide by 52 tall: ", w, ", ", h))
valid = false
end
end
return valid
end
-- initialize the dmesg output window -- initialize the dmesg output window
function renderer.init_dmesg() function renderer.init_dmesg()
local disp_x, disp_y = engine.monitors.primary.getSize() local disp_x, disp_y = engine.monitors.primary.getSize()
@@ -107,6 +126,9 @@ function renderer.start_ui()
table.insert(ui.unit_layouts, unit_view(monitor, id)) table.insert(ui.unit_layouts, unit_view(monitor, id))
end end
-- start flasher callback task
flasher.run()
-- report ui as ready -- report ui as ready
engine.ui_ready = true engine.ui_ready = true
end end
@@ -114,28 +136,37 @@ end
-- close out the UI -- close out the UI
function renderer.close_ui() function renderer.close_ui()
if engine.ui_ready then -- report ui as not ready
-- report ui as not ready engine.ui_ready = false
engine.ui_ready = false
-- stop blinking indicators
flasher.clear()
if engine.ui_ready then
-- hide to stop animation callbacks -- hide to stop animation callbacks
ui.main_layout.hide() ui.main_layout.hide()
for i = 1, #ui.unit_layouts do for i = 1, #ui.unit_layouts do
ui.unit_layouts[i].hide() ui.unit_layouts[i].hide()
engine.monitors.unit_displays[i].clear() engine.monitors.unit_displays[i].clear()
end end
else
-- clear root UI elements -- clear unit displays
ui.main_layout = nil for i = 1, #ui.unit_layouts do
ui.unit_layouts = {} engine.monitors.unit_displays[i].clear()
end
-- re-draw dmesg
engine.dmesg_window.setVisible(true)
engine.dmesg_window.redraw()
end end
-- clear root UI elements
ui.main_layout = nil
ui.unit_layouts = {}
-- re-draw dmesg
engine.dmesg_window.setVisible(true)
engine.dmesg_window.redraw()
end end
-- is the UI ready? -- is the UI ready?
---@nodiscard
---@return boolean ready ---@return boolean ready
function renderer.ui_ready() return engine.ui_ready end function renderer.ui_ready() return engine.ui_ready end

468
coordinator/sounder.lua Normal file
View File

@@ -0,0 +1,468 @@
--
-- Alarm Sounder
--
local log = require("scada-common.log")
local types = require("scada-common.types")
local util = require("scada-common.util")
local ALARM = types.ALARM
local ALARM_STATE = types.ALARM_STATE
---@class sounder
local sounder = {}
local _2_PI = 2 * math.pi -- 2 whole pies, hope you're hungry
local _DRATE = 48000 -- 48kHz audio
local _MAX_VAL = 127 / 2 -- max signed integer in this 8-bit audio
local _MAX_SAMPLES = 0x20000 -- 128 * 1024 samples
local _05s_SAMPLES = 24000 -- half a second worth of samples
local test_alarms = { false, false, false, false, false, false, false, false, false, false, false, false }
local alarm_ctl = {
speaker = nil,
volume = 0.5,
playing = false,
num_active = 0,
next_block = 1,
-- split audio up into 0.5s samples so specific components can be ended quicker
quad_buffer = { {}, {}, {}, {} }
}
-- sounds modeled after https://www.e2s.com/references-and-guidelines/listen-and-download-alarm-tones
local T_340Hz_Int_2Hz = 1
local T_544Hz_440Hz_Alt = 2
local T_660Hz_Int_125ms = 3
local T_745Hz_Int_1Hz = 4
local T_800Hz_Int = 5
local T_800Hz_1000Hz_Alt = 6
local T_1000Hz_Int = 7
local T_1800Hz_Int_4Hz = 8
local TONES = {
{ active = false, component = { {}, {}, {}, {} } }, -- 340Hz @ 2Hz Intermittent
{ active = false, component = { {}, {}, {}, {} } }, -- 544Hz 100mS / 440Hz 400mS Alternating
{ active = false, component = { {}, {}, {}, {} } }, -- 660Hz @ 125ms On 125ms Off
{ active = false, component = { {}, {}, {}, {} } }, -- 745Hz @ 1Hz Intermittent
{ active = false, component = { {}, {}, {}, {} } }, -- 800Hz @ 0.25s On 1.75s Off
{ active = false, component = { {}, {}, {}, {} } }, -- 800/1000Hz @ 0.25s Alternating
{ active = false, component = { {}, {}, {}, {} } }, -- 1KHz 1s on, 1s off Intermittent
{ active = false, component = { {}, {}, {}, {} } } -- 1.8KHz @ 4Hz Intermittent
}
-- calculate how many samples are in the given number of milliseconds
---@nodiscard
---@param ms integer milliseconds
---@return integer samples
local function ms_to_samples(ms) return math.floor(ms * 48) end
--#region Tone Generation (the Maths)
-- 340Hz @ 2Hz Intermittent
local function gen_tone_1()
local t, dt = 0, _2_PI * 340 / _DRATE
for i = 1, _05s_SAMPLES do
local val = math.floor(math.sin(t) * _MAX_VAL)
TONES[1].component[1][i] = val
TONES[1].component[3][i] = val
TONES[1].component[2][i] = 0
TONES[1].component[4][i] = 0
t = (t + dt) % _2_PI
end
end
-- 544Hz 100mS / 440Hz 400mS Alternating
local function gen_tone_2()
local t1, dt1 = 0, _2_PI * 544 / _DRATE
local t2, dt2 = 0, _2_PI * 440 / _DRATE
local alternate_at = ms_to_samples(100)
for i = 1, _05s_SAMPLES do
local value
if i <= alternate_at then
value = math.floor(math.sin(t1) * _MAX_VAL)
t1 = (t1 + dt1) % _2_PI
else
value = math.floor(math.sin(t2) * _MAX_VAL)
t2 = (t2 + dt2) % _2_PI
end
TONES[2].component[1][i] = value
TONES[2].component[2][i] = value
TONES[2].component[3][i] = value
TONES[2].component[4][i] = value
end
end
-- 660Hz @ 125ms On 125ms Off
local function gen_tone_3()
local elapsed_samples = 0
local alternate_after = ms_to_samples(125)
local alternate_at = alternate_after
local mode = true
local t, dt = 0, _2_PI * 660 / _DRATE
for set = 1, 4 do
for i = 1, _05s_SAMPLES do
if mode then
local val = math.floor(math.sin(t) * _MAX_VAL)
TONES[3].component[set][i] = val
t = (t + dt) % _2_PI
else
t = 0
TONES[3].component[set][i] = 0
end
if elapsed_samples == alternate_at then
mode = not mode
alternate_at = elapsed_samples + alternate_after
end
elapsed_samples = elapsed_samples + 1
end
end
end
-- 745Hz @ 1Hz Intermittent
local function gen_tone_4()
local t, dt = 0, _2_PI * 745 / _DRATE
for i = 1, _05s_SAMPLES do
local val = math.floor(math.sin(t) * _MAX_VAL)
TONES[4].component[1][i] = val
TONES[4].component[3][i] = val
TONES[4].component[2][i] = 0
TONES[4].component[4][i] = 0
t = (t + dt) % _2_PI
end
end
-- 800Hz @ 0.25s On 1.75s Off
local function gen_tone_5()
local t, dt = 0, _2_PI * 800 / _DRATE
local stop_at = ms_to_samples(250)
for i = 1, _05s_SAMPLES do
local val = math.floor(math.sin(t) * _MAX_VAL)
if i > stop_at then
TONES[5].component[1][i] = val
else
TONES[5].component[1][i] = 0
end
TONES[5].component[2][i] = 0
TONES[5].component[3][i] = 0
TONES[5].component[4][i] = 0
t = (t + dt) % _2_PI
end
end
-- 1000/800Hz @ 0.25s Alternating
local function gen_tone_6()
local t1, dt1 = 0, _2_PI * 1000 / _DRATE
local t2, dt2 = 0, _2_PI * 800 / _DRATE
local alternate_at = ms_to_samples(250)
for i = 1, _05s_SAMPLES do
local val
if i <= alternate_at then
val = math.floor(math.sin(t1) * _MAX_VAL)
t1 = (t1 + dt1) % _2_PI
else
val = math.floor(math.sin(t2) * _MAX_VAL)
t2 = (t2 + dt2) % _2_PI
end
TONES[6].component[1][i] = val
TONES[6].component[2][i] = val
TONES[6].component[3][i] = val
TONES[6].component[4][i] = val
end
end
-- 1KHz 1s on, 1s off Intermittent
local function gen_tone_7()
local t, dt = 0, _2_PI * 1000 / _DRATE
for i = 1, _05s_SAMPLES do
local val = math.floor(math.sin(t) * _MAX_VAL)
TONES[7].component[1][i] = val
TONES[7].component[2][i] = val
TONES[7].component[3][i] = 0
TONES[7].component[4][i] = 0
t = (t + dt) % _2_PI
end
end
-- 1800Hz @ 4Hz Intermittent
local function gen_tone_8()
local t, dt = 0, _2_PI * 1800 / _DRATE
local off_at = ms_to_samples(250)
for i = 1, _05s_SAMPLES do
local val = 0
if i <= off_at then
val = math.floor(math.sin(t) * _MAX_VAL)
t = (t + dt) % _2_PI
end
TONES[8].component[1][i] = val
TONES[8].component[2][i] = val
TONES[8].component[3][i] = val
TONES[8].component[4][i] = val
end
end
--#endregion
-- hard audio limiter
---@nodiscard
---@param output number output level
---@return number limited -128.0 to 127.0
local function limit(output)
return math.max(-128, math.min(127, output))
end
-- zero the alarm audio buffer
local function zero()
for i = 1, 4 do
for s = 1, _05s_SAMPLES do alarm_ctl.quad_buffer[i][s] = 0 end
end
end
-- add an alarm to the output buffer
---@param alarm_idx integer tone ID
local function add(alarm_idx)
alarm_ctl.num_active = alarm_ctl.num_active + 1
TONES[alarm_idx].active = true
for i = 1, 4 do
for s = 1, _05s_SAMPLES do
alarm_ctl.quad_buffer[i][s] = limit(alarm_ctl.quad_buffer[i][s] + TONES[alarm_idx].component[i][s])
end
end
end
-- start audio or continue audio on buffer empty
---@return boolean success successfully added buffer to audio output
local function play()
if not alarm_ctl.playing then
alarm_ctl.playing = true
alarm_ctl.next_block = 1
return sounder.continue()
else
return true
end
end
-- initialize the annunciator alarm system
---@param speaker table speaker peripheral
---@param volume number speaker volume
function sounder.init(speaker, volume)
alarm_ctl.speaker = speaker
alarm_ctl.speaker.stop()
alarm_ctl.volume = volume
alarm_ctl.playing = false
alarm_ctl.num_active = 0
alarm_ctl.next_block = 1
zero()
-- generate tones
gen_tone_1()
gen_tone_2()
gen_tone_3()
gen_tone_4()
gen_tone_5()
gen_tone_6()
gen_tone_7()
gen_tone_8()
end
-- reconnect the speaker peripheral
---@param speaker table speaker peripheral
function sounder.reconnect(speaker)
alarm_ctl.speaker = speaker
alarm_ctl.playing = false
alarm_ctl.next_block = 1
alarm_ctl.num_active = 0
for id = 1, #TONES do TONES[id].active = false end
end
-- check alarm state to enable/disable alarms
---@param units table|nil unit list or nil to use test mode
function sounder.eval(units)
local changed = false
local any_active = false
local new_states = { false, false, false, false, false, false, false, false }
local alarms = { false, false, false, false, false, false, false, false, false, false, false, false }
if units ~= nil then
-- check all alarms for all units
for i = 1, #units do
local unit = units[i] ---@type ioctl_unit
for id = 1, #unit.alarms do
alarms[id] = alarms[id] or (unit.alarms[id] == ALARM_STATE.TRIPPED)
end
end
else
alarms = test_alarms
end
-- containment breach is worst case CRITICAL alarm, this takes priority
if alarms[ALARM.ContainmentBreach] then
new_states[T_1800Hz_Int_4Hz] = true
else
-- critical damage is highest priority CRITICAL level alarm
if alarms[ALARM.CriticalDamage] then
new_states[T_660Hz_Int_125ms] = true
else
-- EMERGENCY level alarms + URGENT over temp
if alarms[ALARM.ReactorDamage] or alarms[ALARM.ReactorOverTemp] or alarms[ALARM.ReactorWasteLeak] then
new_states[T_544Hz_440Hz_Alt] = true
-- URGENT level turbine trip
elseif alarms[ALARM.TurbineTrip] then
new_states[T_745Hz_Int_1Hz] = true
-- URGENT level reactor lost
elseif alarms[ALARM.ReactorLost] then
new_states[T_340Hz_Int_2Hz] = true
-- TIMELY level alarms
elseif alarms[ALARM.ReactorHighTemp] or alarms[ALARM.ReactorHighWaste] or alarms[ALARM.RCSTransient] then
new_states[T_800Hz_Int] = true
end
end
-- check RPS transient URGENT level alarm
if alarms[ALARM.RPSTransient] then
new_states[T_1000Hz_Int] = true
-- disable really painful audio combination
new_states[T_340Hz_Int_2Hz] = false
end
end
-- radiation is a big concern, always play this CRITICAL level alarm if active
if alarms[ALARM.ContainmentRadiation] then
new_states[T_800Hz_1000Hz_Alt] = true
-- we are going to disable the RPS trip alarm audio due to conflict, and if it was enabled
-- then we can re-enable the reactor lost alarm audio since it doesn't painfully combine with this one
if new_states[T_1000Hz_Int] and alarms[ALARM.ReactorLost] then new_states[T_340Hz_Int_2Hz] = true end
-- it sounds *really* bad if this is in conjunction with these other tones, so disable them
new_states[T_745Hz_Int_1Hz] = false
new_states[T_800Hz_Int] = false
new_states[T_1000Hz_Int] = false
end
-- check if any changed, check if any active, update active flags
for id = 1, #TONES do
if new_states[id] ~= TONES[id].active then
TONES[id].active = new_states[id]
changed = true
end
if TONES[id].active then any_active = true end
end
-- zero and re-add tones if changed
if changed then
zero()
for id = 1, #TONES do
if TONES[id].active then add(id) end
end
end
if any_active then play() else sounder.stop() end
end
-- stop all audio and clear output buffer
function sounder.stop()
alarm_ctl.playing = false
alarm_ctl.speaker.stop()
alarm_ctl.next_block = 1
alarm_ctl.num_active = 0
for id = 1, #TONES do TONES[id].active = false end
zero()
end
-- continue audio on buffer empty
---@return boolean success successfully added buffer to audio output
function sounder.continue()
if alarm_ctl.playing then
if alarm_ctl.speaker ~= nil and #alarm_ctl.quad_buffer[alarm_ctl.next_block] > 0 then
local success = alarm_ctl.speaker.playAudio(alarm_ctl.quad_buffer[alarm_ctl.next_block], alarm_ctl.volume)
alarm_ctl.next_block = alarm_ctl.next_block + 1
if alarm_ctl.next_block > 4 then alarm_ctl.next_block = 1 end
if not success then
log.debug("SOUNDER: error playing audio")
end
return success
else
return false
end
else
return false
end
end
--#region Test Functions
function sounder.test_1() add(1) play() end -- play tone T_340Hz_Int_2Hz
function sounder.test_2() add(2) play() end -- play tone T_544Hz_440Hz_Alt
function sounder.test_3() add(3) play() end -- play tone T_660Hz_Int_125ms
function sounder.test_4() add(4) play() end -- play tone T_745Hz_Int_1Hz
function sounder.test_5() add(5) play() end -- play tone T_800Hz_Int
function sounder.test_6() add(6) play() end -- play tone T_800Hz_1000Hz_Alt
function sounder.test_7() add(7) play() end -- play tone T_1000Hz_Int
function sounder.test_8() add(8) play() end -- play tone T_1800Hz_Int_4Hz
function sounder.test_breach(active) test_alarms[ALARM.ContainmentBreach] = active end ---@param active boolean
function sounder.test_rad(active) test_alarms[ALARM.ContainmentRadiation] = active end ---@param active boolean
function sounder.test_lost(active) test_alarms[ALARM.ReactorLost] = active end ---@param active boolean
function sounder.test_crit(active) test_alarms[ALARM.CriticalDamage] = active end ---@param active boolean
function sounder.test_dmg(active) test_alarms[ALARM.ReactorDamage] = active end ---@param active boolean
function sounder.test_overtemp(active) test_alarms[ALARM.ReactorOverTemp] = active end ---@param active boolean
function sounder.test_hightemp(active) test_alarms[ALARM.ReactorHighTemp] = active end ---@param active boolean
function sounder.test_wasteleak(active) test_alarms[ALARM.ReactorWasteLeak] = active end ---@param active boolean
function sounder.test_highwaste(active) test_alarms[ALARM.ReactorHighWaste] = active end ---@param active boolean
function sounder.test_rps(active) test_alarms[ALARM.RPSTransient] = active end ---@param active boolean
function sounder.test_rcs(active) test_alarms[ALARM.RCSTransient] = active end ---@param active boolean
function sounder.test_turbinet(active) test_alarms[ALARM.TurbineTrip] = active end ---@param active boolean
-- power rescaling limiter test
function sounder.test_power_scale()
local start = util.time_ms()
zero()
for id = 1, #TONES do
if TONES[id].active then
for i = 1, 4 do
for s = 1, _05s_SAMPLES do
alarm_ctl.quad_buffer[i][s] = limit(alarm_ctl.quad_buffer[i][s] +
(TONES[id].component[i][s] / math.sqrt(alarm_ctl.num_active)))
end
end
end
end
log.debug("SOUNDER: power rescale test took " .. (util.time_ms() - start) .. "ms")
end
--#endregion
return sounder

View File

@@ -4,6 +4,7 @@
require("/initenv").init_env() require("/initenv").init_env()
local crash = require("scada-common.crash")
local log = require("scada-common.log") local log = require("scada-common.log")
local ppm = require("scada-common.ppm") local ppm = require("scada-common.ppm")
local tcallbackdsp = require("scada-common.tcallbackdsp") local tcallbackdsp = require("scada-common.tcallbackdsp")
@@ -14,9 +15,11 @@ local core = require("graphics.core")
local apisessions = require("coordinator.apisessions") local apisessions = require("coordinator.apisessions")
local config = require("coordinator.config") local config = require("coordinator.config")
local coordinator = require("coordinator.coordinator") local coordinator = require("coordinator.coordinator")
local iocontrol = require("coordinator.iocontrol")
local renderer = require("coordinator.renderer") local renderer = require("coordinator.renderer")
local sounder = require("coordinator.sounder")
local COORDINATOR_VERSION = "alpha-v0.4.12" local COORDINATOR_VERSION = "v0.12.2"
local print = util.print local print = util.print
local println = util.println local println = util.println
@@ -38,12 +41,15 @@ local cfv = util.new_validator()
cfv.assert_port(config.SCADA_SV_PORT) cfv.assert_port(config.SCADA_SV_PORT)
cfv.assert_port(config.SCADA_SV_LISTEN) cfv.assert_port(config.SCADA_SV_LISTEN)
cfv.assert_port(config.SCADA_API_LISTEN) cfv.assert_port(config.SCADA_API_LISTEN)
cfv.assert_type_int(config.TRUSTED_RANGE)
cfv.assert_type_num(config.COMMS_TIMEOUT)
cfv.assert_min(config.COMMS_TIMEOUT, 2)
cfv.assert_type_int(config.NUM_UNITS) cfv.assert_type_int(config.NUM_UNITS)
cfv.assert_type_bool(config.RECOLOR) cfv.assert_type_num(config.SOUNDER_VOLUME)
cfv.assert_type_bool(config.TIME_24_HOUR)
cfv.assert_type_str(config.LOG_PATH) cfv.assert_type_str(config.LOG_PATH)
cfv.assert_type_int(config.LOG_MODE) cfv.assert_type_int(config.LOG_MODE)
cfv.assert_type_bool(config.SECURE)
cfv.assert_type_str(config.PASSWORD)
assert(cfv.valid(), "bad config file: missing/invalid fields") assert(cfv.valid(), "bad config file: missing/invalid fields")
---------------------------------------- ----------------------------------------
@@ -57,262 +63,326 @@ log.info("BOOTING coordinator.startup " .. COORDINATOR_VERSION)
log.info("========================================") log.info("========================================")
println(">> SCADA Coordinator " .. COORDINATOR_VERSION .. " <<") println(">> SCADA Coordinator " .. COORDINATOR_VERSION .. " <<")
---------------------------------------- crash.set_env("coordinator", COORDINATOR_VERSION)
-- system startup
----------------------------------------
-- mount connected devices
ppm.mount_all()
-- setup monitors
local configured, monitors = coordinator.configure_monitors(config.NUM_UNITS)
if not configured then
println("boot> monitor setup failed")
log.fatal("monitor configuration failed")
return
end
log.info("monitors ready, dmesg output incoming...")
-- init renderer
renderer.set_displays(monitors)
renderer.reset(config.RECOLOR)
renderer.init_dmesg()
log_graphics("displays connected and reset")
log_sys("system start on " .. os.date("%c"))
log_boot("starting " .. COORDINATOR_VERSION)
---------------------------------------- ----------------------------------------
-- setup communications -- main application
---------------------------------------- ----------------------------------------
-- get the communications modem local function main()
local modem = ppm.get_wireless_modem() ----------------------------------------
if modem == nil then -- system startup
log_comms("wireless modem not found") ----------------------------------------
println("boot> wireless modem not found")
log.fatal("no wireless modem on startup")
return
else
log_comms("wireless modem connected")
end
-- create connection watchdog -- mount connected devices
local conn_watchdog = util.new_watchdog(5) ppm.mount_all()
conn_watchdog.cancel()
log.debug("boot> conn watchdog created")
-- start comms, open all channels -- setup monitors
local coord_comms = coordinator.comms(COORDINATOR_VERSION, modem, config.SCADA_SV_PORT, config.SCADA_SV_LISTEN, config.SCADA_API_LISTEN, conn_watchdog) local configured, monitors = coordinator.configure_monitors(config.NUM_UNITS)
log.debug("boot> comms init") if not configured or monitors == nil then
log_comms("comms initialized") println("startup> monitor setup failed")
log.fatal("monitor configuration failed")
-- base loop clock (2Hz, 10 ticks) return
local MAIN_CLOCK = 0.5
local loop_clock = util.new_clock(MAIN_CLOCK)
----------------------------------------
-- connect to the supervisor
----------------------------------------
-- attempt to connect to the supervisor or exit
local function init_connect_sv()
local tick_waiting, task_done = log_comms_connecting("attempting to connect to configured supervisor on channel " .. config.SCADA_SV_PORT)
-- attempt to establish a connection with the supervisory computer
if not coord_comms.sv_connect(60, tick_waiting, task_done) then
log_comms("supervisor connection failed")
log.fatal("failed to connect to supervisor")
return false
end end
return true -- init renderer
end renderer.set_displays(monitors)
renderer.init_displays()
if not init_connect_sv() then if not renderer.validate_main_display_width() then
println("boot> failed to connect to supervisor") println("startup> main display must be 8 blocks wide")
log_sys("system shutdown") log.fatal("main display not wide enough")
return return
else elseif not renderer.validate_unit_display_sizes() then
log_sys("supervisor connected, proceeding to UI start") println("startup> one or more unit display dimensions incorrect; they must be 4x4 blocks")
end log.fatal("unit display dimensions incorrect")
return
end
---------------------------------------- renderer.init_dmesg()
-- start the UI
----------------------------------------
-- start up the UI -- lets get started!
---@return boolean ui_ok started ok log.info("monitors ready, dmesg output incoming...")
local function init_start_ui()
log_graphics("starting UI...")
-- util.psleep(3)
local draw_start = util.time_ms() log_graphics("displays connected and reset")
log_sys("system start on " .. os.date("%c"))
log_boot("starting " .. COORDINATOR_VERSION)
local ui_ok, message = pcall(renderer.start_ui) ----------------------------------------
if not ui_ok then -- setup alarm sounder subsystem
renderer.close_ui() ----------------------------------------
log_graphics(util.c("UI crashed: ", message))
println_ts("UI crashed") local speaker = ppm.get_device("speaker")
log.fatal(util.c("ui crashed with error ", message)) if speaker == nil then
log_boot("annunciator alarm speaker not found")
println("startup> speaker not found")
log.fatal("no annunciator alarm speaker found")
return
else else
log_graphics("first UI draw took " .. (util.time_ms() - draw_start) .. "ms") local sounder_start = util.time_ms()
log_boot("annunciator alarm speaker connected")
-- start clock sounder.init(speaker, config.SOUNDER_VOLUME)
loop_clock.start() log_boot("tone generation took " .. (util.time_ms() - sounder_start) .. "ms")
log_sys("annunciator alarm configured")
end end
return ui_ok ----------------------------------------
end -- setup communications
----------------------------------------
local ui_ok = init_start_ui() -- get the communications modem
local modem = ppm.get_wireless_modem()
if modem == nil then
log_comms("wireless modem not found")
println("startup> wireless modem not found")
log.fatal("no wireless modem on startup")
return
else
log_comms("wireless modem connected")
end
---------------------------------------- -- create connection watchdog
-- main event loop local conn_watchdog = util.new_watchdog(config.COMMS_TIMEOUT)
---------------------------------------- conn_watchdog.cancel()
log.debug("startup> conn watchdog created")
local no_modem = false -- start comms, open all channels
local coord_comms = coordinator.comms(COORDINATOR_VERSION, modem, config.SCADA_SV_PORT, config.SCADA_SV_LISTEN,
config.SCADA_API_LISTEN, config.TRUSTED_RANGE, conn_watchdog)
log.debug("startup> comms init")
log_comms("comms initialized")
-- start connection watchdog -- base loop clock (2Hz, 10 ticks)
conn_watchdog.feed() local MAIN_CLOCK = 0.5
log.debug("boot> conn watchdog started") local loop_clock = util.new_clock(MAIN_CLOCK)
log_sys("system started successfully") ----------------------------------------
-- connect to the supervisor
----------------------------------------
-- event loop -- attempt to connect to the supervisor or exit
-- ui_ok will never change in this loop, same as while true or exit if UI start failed local function init_connect_sv()
while ui_ok do local tick_waiting, task_done = log_comms_connecting("attempting to connect to configured supervisor on channel " .. config.SCADA_SV_PORT)
local event, param1, param2, param3, param4, param5 = util.pull_event()
-- handle event -- attempt to establish a connection with the supervisory computer
if event == "peripheral_detach" then if not coord_comms.sv_connect(60, tick_waiting, task_done) then
local type, device = ppm.handle_unmount(param1) log_sys("supervisor connection failed, shutting down...")
log.fatal("failed to connect to supervisor")
return false
end
if type ~= nil and device ~= nil then return true
if type == "modem" then end
-- we only really care if this is our wireless modem
if device == modem then
no_modem = true
log_sys("comms modem disconnected")
println_ts("wireless modem disconnected!")
log.error("comms modem disconnected!")
-- close out UI if not init_connect_sv() then
renderer.close_ui() println("startup> failed to connect to supervisor")
log_sys("system shutdown")
return
else
log_sys("supervisor connected, proceeding to UI start")
end
-- alert user to status ----------------------------------------
log_sys("awaiting comms modem reconnect...") -- start the UI
else ----------------------------------------
log_sys("non-comms modem disconnected")
log.warning("non-comms modem disconnected") -- start up the UI
end ---@return boolean ui_ok started ok
elseif type == "monitor" then local function init_start_ui()
if renderer.is_monitor_used(device) then log_graphics("starting UI...")
-- "halt and catch fire" style handling
log_sys("lost a configured monitor, system will now exit") local draw_start = util.time_ms()
break
else local ui_ok, message = pcall(renderer.start_ui)
log_sys("lost unused monitor, ignoring") if not ui_ok then
renderer.close_ui()
log_graphics(util.c("UI crashed: ", message))
println_ts("UI crashed")
log.fatal(util.c("GUI crashed with error ", message))
else
log_graphics("first UI draw took " .. (util.time_ms() - draw_start) .. "ms")
-- start clock
loop_clock.start()
end
return ui_ok
end
local ui_ok = init_start_ui()
----------------------------------------
-- main event loop
----------------------------------------
local date_format = util.trinary(config.TIME_24_HOUR, "%X \x04 %A, %B %d %Y", "%r \x04 %A, %B %d %Y")
local no_modem = false
if ui_ok then
-- start connection watchdog
conn_watchdog.feed()
log.debug("startup> conn watchdog started")
log_sys("system started successfully")
end
-- main event loop
while ui_ok do
local event, param1, param2, param3, param4, param5 = util.pull_event()
-- handle event
if event == "peripheral_detach" then
local type, device = ppm.handle_unmount(param1)
if type ~= nil and device ~= nil then
if type == "modem" then
-- we only really care if this is our wireless modem
if device == modem then
no_modem = true
log_sys("comms modem disconnected")
println_ts("wireless modem disconnected!")
-- close out UI
renderer.close_ui()
-- alert user to status
log_sys("awaiting comms modem reconnect...")
else
log_sys("non-comms modem disconnected")
end
elseif type == "monitor" then
if renderer.is_monitor_used(device) then
-- "halt and catch fire" style handling
local msg = "lost a configured monitor, system will now exit"
println_ts(msg)
log_sys(msg)
break
else
log_sys("lost unused monitor, ignoring")
end
elseif type == "speaker" then
local msg = "lost alarm sounder speaker"
println_ts(msg)
log_sys(msg)
end end
end end
end elseif event == "peripheral" then
elseif event == "peripheral" then local type, device = ppm.mount(param1)
local type, device = ppm.mount(param1)
if type ~= nil and device ~= nil then if type ~= nil and device ~= nil then
if type == "modem" then if type == "modem" then
if device.isWireless() then if device.isWireless() then
-- reconnected modem -- reconnected modem
no_modem = false no_modem = false
modem = device modem = device
coord_comms.reconnect_modem(modem) coord_comms.reconnect_modem(modem)
log_sys("comms modem reconnected") log_sys("comms modem reconnected")
println_ts("wireless modem reconnected.") println_ts("wireless modem reconnected.")
-- re-init system -- re-init system
if not init_connect_sv() then break end
ui_ok = init_start_ui()
else
log_sys("wired modem reconnected")
end
elseif type == "monitor" then
-- not supported, system will exit on loss of in-use monitors
elseif type == "speaker" then
local msg = "alarm sounder speaker reconnected"
println_ts(msg)
log_sys(msg)
sounder.reconnect(device)
end
end
elseif event == "timer" then
if loop_clock.is_clock(param1) then
-- main loop tick
-- free any closed sessions
apisessions.free_all_closed()
-- update date and time string for main display
iocontrol.get_db().facility.ps.publish("date_time", os.date(date_format))
loop_clock.start()
elseif conn_watchdog.is_timer(param1) then
-- supervisor watchdog timeout
local msg = "supervisor server timeout"
log_comms(msg)
println_ts(msg)
-- close connection, UI, and stop sounder
coord_comms.close()
renderer.close_ui()
sounder.stop()
if not no_modem then
-- try to re-connect to the supervisor
if not init_connect_sv() then break end if not init_connect_sv() then break end
ui_ok = init_start_ui() ui_ok = init_start_ui()
else
log_sys("wired modem reconnected")
end end
elseif type == "monitor" then else
-- not supported, system will exit on loss of in-use monitors -- a non-clock/main watchdog timer event
--check API watchdogs
apisessions.check_all_watchdogs(param1)
-- notify timer callback dispatcher
tcallbackdsp.handle(param1)
end end
elseif event == "modem_message" then
-- got a packet
local packet = coord_comms.parse_packet(param1, param2, param3, param4, param5)
coord_comms.handle_packet(packet)
-- check if it was a disconnect
if not coord_comms.is_linked() then
log_comms("supervisor closed connection")
-- close connection, UI, and stop sounder
coord_comms.close()
renderer.close_ui()
sounder.stop()
if not no_modem then
-- try to re-connect to the supervisor
if not init_connect_sv() then break end
ui_ok = init_start_ui()
end
end
elseif event == "monitor_touch" then
-- handle a monitor touch event
renderer.handle_touch(core.events.touch(param1, param2, param3))
elseif event == "speaker_audio_empty" then
-- handle speaker buffer emptied
sounder.continue()
end end
elseif event == "timer" then
if loop_clock.is_clock(param1) then
-- main loop tick
-- free any closed sessions -- check for termination request
--apisessions.free_all_closed() if event == "terminate" or ppm.should_terminate() then
println_ts("terminate requested, closing connections...")
loop_clock.start() log_comms("terminate requested, closing supervisor connection...")
elseif conn_watchdog.is_timer(param1) then
-- supervisor watchdog timeout
local msg = "supervisor server timeout"
log_comms(msg)
println_ts(msg)
log.warning(msg)
-- close connection and UI
coord_comms.close() coord_comms.close()
renderer.close_ui() log_comms("supervisor connection closed")
log_comms("closing api sessions...")
if not no_modem then apisessions.close_all()
-- try to re-connect to the supervisor log_comms("api sessions closed")
if not init_connect_sv() then break end break
ui_ok = init_start_ui()
end
else
-- a non-clock/main watchdog timer event
--check API watchdogs
--apisessions.check_all_watchdogs(param1)
-- notify timer callback dispatcher
tcallbackdsp.handle(param1)
end end
elseif event == "modem_message" then
-- got a packet
local packet = coord_comms.parse_packet(param1, param2, param3, param4, param5)
coord_comms.handle_packet(packet)
-- check if it was a disconnect
if not coord_comms.is_linked() then
log_comms("supervisor closed connection")
-- close connection and UI
coord_comms.close()
renderer.close_ui()
if not no_modem then
-- try to re-connect to the supervisor
if not init_connect_sv() then break end
ui_ok = init_start_ui()
end
end
elseif event == "monitor_touch" then
-- handle a monitor touch event
renderer.handle_touch(core.events.touch(param1, param2, param3))
end end
-- check for termination request renderer.close_ui()
if event == "terminate" or ppm.should_terminate() then sounder.stop()
println_ts("terminate requested, closing connections...") log_sys("system shutdown")
log_comms("terminate requested, closing supervisor connection...")
coord_comms.close() println_ts("exited")
log_comms("supervisor connection closed") log.info("exited")
log_comms("closing api sessions...")
apisessions.close_all()
log_comms("api sessions closed")
break
end
end end
renderer.close_ui() if not xpcall(main, crash.handler) then
log_sys("system shutdown") pcall(renderer.close_ui)
pcall(sounder.stop)
println_ts("exited") crash.exit()
log.info("exited") end

View File

@@ -1,11 +1,12 @@
local style = require("coordinator.ui.style")
local core = require("graphics.core") local core = require("graphics.core")
local style = require("coordinator.ui.style") local Rectangle = require("graphics.elements.rectangle")
local TextBox = require("graphics.elements.textbox")
local DataIndicator = require("graphics.elements.indicators.data") local DataIndicator = require("graphics.elements.indicators.data")
local StateIndicator = require("graphics.elements.indicators.state") local StateIndicator = require("graphics.elements.indicators.state")
local Rectangle = require("graphics.elements.rectangle")
local TextBox = require("graphics.elements.textbox")
local VerticalBar = require("graphics.elements.indicators.vbar") local VerticalBar = require("graphics.elements.indicators.vbar")
local cpair = core.graphics.cpair local cpair = core.graphics.cpair
@@ -22,7 +23,7 @@ local function new_view(root, x, y, ps)
local text_fg_bg = cpair(colors.black, colors.lightGray) local text_fg_bg = cpair(colors.black, colors.lightGray)
local lu_col = cpair(colors.gray, colors.gray) local lu_col = cpair(colors.gray, colors.gray)
local status = StateIndicator{parent=boiler,x=10,y=1,states=style.boiler.states,value=1,min_width=10} local status = StateIndicator{parent=boiler,x=9,y=1,states=style.boiler.states,value=1,min_width=12}
local temp = DataIndicator{parent=boiler,x=5,y=3,lu_colors=lu_col,label="Temp:",unit="K",format="%10.2f",value=0,width=22,fg_bg=text_fg_bg} local temp = DataIndicator{parent=boiler,x=5,y=3,lu_colors=lu_col,label="Temp:",unit="K",format="%10.2f",value=0,width=22,fg_bg=text_fg_bg}
local boil_r = DataIndicator{parent=boiler,x=5,y=4,lu_colors=lu_col,label="Boil:",unit="mB/t",format="%10.0f",value=0,commas=true,width=22,fg_bg=text_fg_bg} local boil_r = DataIndicator{parent=boiler,x=5,y=4,lu_colors=lu_col,label="Boil:",unit="mB/t",format="%10.0f",value=0,commas=true,width=22,fg_bg=text_fg_bg}

View File

@@ -0,0 +1,96 @@
local util = require("scada-common.util")
local style = require("coordinator.ui.style")
local core = require("graphics.core")
local Div = require("graphics.elements.div")
local Rectangle = require("graphics.elements.rectangle")
local TextBox = require("graphics.elements.textbox")
local DataIndicator = require("graphics.elements.indicators.data")
local PowerIndicator = require("graphics.elements.indicators.power")
local StateIndicator = require("graphics.elements.indicators.state")
local VerticalBar = require("graphics.elements.indicators.vbar")
local cpair = core.graphics.cpair
local border = core.graphics.border
local TEXT_ALIGN = core.graphics.TEXT_ALIGN
-- new induction matrix view
---@param root graphics_element parent
---@param x integer top left x
---@param y integer top left y
---@param data imatrix_session_db matrix data
---@param ps psil ps interface
---@param id number? matrix ID
local function new_view(root, x, y, data, ps, id)
local title = "INDUCTION MATRIX"
if type(id) == "number" then title = title .. id end
local matrix = Div{parent=root,fg_bg=style.root,width=33,height=24,x=x,y=y}
TextBox{parent=matrix,text=" ",width=33,height=1,x=1,y=1,fg_bg=cpair(colors.lightGray,colors.gray)}
TextBox{parent=matrix,text=title,alignment=TEXT_ALIGN.CENTER,width=33,height=1,x=1,y=2,fg_bg=cpair(colors.lightGray,colors.gray)}
local rect = Rectangle{parent=matrix,border=border(1,colors.gray,true),width=33,height=22,x=1,y=3}
local text_fg_bg = cpair(colors.black, colors.lightGray)
local label_fg_bg = cpair(colors.gray, colors.lightGray)
local lu_col = cpair(colors.gray, colors.gray)
local status = StateIndicator{parent=rect,x=10,y=1,states=style.imatrix.states,value=1,min_width=14}
local energy = PowerIndicator{parent=rect,x=7,y=3,lu_colors=lu_col,label="Energy: ",format="%8.2f",value=0,width=26,fg_bg=text_fg_bg}
local capacity = PowerIndicator{parent=rect,x=7,y=4,lu_colors=lu_col,label="Capacity:",format="%8.2f",value=0,width=26,fg_bg=text_fg_bg}
local input = PowerIndicator{parent=rect,x=7,y=5,lu_colors=lu_col,label="Input: ",format="%8.2f",rate=true,value=0,width=26,fg_bg=text_fg_bg}
local output = PowerIndicator{parent=rect,x=7,y=6,lu_colors=lu_col,label="Output: ",format="%8.2f",rate=true,value=0,width=26,fg_bg=text_fg_bg}
local avg_chg = PowerIndicator{parent=rect,x=7,y=8,lu_colors=lu_col,label="Avg. Chg:",format="%8.2f",value=0,width=26,fg_bg=text_fg_bg}
local avg_in = PowerIndicator{parent=rect,x=7,y=9,lu_colors=lu_col,label="Avg. In: ",format="%8.2f",rate=true,value=0,width=26,fg_bg=text_fg_bg}
local avg_out = PowerIndicator{parent=rect,x=7,y=10,lu_colors=lu_col,label="Avg. Out:",format="%8.2f",rate=true,value=0,width=26,fg_bg=text_fg_bg}
ps.subscribe("computed_status", status.update)
ps.subscribe("energy", function (val) energy.update(util.joules_to_fe(val)) end)
ps.subscribe("max_energy", function (val) capacity.update(util.joules_to_fe(val)) end)
ps.subscribe("last_input", function (val) input.update(util.joules_to_fe(val)) end)
ps.subscribe("last_output", function (val) output.update(util.joules_to_fe(val)) end)
ps.subscribe("avg_charge", avg_chg.update)
ps.subscribe("avg_inflow", avg_in.update)
ps.subscribe("avg_outflow", avg_out.update)
local fill = DataIndicator{parent=rect,x=11,y=12,lu_colors=lu_col,label="Fill:",unit="%",format="%8.2f",value=0,width=18,fg_bg=text_fg_bg}
local cells = DataIndicator{parent=rect,x=11,y=14,lu_colors=lu_col,label="Cells: ",format="%7d",value=0,width=18,fg_bg=text_fg_bg}
local providers = DataIndicator{parent=rect,x=11,y=15,lu_colors=lu_col,label="Providers:",format="%7d",value=0,width=18,fg_bg=text_fg_bg}
TextBox{parent=rect,text="Transfer Capacity",x=11,y=17,height=1,width=17,fg_bg=label_fg_bg}
local trans_cap = PowerIndicator{parent=rect,x=19,y=18,lu_colors=lu_col,label="",format="%5.2f",rate=true,value=0,width=12,fg_bg=text_fg_bg}
ps.subscribe("cells", cells.update)
ps.subscribe("providers", providers.update)
ps.subscribe("energy_fill", function (val) fill.update(val * 100) end)
ps.subscribe("transfer_cap", function (val) trans_cap.update(util.joules_to_fe(val)) end)
local charge = VerticalBar{parent=rect,x=2,y=2,fg_bg=cpair(colors.green,colors.gray),height=17,width=4}
local in_cap = VerticalBar{parent=rect,x=7,y=12,fg_bg=cpair(colors.red,colors.gray),height=7,width=1}
local out_cap = VerticalBar{parent=rect,x=9,y=12,fg_bg=cpair(colors.blue,colors.gray),height=7,width=1}
TextBox{parent=rect,text="FILL",x=2,y=20,height=1,width=4,fg_bg=text_fg_bg}
TextBox{parent=rect,text="I/O",x=7,y=20,height=1,width=3,fg_bg=text_fg_bg}
local function calc_saturation(val)
if (type(data.build) == "table") and (type(data.build.transfer_cap) == "number") and (data.build.transfer_cap > 0) then
return val / data.build.transfer_cap
else
return 0
end
end
ps.subscribe("energy_fill", charge.update)
ps.subscribe("last_input", function (val) in_cap.update(calc_saturation(val)) end)
ps.subscribe("last_output", function (val) out_cap.update(calc_saturation(val)) end)
end
return new_view

View File

@@ -0,0 +1,267 @@
local tcd = require("scada-common.tcallbackdsp")
local util = require("scada-common.util")
local iocontrol = require("coordinator.iocontrol")
local process = require("coordinator.process")
local style = require("coordinator.ui.style")
local core = require("graphics.core")
local Div = require("graphics.elements.div")
local Rectangle = require("graphics.elements.rectangle")
local TextBox = require("graphics.elements.textbox")
local DataIndicator = require("graphics.elements.indicators.data")
local IndicatorLight = require("graphics.elements.indicators.light")
local RadIndicator = require("graphics.elements.indicators.rad")
local TriIndicatorLight = require("graphics.elements.indicators.trilight")
local HazardButton = require("graphics.elements.controls.hazard_button")
local RadioButton = require("graphics.elements.controls.radio_button")
local SpinboxNumeric = require("graphics.elements.controls.spinbox_numeric")
local TEXT_ALIGN = core.graphics.TEXT_ALIGN
local cpair = core.graphics.cpair
local border = core.graphics.border
local period = core.flasher.PERIOD
-- new process control view
---@param root graphics_element parent
---@param x integer top left x
---@param y integer top left y
local function new_view(root, x, y)
assert(root.height() >= (y + 24), "main display not of sufficient vertical resolution (add an additional row of monitors)")
local facility = iocontrol.get_db().facility
local units = iocontrol.get_db().units
local bw_fg_bg = cpair(colors.black, colors.white)
local hzd_fg_bg = cpair(colors.white, colors.gray)
local lu_cpair = cpair(colors.gray, colors.gray)
local dis_colors = cpair(colors.white, colors.lightGray)
local main = Div{parent=root,width=104,height=24,x=x,y=y}
local scram = HazardButton{parent=main,x=1,y=1,text="FAC SCRAM",accent=colors.yellow,dis_colors=dis_colors,callback=process.fac_scram,fg_bg=hzd_fg_bg}
local ack_a = HazardButton{parent=main,x=16,y=1,text="ACK \x13",accent=colors.orange,dis_colors=dis_colors,callback=process.fac_ack_alarms,fg_bg=hzd_fg_bg}
facility.scram_ack = scram.on_response
facility.ack_alarms_ack = ack_a.on_response
local all_ok = IndicatorLight{parent=main,y=5,label="Unit Systems Online",colors=cpair(colors.green,colors.red)}
local ind_mat = IndicatorLight{parent=main,label="Induction Matrix",colors=cpair(colors.green,colors.gray)}
local rad_mon = TriIndicatorLight{parent=main,label="Radiation Monitor",c1=colors.gray,c2=colors.yellow,c3=colors.green}
facility.ps.subscribe("all_sys_ok", all_ok.update)
facility.induction_ps_tbl[1].subscribe("computed_status", function (status) ind_mat.update(status > 1) end)
facility.ps.subscribe("rad_computed_status", rad_mon.update)
main.line_break()
local auto_ready = IndicatorLight{parent=main,label="Configured Units Ready",colors=cpair(colors.green,colors.red)}
local auto_act = IndicatorLight{parent=main,label="Process Active",colors=cpair(colors.green,colors.gray)}
local auto_ramp = IndicatorLight{parent=main,label="Process Ramping",colors=cpair(colors.white,colors.gray),flash=true,period=period.BLINK_250_MS}
local auto_sat = IndicatorLight{parent=main,label="Min/Max Burn Rate",colors=cpair(colors.yellow,colors.gray)}
facility.ps.subscribe("auto_ready", auto_ready.update)
facility.ps.subscribe("auto_active", auto_act.update)
facility.ps.subscribe("auto_ramping", auto_ramp.update)
facility.ps.subscribe("auto_saturated", auto_sat.update)
main.line_break()
local auto_scram = IndicatorLight{parent=main,label="Automatic SCRAM",colors=cpair(colors.red,colors.gray),flash=true,period=period.BLINK_250_MS}
local matrix_dc = IndicatorLight{parent=main,label="Matrix Disconnected",colors=cpair(colors.yellow,colors.gray),flash=true,period=period.BLINK_500_MS}
local matrix_fill = IndicatorLight{parent=main,label="Matrix Charge High",colors=cpair(colors.red,colors.gray),flash=true,period=period.BLINK_500_MS}
local unit_crit = IndicatorLight{parent=main,label="Unit Critical Alarm",colors=cpair(colors.red,colors.gray),flash=true,period=period.BLINK_250_MS}
local fac_rad_h = IndicatorLight{parent=main,label="Facility Radiation High",colors=cpair(colors.red,colors.gray),flash=true,period=period.BLINK_250_MS}
local gen_fault = IndicatorLight{parent=main,label="Gen. Control Fault",colors=cpair(colors.yellow,colors.gray),flash=true,period=period.BLINK_500_MS}
facility.ps.subscribe("auto_scram", auto_scram.update)
facility.ps.subscribe("as_matrix_dc", matrix_dc.update)
facility.ps.subscribe("as_matrix_fill", matrix_fill.update)
facility.ps.subscribe("as_crit_alarm", unit_crit.update)
facility.ps.subscribe("as_radiation", fac_rad_h.update)
facility.ps.subscribe("as_gen_fault", gen_fault.update)
TextBox{parent=main,y=23,text="Radiation",height=1,width=13,fg_bg=style.label}
local radiation = RadIndicator{parent=main,label="",format="%9.3f",lu_colors=lu_cpair,width=13,fg_bg=bw_fg_bg}
facility.ps.subscribe("radiation", radiation.update)
TextBox{parent=main,x=15,y=23,text="Linked RTUs",height=1,width=11,fg_bg=style.label}
local rtu_count = DataIndicator{parent=main,x=15,y=24,label="",format="%11d",value=0,lu_colors=lu_cpair,width=11,fg_bg=bw_fg_bg}
facility.ps.subscribe("rtu_count", rtu_count.update)
---------------------
-- process control --
---------------------
local proc = Div{parent=main,width=78,height=24,x=27,y=1}
-----------------------------
-- process control targets --
-----------------------------
local targets = Div{parent=proc,width=31,height=24,x=1,y=1}
local burn_tag = Div{parent=targets,x=1,y=1,width=8,height=4,fg_bg=cpair(colors.black,colors.purple)}
TextBox{parent=burn_tag,x=2,y=2,text="Burn Target",width=7,height=2}
local burn_target = Div{parent=targets,x=9,y=1,width=23,height=3,fg_bg=cpair(colors.gray,colors.white)}
local b_target = SpinboxNumeric{parent=burn_target,x=11,y=1,whole_num_precision=4,fractional_precision=1,min=0.1,arrow_fg_bg=cpair(colors.gray,colors.white),fg_bg=bw_fg_bg}
TextBox{parent=burn_target,x=18,y=2,text="mB/t"}
local burn_sum = DataIndicator{parent=targets,x=9,y=4,label="",format="%18.1f",value=0,unit="mB/t",commas=true,lu_colors=cpair(colors.black,colors.black),width=23,fg_bg=cpair(colors.black,colors.brown)}
facility.ps.subscribe("process_burn_target", b_target.set_value)
facility.ps.subscribe("burn_sum", burn_sum.update)
local chg_tag = Div{parent=targets,x=1,y=6,width=8,height=4,fg_bg=cpair(colors.black,colors.purple)}
TextBox{parent=chg_tag,x=2,y=2,text="Charge Target",width=7,height=2}
local chg_target = Div{parent=targets,x=9,y=6,width=23,height=3,fg_bg=cpair(colors.gray,colors.white)}
local c_target = SpinboxNumeric{parent=chg_target,x=2,y=1,whole_num_precision=15,fractional_precision=0,min=0,arrow_fg_bg=cpair(colors.gray,colors.white),fg_bg=bw_fg_bg}
TextBox{parent=chg_target,x=18,y=2,text="MFE"}
local cur_charge = DataIndicator{parent=targets,x=9,y=9,label="",format="%19d",value=0,unit="MFE",commas=true,lu_colors=cpair(colors.black,colors.black),width=23,fg_bg=cpair(colors.black,colors.brown)}
facility.ps.subscribe("process_charge_target", c_target.set_value)
facility.induction_ps_tbl[1].subscribe("energy", function (j) cur_charge.update(util.joules_to_fe(j) / 1000000) end)
local gen_tag = Div{parent=targets,x=1,y=11,width=8,height=4,fg_bg=cpair(colors.black,colors.purple)}
TextBox{parent=gen_tag,x=2,y=2,text="Gen. Target",width=7,height=2}
local gen_target = Div{parent=targets,x=9,y=11,width=23,height=3,fg_bg=cpair(colors.gray,colors.white)}
local g_target = SpinboxNumeric{parent=gen_target,x=8,y=1,whole_num_precision=9,fractional_precision=0,min=0,arrow_fg_bg=cpair(colors.gray,colors.white),fg_bg=bw_fg_bg}
TextBox{parent=gen_target,x=18,y=2,text="kFE/t"}
local cur_gen = DataIndicator{parent=targets,x=9,y=14,label="",format="%17d",value=0,unit="kFE/t",commas=true,lu_colors=cpair(colors.black,colors.black),width=23,fg_bg=cpair(colors.black,colors.brown)}
facility.ps.subscribe("process_gen_target", g_target.set_value)
facility.induction_ps_tbl[1].subscribe("last_input", function (j) cur_gen.update(util.round(util.joules_to_fe(j) / 1000)) end)
-----------------
-- unit limits --
-----------------
local limit_div = Div{parent=proc,width=21,height=19,x=34,y=6}
local rate_limits = {}
for i = 1, facility.num_units do
local unit = units[i] ---@type ioctl_unit
local _y = ((i - 1) * 5) + 1
local unit_tag = Div{parent=limit_div,x=1,y=_y,width=8,height=4,fg_bg=cpair(colors.black,colors.lightBlue)}
TextBox{parent=unit_tag,x=2,y=2,text="Unit "..i.." Limit",width=7,height=2}
local lim_ctl = Div{parent=limit_div,x=9,y=_y,width=14,height=3,fg_bg=cpair(colors.gray,colors.white)}
rate_limits[i] = SpinboxNumeric{parent=lim_ctl,x=2,y=1,whole_num_precision=4,fractional_precision=1,min=0.1,arrow_fg_bg=cpair(colors.gray,colors.white),fg_bg=bw_fg_bg}
TextBox{parent=lim_ctl,x=9,y=2,text="mB/t",width=4,height=1}
unit.unit_ps.subscribe("max_burn", rate_limits[i].set_max)
unit.unit_ps.subscribe("burn_limit", rate_limits[i].set_value)
local cur_burn = DataIndicator{parent=limit_div,x=9,y=_y+3,label="",format="%7.1f",value=0,unit="mB/t",commas=false,lu_colors=cpair(colors.black,colors.black),width=14,fg_bg=cpair(colors.black,colors.brown)}
unit.unit_ps.subscribe("act_burn_rate", cur_burn.update)
end
-------------------
-- unit statuses --
-------------------
local stat_div = Div{parent=proc,width=38,height=19,x=57,y=6}
for i = 1, facility.num_units do
local unit = units[i] ---@type ioctl_unit
local _y = ((i - 1) * 5) + 1
local unit_tag = Div{parent=stat_div,x=1,y=_y,width=8,height=4,fg_bg=cpair(colors.black,colors.lightBlue)}
TextBox{parent=unit_tag,x=2,y=2,text="Unit "..i.." Status",width=7,height=2}
local lights = Div{parent=stat_div,x=9,y=_y,width=12,height=4,fg_bg=bw_fg_bg}
local ready = IndicatorLight{parent=lights,x=2,y=2,label="Ready",colors=cpair(colors.green,colors.gray)}
local degraded = IndicatorLight{parent=lights,x=2,y=3,label="Degraded",colors=cpair(colors.red,colors.gray),flash=true,period=period.BLINK_250_MS}
unit.unit_ps.subscribe("U_AutoReady", ready.update)
unit.unit_ps.subscribe("U_AutoDegraded", degraded.update)
end
-------------------------
-- controls and status --
-------------------------
local ctl_opts = { "Monitored Max Burn", "Combined Burn Rate", "Charge Level", "Generation Rate" }
local mode = RadioButton{parent=proc,x=34,y=1,options=ctl_opts,callback=function()end,radio_colors=cpair(colors.purple,colors.black),radio_bg=colors.gray}
facility.ps.subscribe("process_mode", mode.set_value)
local u_stat = Rectangle{parent=proc,border=border(1,colors.gray,true),thin=true,width=31,height=4,x=1,y=16,fg_bg=bw_fg_bg}
local stat_line_1 = TextBox{parent=u_stat,x=1,y=1,text="UNKNOWN",width=31,height=1,alignment=TEXT_ALIGN.CENTER,fg_bg=bw_fg_bg}
local stat_line_2 = TextBox{parent=u_stat,x=1,y=2,text="awaiting data...",width=31,height=1,alignment=TEXT_ALIGN.CENTER,fg_bg=cpair(colors.gray, colors.white)}
facility.ps.subscribe("status_line_1", stat_line_1.set_value)
facility.ps.subscribe("status_line_2", stat_line_2.set_value)
local auto_controls = Div{parent=proc,x=1,y=20,width=31,height=5,fg_bg=cpair(colors.gray,colors.white)}
-- save the automatic process control configuration without starting
local function _save_cfg()
local limits = {}
for i = 1, #rate_limits do limits[i] = rate_limits[i].get_value() end
process.save(mode.get_value(), b_target.get_value(), c_target.get_value(), g_target.get_value(), limits)
end
-- start automatic control after saving process control settings
local function _start_auto()
_save_cfg()
process.start_auto()
end
local save = HazardButton{parent=auto_controls,x=2,y=2,text="SAVE",accent=colors.purple,dis_colors=dis_colors,callback=_save_cfg,fg_bg=hzd_fg_bg}
local start = HazardButton{parent=auto_controls,x=13,y=2,text="START",accent=colors.lightBlue,dis_colors=dis_colors,callback=_start_auto,fg_bg=hzd_fg_bg}
local stop = HazardButton{parent=auto_controls,x=23,y=2,text="STOP",accent=colors.red,dis_colors=dis_colors,callback=process.stop_auto,fg_bg=hzd_fg_bg}
facility.start_ack = start.on_response
facility.stop_ack = stop.on_response
function facility.save_cfg_ack(ack)
tcd.dispatch(0.2, function () save.on_response(ack) end)
end
facility.ps.subscribe("auto_ready", function (ready)
if ready and (not facility.auto_active) then start.enable() else start.disable() end
end)
facility.ps.subscribe("auto_active", function (active)
if active then
b_target.disable()
c_target.disable()
g_target.disable()
mode.disable()
start.disable()
for i = 1, #rate_limits do
rate_limits[i].disable()
end
else
b_target.enable()
c_target.enable()
g_target.enable()
mode.enable()
if facility.auto_ready then start.enable() end
for i = 1, #rate_limits do
rate_limits[i].enable()
end
end
end)
end
return new_view

View File

@@ -1,16 +1,15 @@
local util = require("scada-common.util") local types = require("scada-common.types")
local core = require("graphics.core")
local style = require("coordinator.ui.style") local style = require("coordinator.ui.style")
local HorizontalBar = require("graphics.elements.indicators.hbar") local core = require("graphics.core")
local DataIndicator = require("graphics.elements.indicators.data")
local StateIndicator = require("graphics.elements.indicators.state")
local Rectangle = require("graphics.elements.rectangle") local Rectangle = require("graphics.elements.rectangle")
local TextBox = require("graphics.elements.textbox") local TextBox = require("graphics.elements.textbox")
local TEXT_ALIGN = core.graphics.TEXT_ALIGN local DataIndicator = require("graphics.elements.indicators.data")
local HorizontalBar = require("graphics.elements.indicators.hbar")
local StateIndicator = require("graphics.elements.indicators.state")
local cpair = core.graphics.cpair local cpair = core.graphics.cpair
local border = core.graphics.border local border = core.graphics.border
@@ -27,9 +26,9 @@ local function new_view(root, x, y, data, ps)
local text_fg_bg = cpair(colors.black, colors.lightGray) local text_fg_bg = cpair(colors.black, colors.lightGray)
local lu_col = cpair(colors.gray, colors.gray) local lu_col = cpair(colors.gray, colors.gray)
local status = StateIndicator{parent=reactor,x=8,y=1,states=style.reactor.states,value=1,min_width=14} local status = StateIndicator{parent=reactor,x=6,y=1,states=style.reactor.states,value=1,min_width=16}
local core_temp = DataIndicator{parent=reactor,x=2,y=3,lu_colors=lu_col,label="Core Temp:",unit="K",format="%10.2f",value=0,width=26,fg_bg=text_fg_bg} local core_temp = DataIndicator{parent=reactor,x=2,y=3,lu_colors=lu_col,label="Core Temp:",unit="K",format="%10.2f",value=0,width=26,fg_bg=text_fg_bg}
local burn_r = DataIndicator{parent=reactor,x=2,y=4,lu_colors=lu_col,label="Burn Rate:",unit="mB/t",format="%10.1f",value=0,width=26,fg_bg=text_fg_bg} local burn_r = DataIndicator{parent=reactor,x=2,y=4,lu_colors=lu_col,label="Burn Rate:",unit="mB/t",format="%10.2f",value=0,width=26,fg_bg=text_fg_bg}
local heating_r = DataIndicator{parent=reactor,x=2,y=5,lu_colors=lu_col,label="Heating:",unit="mB/t",format="%12.0f",value=0,commas=true,width=26,fg_bg=text_fg_bg} local heating_r = DataIndicator{parent=reactor,x=2,y=5,lu_colors=lu_col,label="Heating:",unit="mB/t",format="%12.0f",value=0,commas=true,width=26,fg_bg=text_fg_bg}
ps.subscribe("computed_status", status.update) ps.subscribe("computed_status", status.update)
@@ -44,16 +43,27 @@ local function new_view(root, x, y, data, ps)
TextBox{parent=reactor_fills,text="HCOOL",x=2,y=4,height=1,fg_bg=text_fg_bg} TextBox{parent=reactor_fills,text="HCOOL",x=2,y=4,height=1,fg_bg=text_fg_bg}
TextBox{parent=reactor_fills,text="WASTE",x=2,y=5,height=1,fg_bg=text_fg_bg} TextBox{parent=reactor_fills,text="WASTE",x=2,y=5,height=1,fg_bg=text_fg_bg}
-- local ccool_color = util.trinary(data.mek_status.ccool_type == "sodium", cpair(colors.lightBlue,colors.gray), cpair(colors.blue,colors.gray))
-- local hcool_color = util.trinary(data.mek_status.hcool_type == "superheated_sodium", cpair(colors.orange,colors.gray), cpair(colors.white,colors.gray))
local ccool_color = util.trinary(true, cpair(colors.lightBlue,colors.gray), cpair(colors.blue,colors.gray))
local hcool_color = util.trinary(true, cpair(colors.orange,colors.gray), cpair(colors.white,colors.gray))
local fuel = HorizontalBar{parent=reactor_fills,x=8,y=1,show_percent=true,bar_fg_bg=cpair(colors.black,colors.gray),height=1,width=14} local fuel = HorizontalBar{parent=reactor_fills,x=8,y=1,show_percent=true,bar_fg_bg=cpair(colors.black,colors.gray),height=1,width=14}
local ccool = HorizontalBar{parent=reactor_fills,x=8,y=2,show_percent=true,bar_fg_bg=ccool_color,height=1,width=14} local ccool = HorizontalBar{parent=reactor_fills,x=8,y=2,show_percent=true,bar_fg_bg=cpair(colors.blue,colors.gray),height=1,width=14}
local hcool = HorizontalBar{parent=reactor_fills,x=8,y=4,show_percent=true,bar_fg_bg=hcool_color,height=1,width=14} local hcool = HorizontalBar{parent=reactor_fills,x=8,y=4,show_percent=true,bar_fg_bg=cpair(colors.white,colors.gray),height=1,width=14}
local waste = HorizontalBar{parent=reactor_fills,x=8,y=5,show_percent=true,bar_fg_bg=cpair(colors.brown,colors.gray),height=1,width=14} local waste = HorizontalBar{parent=reactor_fills,x=8,y=5,show_percent=true,bar_fg_bg=cpair(colors.brown,colors.gray),height=1,width=14}
ps.subscribe("ccool_type", function (type)
if type == types.FLUID.SODIUM then
ccool.recolor(cpair(colors.lightBlue, colors.gray))
else
ccool.recolor(cpair(colors.blue, colors.gray))
end
end)
ps.subscribe("hcool_type", function (type)
if type == types.FLUID.SUPERHEATED_SODIUM then
hcool.recolor(cpair(colors.orange, colors.gray))
else
hcool.recolor(cpair(colors.white, colors.gray))
end
end)
ps.subscribe("fuel_fill", fuel.update) ps.subscribe("fuel_fill", fuel.update)
ps.subscribe("ccool_fill", ccool.update) ps.subscribe("ccool_fill", ccool.update)
ps.subscribe("hcool_fill", hcool.update) ps.subscribe("hcool_fill", hcool.update)

View File

@@ -1,10 +1,15 @@
local core = require("graphics.core") local util = require("scada-common.util")
local style = require("coordinator.ui.style") local style = require("coordinator.ui.style")
local DataIndicator = require("graphics.elements.indicators.data") local core = require("graphics.core")
local StateIndicator = require("graphics.elements.indicators.state")
local Rectangle = require("graphics.elements.rectangle") local Rectangle = require("graphics.elements.rectangle")
local TextBox = require("graphics.elements.textbox")
local DataIndicator = require("graphics.elements.indicators.data")
local PowerIndicator = require("graphics.elements.indicators.power")
local StateIndicator = require("graphics.elements.indicators.state")
local VerticalBar = require("graphics.elements.indicators.vbar") local VerticalBar = require("graphics.elements.indicators.vbar")
local cpair = core.graphics.cpair local cpair = core.graphics.cpair
@@ -21,17 +26,22 @@ local function new_view(root, x, y, ps)
local text_fg_bg = cpair(colors.black, colors.lightGray) local text_fg_bg = cpair(colors.black, colors.lightGray)
local lu_col = cpair(colors.gray, colors.gray) local lu_col = cpair(colors.gray, colors.gray)
local status = StateIndicator{parent=turbine,x=8,y=1,states=style.turbine.states,value=1,min_width=10} local status = StateIndicator{parent=turbine,x=7,y=1,states=style.turbine.states,value=1,min_width=12}
local prod_rate = DataIndicator{parent=turbine,x=5,y=3,lu_colors=lu_col,label="",unit="MFE",format="%10.2f",value=0,width=16,fg_bg=text_fg_bg} local prod_rate = PowerIndicator{parent=turbine,x=5,y=3,lu_colors=lu_col,label="",format="%10.2f",value=0,rate=true,width=16,fg_bg=text_fg_bg}
local flow_rate = DataIndicator{parent=turbine,x=5,y=4,lu_colors=lu_col,label="",unit="mB/t",format="%10.0f",value=0,commas=true,width=16,fg_bg=text_fg_bg} local flow_rate = DataIndicator{parent=turbine,x=5,y=4,lu_colors=lu_col,label="",unit="mB/t",format="%10.0f",value=0,commas=true,width=16,fg_bg=text_fg_bg}
ps.subscribe("computed_status", status.update) ps.subscribe("computed_status", status.update)
ps.subscribe("prod_rate", prod_rate.update) ps.subscribe("prod_rate", function (val) prod_rate.update(util.joules_to_fe(val)) end)
ps.subscribe("flow_rate", flow_rate.update) ps.subscribe("flow_rate", flow_rate.update)
local steam = VerticalBar{parent=turbine,x=2,y=1,fg_bg=cpair(colors.white,colors.gray),height=5,width=2} local steam = VerticalBar{parent=turbine,x=2,y=1,fg_bg=cpair(colors.white,colors.gray),height=4,width=1}
local energy = VerticalBar{parent=turbine,x=3,y=1,fg_bg=cpair(colors.green,colors.gray),height=4,width=1}
TextBox{parent=turbine,text="S",x=2,y=5,height=1,width=1,fg_bg=text_fg_bg}
TextBox{parent=turbine,text="E",x=3,y=5,height=1,width=1,fg_bg=text_fg_bg}
ps.subscribe("steam_fill", steam.update) ps.subscribe("steam_fill", steam.update)
ps.subscribe("energy_fill", energy.update)
end end
return new_view return new_view

View File

@@ -2,8 +2,6 @@
-- Reactor Unit SCADA Coordinator GUI -- Reactor Unit SCADA Coordinator GUI
-- --
local tcallbackdsp = require("scada-common.tcallbackdsp")
local iocontrol = require("coordinator.iocontrol") local iocontrol = require("coordinator.iocontrol")
local style = require("coordinator.ui.style") local style = require("coordinator.ui.style")
@@ -11,30 +9,60 @@ local style = require("coordinator.ui.style")
local core = require("graphics.core") local core = require("graphics.core")
local Div = require("graphics.elements.div") local Div = require("graphics.elements.div")
local Rectangle = require("graphics.elements.rectangle")
local TextBox = require("graphics.elements.textbox") local TextBox = require("graphics.elements.textbox")
local ColorMap = require("graphics.elements.colormap")
local AlarmLight = require("graphics.elements.indicators.alight")
local CoreMap = require("graphics.elements.indicators.coremap") local CoreMap = require("graphics.elements.indicators.coremap")
local DataIndicator = require("graphics.elements.indicators.data") local DataIndicator = require("graphics.elements.indicators.data")
local IndicatorLight = require("graphics.elements.indicators.light") local IndicatorLight = require("graphics.elements.indicators.light")
local RadIndicator = require("graphics.elements.indicators.rad")
local TriIndicatorLight = require("graphics.elements.indicators.trilight") local TriIndicatorLight = require("graphics.elements.indicators.trilight")
local VerticalBar = require("graphics.elements.indicators.vbar")
local HazardButton = require("graphics.elements.controls.hazard_button")
local MultiButton = require("graphics.elements.controls.multi_button") local MultiButton = require("graphics.elements.controls.multi_button")
local PushButton = require("graphics.elements.controls.push_button") local PushButton = require("graphics.elements.controls.push_button")
local SCRAMButton = require("graphics.elements.controls.scram_button") local RadioButton = require("graphics.elements.controls.radio_button")
local StartButton = require("graphics.elements.controls.start_button")
local SpinboxNumeric = require("graphics.elements.controls.spinbox_numeric") local SpinboxNumeric = require("graphics.elements.controls.spinbox_numeric")
local TEXT_ALIGN = core.graphics.TEXT_ALIGN local TEXT_ALIGN = core.graphics.TEXT_ALIGN
local cpair = core.graphics.cpair local cpair = core.graphics.cpair
local border = core.graphics.border
local period = core.flasher.PERIOD
local waste_opts = {
{
text = "Auto",
fg_bg = cpair(colors.black, colors.lightGray),
active_fg_bg = cpair(colors.white, colors.gray)
},
{
text = "Pu",
fg_bg = cpair(colors.black, colors.lightGray),
active_fg_bg = cpair(colors.black, colors.green)
},
{
text = "Po",
fg_bg = cpair(colors.black, colors.lightGray),
active_fg_bg = cpair(colors.black, colors.cyan)
},
{
text = "AM",
fg_bg = cpair(colors.black, colors.lightGray),
active_fg_bg = cpair(colors.black, colors.purple)
}
}
-- create a unit view -- create a unit view
---@param parent graphics_element parent ---@param parent graphics_element parent
---@param id integer ---@param id integer
local function init(parent, id) local function init(parent, id)
local unit = iocontrol.get_db().units[id] ---@type ioctl_entry local unit = iocontrol.get_db().units[id] ---@type ioctl_unit
local r_ps = unit.reactor_ps local f_ps = iocontrol.get_db().facility.ps
local u_ps = unit.unit_ps
local b_ps = unit.boiler_ps_tbl local b_ps = unit.boiler_ps_tbl
local t_ps = unit.turbine_ps_tbl local t_ps = unit.turbine_ps_tbl
@@ -42,246 +70,449 @@ local function init(parent, id)
TextBox{parent=main,text="Reactor Unit #" .. id,alignment=TEXT_ALIGN.CENTER,height=1,fg_bg=style.header} TextBox{parent=main,text="Reactor Unit #" .. id,alignment=TEXT_ALIGN.CENTER,height=1,fg_bg=style.header}
local scram_fg_bg = cpair(colors.white, colors.gray) local bw_fg_bg = cpair(colors.black, colors.white)
local lu_cpair = cpair(colors.gray, colors.gray) local hzd_fg_bg = cpair(colors.white, colors.gray)
local lu_cpair = cpair(colors.gray, colors.gray)
-----------------------------
-- main stats and core map -- -- main stats and core map --
-----------------------------
---@todo need to be checking actual reactor dimensions somehow
local core_map = CoreMap{parent=main,x=2,y=3,reactor_l=18,reactor_w=18} local core_map = CoreMap{parent=main,x=2,y=3,reactor_l=18,reactor_w=18}
r_ps.subscribe("temp", core_map.update) u_ps.subscribe("temp", core_map.update)
u_ps.subscribe("size", function (s) core_map.resize(s[1], s[2]) end)
local stat_fg_bg = cpair(colors.black,colors.white) TextBox{parent=main,x=12,y=22,text="Heating Rate",height=1,width=12,fg_bg=style.label}
local heating_r = DataIndicator{parent=main,x=12,label="",format="%14.0f",value=0,unit="mB/t",commas=true,lu_colors=lu_cpair,width=19,fg_bg=bw_fg_bg}
u_ps.subscribe("heating_rate", heating_r.update)
TextBox{parent=main,x=21,y=3,text="Core Temp",height=1,fg_bg=style.label} TextBox{parent=main,x=12,y=25,text="Commanded Burn Rate",height=1,width=19,fg_bg=style.label}
local core_temp = DataIndicator{parent=main,x=21,label="",format="%9.2f",value=0,unit="K",lu_colors=lu_cpair,width=12,fg_bg=stat_fg_bg} local burn_r = DataIndicator{parent=main,x=12,label="",format="%14.2f",value=0,unit="mB/t",lu_colors=lu_cpair,width=19,fg_bg=bw_fg_bg}
r_ps.subscribe("temp", core_temp.update) u_ps.subscribe("burn_rate", burn_r.update)
main.line_break()
TextBox{parent=main,x=21,text="Burn Rate",height=1,width=12,fg_bg=style.label} TextBox{parent=main,text="F",x=2,y=22,width=1,height=1,fg_bg=style.label}
local act_burn_r = DataIndicator{parent=main,x=21,label="",format="%6.1f",value=0,unit="mB/t",lu_colors=lu_cpair,width=12,fg_bg=stat_fg_bg} TextBox{parent=main,text="C",x=4,y=22,width=1,height=1,fg_bg=style.label}
r_ps.subscribe("act_burn_rate", act_burn_r.update) TextBox{parent=main,text="\x1a",x=6,y=24,width=1,height=1,fg_bg=style.label}
main.line_break() TextBox{parent=main,text="\x1a",x=6,y=25,width=1,height=1,fg_bg=style.label}
TextBox{parent=main,text="H",x=8,y=22,width=1,height=1,fg_bg=style.label}
TextBox{parent=main,text="W",x=10,y=22,width=1,height=1,fg_bg=style.label}
TextBox{parent=main,x=21,text="Commanded Burn Rate",height=2,width=12,fg_bg=style.label} local fuel = VerticalBar{parent=main,x=2,y=23,fg_bg=cpair(colors.black,colors.gray),height=4,width=1}
local burn_r = DataIndicator{parent=main,x=21,label="",format="%6.1f",value=0,unit="mB/t",lu_colors=lu_cpair,width=12,fg_bg=stat_fg_bg} local ccool = VerticalBar{parent=main,x=4,y=23,fg_bg=cpair(colors.blue,colors.gray),height=4,width=1}
r_ps.subscribe("burn_rate", burn_r.update) local hcool = VerticalBar{parent=main,x=8,y=23,fg_bg=cpair(colors.white,colors.gray),height=4,width=1}
main.line_break() local waste = VerticalBar{parent=main,x=10,y=23,fg_bg=cpair(colors.brown,colors.gray),height=4,width=1}
TextBox{parent=main,x=21,text="Heating Rate",height=1,width=12,fg_bg=style.label} u_ps.subscribe("fuel_fill", fuel.update)
local heating_r = DataIndicator{parent=main,x=21,label="",format="%11.0f",value=0,unit="",lu_colors=lu_cpair,width=12,fg_bg=stat_fg_bg} u_ps.subscribe("ccool_fill", ccool.update)
r_ps.subscribe("heating_rate", heating_r.update) u_ps.subscribe("hcool_fill", hcool.update)
main.line_break() u_ps.subscribe("waste_fill", waste.update)
TextBox{parent=main,x=21,text="Containment Integrity",height=2,width=12,fg_bg=style.label} u_ps.subscribe("ccool_type", function (type)
local integ = DataIndicator{parent=main,x=21,label="",format="%9.0f",value=100,unit="%",lu_colors=lu_cpair,width=12,fg_bg=stat_fg_bg} if type == "mekanism:sodium" then
r_ps.subscribe("damage", function (x) integ.update(100.0 - x) end) ccool.recolor(cpair(colors.lightBlue, colors.gray))
main.line_break() else
ccool.recolor(cpair(colors.blue, colors.gray))
end
end)
-- TextBox{parent=main,text="FL",x=21,y=19,height=1,width=2,fg_bg=style.label} u_ps.subscribe("hcool_type", function (type)
-- TextBox{parent=main,text="WS",x=24,y=19,height=1,width=2,fg_bg=style.label} if type == "mekanism:superheated_sodium" then
-- TextBox{parent=main,text="CL",x=28,y=19,height=1,width=2,fg_bg=style.label} hcool.recolor(cpair(colors.orange, colors.gray))
-- TextBox{parent=main,text="HC",x=31,y=19,height=1,width=2,fg_bg=style.label} else
hcool.recolor(cpair(colors.white, colors.gray))
end
end)
-- local fuel = VerticalBar{parent=main,x=21,y=12,fg_bg=cpair(colors.black,colors.gray),height=6,width=2} TextBox{parent=main,x=32,y=22,text="Core Temp",height=1,width=9,fg_bg=style.label}
-- local waste = VerticalBar{parent=main,x=24,y=12,fg_bg=cpair(colors.brown,colors.gray),height=6,width=2} local core_temp = DataIndicator{parent=main,x=32,label="",format="%11.2f",value=0,unit="K",lu_colors=lu_cpair,width=13,fg_bg=bw_fg_bg}
-- local ccool = VerticalBar{parent=main,x=28,y=12,fg_bg=cpair(colors.lightBlue,colors.gray),height=6,width=2} u_ps.subscribe("temp", core_temp.update)
-- local hcool = VerticalBar{parent=main,x=31,y=12,fg_bg=cpair(colors.orange,colors.gray),height=6,width=2}
TextBox{parent=main,x=32,y=25,text="Burn Rate",height=1,width=9,fg_bg=style.label}
local act_burn_r = DataIndicator{parent=main,x=32,label="",format="%8.2f",value=0,unit="mB/t",lu_colors=lu_cpair,width=13,fg_bg=bw_fg_bg}
u_ps.subscribe("act_burn_rate", act_burn_r.update)
TextBox{parent=main,x=32,y=28,text="Damage",height=1,width=6,fg_bg=style.label}
local damage_p = DataIndicator{parent=main,x=32,label="",format="%11.0f",value=0,unit="%",lu_colors=lu_cpair,width=13,fg_bg=bw_fg_bg}
u_ps.subscribe("damage", damage_p.update)
TextBox{parent=main,x=32,y=31,text="Radiation",height=1,width=21,fg_bg=style.label}
local radiation = RadIndicator{parent=main,x=32,label="",format="%9.3f",lu_colors=lu_cpair,width=13,fg_bg=bw_fg_bg}
u_ps.subscribe("radiation", radiation.update)
-------------------
-- system status --
-------------------
local u_stat = Rectangle{parent=main,border=border(1,colors.gray,true),thin=true,width=33,height=4,x=46,y=3,fg_bg=bw_fg_bg}
local stat_line_1 = TextBox{parent=u_stat,x=1,y=1,text="UNKNOWN",width=33,height=1,alignment=TEXT_ALIGN.CENTER,fg_bg=bw_fg_bg}
local stat_line_2 = TextBox{parent=u_stat,x=1,y=2,text="awaiting data...",width=33,height=1,alignment=TEXT_ALIGN.CENTER,fg_bg=cpair(colors.gray, colors.white)}
u_ps.subscribe("U_StatusLine1", stat_line_1.set_value)
u_ps.subscribe("U_StatusLine2", stat_line_2.set_value)
-----------------
-- annunciator -- -- annunciator --
-----------------
local annunciator = Div{parent=main,x=34,y=3} -- annunciator colors (generally) per IAEA-TECDOC-812 recommendations
-- annunciator colors per IAEA-TECDOC-812 recommendations local annunciator = Div{parent=main,width=23,height=18,x=22,y=3}
-- connectivity/basic state -- connectivity
local plc_online = IndicatorLight{parent=annunciator,label="PLC Online",colors=cpair(colors.green,colors.red)} local plc_online = IndicatorLight{parent=annunciator,label="PLC Online",colors=cpair(colors.green,colors.red)}
local plc_hbeat = IndicatorLight{parent=annunciator,label="PLC Heartbeat",colors=cpair(colors.white,colors.gray)} local plc_hbeat = IndicatorLight{parent=annunciator,label="PLC Heartbeat",colors=cpair(colors.white,colors.gray)}
local r_active = IndicatorLight{parent=annunciator,label="Active",colors=cpair(colors.green,colors.gray)} local rad_mon = TriIndicatorLight{parent=annunciator,label="Radiation Monitor",c1=colors.gray,c2=colors.yellow,c3=colors.green}
---@todo auto control as info sent here
local r_auto = IndicatorLight{parent=annunciator,label="Auto Control",colors=cpair(colors.blue,colors.gray)}
r_ps.subscribe("PLCOnline", plc_online.update) u_ps.subscribe("PLCOnline", plc_online.update)
r_ps.subscribe("PLCHeartbeat", plc_hbeat.update) u_ps.subscribe("PLCHeartbeat", plc_hbeat.update)
r_ps.subscribe("status", r_active.update) u_ps.subscribe("RadiationMonitor", rad_mon.update)
annunciator.line_break() annunciator.line_break()
-- annunciator fields -- operating state
local r_active = IndicatorLight{parent=annunciator,label="Active",colors=cpair(colors.green,colors.gray)}
local r_auto = IndicatorLight{parent=annunciator,label="Automatic Control",colors=cpair(colors.white,colors.gray)}
u_ps.subscribe("status", r_active.update)
u_ps.subscribe("AutoControl", r_auto.update)
-- main unit transient/warning annunciator panel
local r_scram = IndicatorLight{parent=annunciator,label="Reactor SCRAM",colors=cpair(colors.red,colors.gray)} local r_scram = IndicatorLight{parent=annunciator,label="Reactor SCRAM",colors=cpair(colors.red,colors.gray)}
local r_mscrm = IndicatorLight{parent=annunciator,label="Manual Reactor SCRAM",colors=cpair(colors.red,colors.gray)} local r_mscrm = IndicatorLight{parent=annunciator,label="Manual Reactor SCRAM",colors=cpair(colors.red,colors.gray)}
local r_ascrm = IndicatorLight{parent=annunciator,label="Auto Reactor SCRAM",colors=cpair(colors.red,colors.gray)}
local rad_wrn = IndicatorLight{parent=annunciator,label="Radiation Warning",colors=cpair(colors.yellow,colors.gray)}
local r_rtrip = IndicatorLight{parent=annunciator,label="RCP Trip",colors=cpair(colors.red,colors.gray)} local r_rtrip = IndicatorLight{parent=annunciator,label="RCP Trip",colors=cpair(colors.red,colors.gray)}
local r_cflow = IndicatorLight{parent=annunciator,label="RCS Flow Low",colors=cpair(colors.yellow,colors.gray)} local r_cflow = IndicatorLight{parent=annunciator,label="RCS Flow Low",colors=cpair(colors.yellow,colors.gray)}
local r_clow = IndicatorLight{parent=annunciator,label="Coolant Level Low",colors=cpair(colors.yellow,colors.gray)}
local r_temp = IndicatorLight{parent=annunciator,label="Reactor Temp. High",colors=cpair(colors.red,colors.gray)} local r_temp = IndicatorLight{parent=annunciator,label="Reactor Temp. High",colors=cpair(colors.red,colors.gray)}
local r_rhdt = IndicatorLight{parent=annunciator,label="Reactor High Delta T",colors=cpair(colors.yellow,colors.gray)} local r_rhdt = IndicatorLight{parent=annunciator,label="Reactor High Delta T",colors=cpair(colors.yellow,colors.gray)}
local r_firl = IndicatorLight{parent=annunciator,label="Fuel Input Rate Low",colors=cpair(colors.yellow,colors.gray)} local r_firl = IndicatorLight{parent=annunciator,label="Fuel Input Rate Low",colors=cpair(colors.yellow,colors.gray)}
local r_wloc = IndicatorLight{parent=annunciator,label="Waste Line Occlusion",colors=cpair(colors.yellow,colors.gray)} local r_wloc = IndicatorLight{parent=annunciator,label="Waste Line Occlusion",colors=cpair(colors.yellow,colors.gray)}
local r_hsrt = IndicatorLight{parent=annunciator,label="High Startup Rate",colors=cpair(colors.yellow,colors.gray)} local r_hsrt = IndicatorLight{parent=annunciator,label="Startup Rate High",colors=cpair(colors.yellow,colors.gray)}
r_ps.subscribe("ReactorSCRAM", r_scram.update) u_ps.subscribe("ReactorSCRAM", r_scram.update)
r_ps.subscribe("ManualReactorSCRAM", r_mscrm.update) u_ps.subscribe("ManualReactorSCRAM", r_mscrm.update)
r_ps.subscribe("RCPTrip", r_rtrip.update) u_ps.subscribe("AutoReactorSCRAM", r_ascrm.update)
r_ps.subscribe("RCSFlowLow", r_cflow.update) u_ps.subscribe("RadiationWarning", rad_wrn.update)
r_ps.subscribe("ReactorTempHigh", r_temp.update) u_ps.subscribe("RCPTrip", r_rtrip.update)
r_ps.subscribe("ReactorHighDeltaT", r_rhdt.update) u_ps.subscribe("RCSFlowLow", r_cflow.update)
r_ps.subscribe("FuelInputRateLow", r_firl.update) u_ps.subscribe("CoolantLevelLow", r_clow.update)
r_ps.subscribe("WasteLineOcclusion", r_wloc.update) u_ps.subscribe("ReactorTempHigh", r_temp.update)
r_ps.subscribe("HighStartupRate", r_hsrt.update) u_ps.subscribe("ReactorHighDeltaT", r_rhdt.update)
u_ps.subscribe("FuelInputRateLow", r_firl.update)
u_ps.subscribe("WasteLineOcclusion", r_wloc.update)
u_ps.subscribe("HighStartupRate", r_hsrt.update)
annunciator.line_break() -- RPS annunciator panel
-- RPS TextBox{parent=main,text="REACTOR PROTECTION SYSTEM",fg_bg=cpair(colors.black,colors.cyan),alignment=TEXT_ALIGN.CENTER,width=33,height=1,x=46,y=8}
local rps_trp = IndicatorLight{parent=annunciator,label="RPS Trip",colors=cpair(colors.red,colors.gray)} local rps = Rectangle{parent=main,border=border(1,colors.cyan,true),thin=true,width=33,height=12,x=46,y=9}
local rps_dmg = IndicatorLight{parent=annunciator,label="Damage Critical",colors=cpair(colors.yellow,colors.gray)} local rps_annunc = Div{parent=rps,width=31,height=10,x=2,y=1}
local rps_exh = IndicatorLight{parent=annunciator,label="Excess Heated Coolant",colors=cpair(colors.yellow,colors.gray)}
local rps_exw = IndicatorLight{parent=annunciator,label="Excess Waste",colors=cpair(colors.yellow,colors.gray)}
local rps_tmp = IndicatorLight{parent=annunciator,label="High Core Temp",colors=cpair(colors.yellow,colors.gray)}
local rps_nof = IndicatorLight{parent=annunciator,label="No Fuel",colors=cpair(colors.yellow,colors.gray)}
local rps_noc = IndicatorLight{parent=annunciator,label="No Coolant",colors=cpair(colors.yellow,colors.gray)}
local rps_flt = IndicatorLight{parent=annunciator,label="PPM Fault",colors=cpair(colors.yellow,colors.gray)}
local rps_tmo = IndicatorLight{parent=annunciator,label="Timeout",colors=cpair(colors.yellow,colors.gray)}
r_ps.subscribe("rps_tripped", rps_trp.update) local rps_trp = IndicatorLight{parent=rps_annunc,label="RPS Trip",colors=cpair(colors.red,colors.gray),flash=true,period=period.BLINK_250_MS}
r_ps.subscribe("dmg_crit", rps_dmg.update) local rps_dmg = IndicatorLight{parent=rps_annunc,label="Damage Level High",colors=cpair(colors.red,colors.gray),flash=true,period=period.BLINK_250_MS}
r_ps.subscribe("ex_hcool", rps_exh.update) local rps_exh = IndicatorLight{parent=rps_annunc,label="Excess Heated Coolant",colors=cpair(colors.yellow,colors.gray)}
r_ps.subscribe("ex_waste", rps_exw.update) local rps_exw = IndicatorLight{parent=rps_annunc,label="Excess Waste",colors=cpair(colors.yellow,colors.gray)}
r_ps.subscribe("high_temp", rps_tmp.update) local rps_tmp = IndicatorLight{parent=rps_annunc,label="Core Temperature High",colors=cpair(colors.red,colors.gray),flash=true,period=period.BLINK_250_MS}
r_ps.subscribe("no_fuel", rps_nof.update) local rps_nof = IndicatorLight{parent=rps_annunc,label="No Fuel",colors=cpair(colors.yellow,colors.gray)}
r_ps.subscribe("no_cool", rps_noc.update) local rps_loc = IndicatorLight{parent=rps_annunc,label="Coolant Level Low Low",colors=cpair(colors.yellow,colors.gray)}
r_ps.subscribe("fault", rps_flt.update) local rps_flt = IndicatorLight{parent=rps_annunc,label="PPM Fault",colors=cpair(colors.yellow,colors.gray),flash=true,period=period.BLINK_500_MS}
r_ps.subscribe("timeout", rps_tmo.update) local rps_tmo = IndicatorLight{parent=rps_annunc,label="Connection Timeout",colors=cpair(colors.yellow,colors.gray),flash=true,period=period.BLINK_500_MS}
local rps_sfl = IndicatorLight{parent=rps_annunc,label="System Failure",colors=cpair(colors.orange,colors.gray),flash=true,period=period.BLINK_500_MS}
annunciator.line_break() u_ps.subscribe("rps_tripped", rps_trp.update)
u_ps.subscribe("high_dmg", rps_dmg.update)
u_ps.subscribe("ex_hcool", rps_exh.update)
u_ps.subscribe("ex_waste", rps_exw.update)
u_ps.subscribe("high_temp", rps_tmp.update)
u_ps.subscribe("no_fuel", rps_nof.update)
u_ps.subscribe("low_cool", rps_loc.update)
u_ps.subscribe("fault", rps_flt.update)
u_ps.subscribe("timeout", rps_tmo.update)
u_ps.subscribe("sys_fail", rps_sfl.update)
-- cooling -- cooling annunciator panel
local c_brm = IndicatorLight{parent=annunciator,label="Boil Rate Mismatch",colors=cpair(colors.yellow,colors.gray)}
local c_cfm = IndicatorLight{parent=annunciator,label="Coolant Feed Mismatch",colors=cpair(colors.yellow,colors.gray)}
local c_sfm = IndicatorLight{parent=annunciator,label="Steam Feed Mismatch",colors=cpair(colors.yellow,colors.gray)}
local c_mwrf = IndicatorLight{parent=annunciator,label="Max Water Return Feed",colors=cpair(colors.yellow,colors.gray)}
local c_tbnt = IndicatorLight{parent=annunciator,label="Turbine Trip",colors=cpair(colors.red,colors.gray)}
r_ps.subscribe("BoilRateMismatch", c_brm.update) TextBox{parent=main,text="REACTOR COOLANT SYSTEM",fg_bg=cpair(colors.black,colors.blue),alignment=TEXT_ALIGN.CENTER,width=33,height=1,x=46,y=22}
r_ps.subscribe("CoolantFeedMismatch", c_cfm.update) local rcs = Rectangle{parent=main,border=border(1,colors.blue,true),thin=true,width=33,height=24,x=46,y=23}
r_ps.subscribe("SteamFeedMismatch", c_sfm.update) local rcs_annunc = Div{parent=rcs,width=27,height=23,x=2,y=1}
r_ps.subscribe("MaxWaterReturnFeed", c_mwrf.update) local rcs_tags = Div{parent=rcs,width=2,height=14,x=29,y=9}
r_ps.subscribe("TurbineTrip", c_tbnt.update)
annunciator.line_break() local c_flt = IndicatorLight{parent=rcs_annunc,label="RCS Hardware Fault",colors=cpair(colors.yellow,colors.gray)}
local c_emg = TriIndicatorLight{parent=rcs_annunc,label="Emergency Coolant",c1=colors.gray,c2=colors.white,c3=colors.green}
local c_cfm = IndicatorLight{parent=rcs_annunc,label="Coolant Feed Mismatch",colors=cpair(colors.yellow,colors.gray)}
local c_brm = IndicatorLight{parent=rcs_annunc,label="Boil Rate Mismatch",colors=cpair(colors.yellow,colors.gray)}
local c_sfm = IndicatorLight{parent=rcs_annunc,label="Steam Feed Mismatch",colors=cpair(colors.yellow,colors.gray)}
local c_mwrf = IndicatorLight{parent=rcs_annunc,label="Max Water Return Feed",colors=cpair(colors.yellow,colors.gray)}
local c_tbnt = IndicatorLight{parent=rcs_annunc,label="Turbine Trip",colors=cpair(colors.red,colors.gray),flash=true,period=period.BLINK_250_MS}
u_ps.subscribe("RCSFault", c_flt.update)
u_ps.subscribe("EmergencyCoolant", c_emg.update)
u_ps.subscribe("CoolantFeedMismatch", c_cfm.update)
u_ps.subscribe("BoilRateMismatch", c_brm.update)
u_ps.subscribe("SteamFeedMismatch", c_sfm.update)
u_ps.subscribe("MaxWaterReturnFeed", c_mwrf.update)
u_ps.subscribe("TurbineTrip", c_tbnt.update)
rcs_annunc.line_break()
-- boiler annunciator panel(s)
-- machine-specific indicators
if unit.num_boilers > 0 then if unit.num_boilers > 0 then
TextBox{parent=main,x=32,y=34,text="B1",width=2,height=1,fg_bg=cpair(colors.black, colors.white)} TextBox{parent=rcs_tags,x=1,text="B1",width=2,height=1,fg_bg=bw_fg_bg}
local b1_hr = IndicatorLight{parent=annunciator,label="Heating Rate Low",colors=cpair(colors.yellow,colors.gray)} local b1_wll = IndicatorLight{parent=rcs_annunc,label="Water Level Low",colors=cpair(colors.red,colors.gray)}
b_ps[1].subscribe("WasterLevelLow", b1_wll.update)
TextBox{parent=rcs_tags,text="B1",width=2,height=1,fg_bg=bw_fg_bg}
local b1_hr = IndicatorLight{parent=rcs_annunc,label="Heating Rate Low",colors=cpair(colors.yellow,colors.gray)}
b_ps[1].subscribe("HeatingRateLow", b1_hr.update) b_ps[1].subscribe("HeatingRateLow", b1_hr.update)
end end
if unit.num_boilers > 1 then if unit.num_boilers > 1 then
TextBox{parent=main,x=32,text="B2",width=2,height=1,fg_bg=cpair(colors.black, colors.white)} TextBox{parent=rcs_tags,text="B2",width=2,height=1,fg_bg=bw_fg_bg}
local b2_hr = IndicatorLight{parent=annunciator,label="Heating Rate Low",colors=cpair(colors.yellow,colors.gray)} local b2_wll = IndicatorLight{parent=rcs_annunc,label="Water Level Low",colors=cpair(colors.red,colors.gray)}
b_ps[2].subscribe("WasterLevelLow", b2_wll.update)
TextBox{parent=rcs_tags,text="B2",width=2,height=1,fg_bg=bw_fg_bg}
local b2_hr = IndicatorLight{parent=rcs_annunc,label="Heating Rate Low",colors=cpair(colors.yellow,colors.gray)}
b_ps[2].subscribe("HeatingRateLow", b2_hr.update) b_ps[2].subscribe("HeatingRateLow", b2_hr.update)
end end
if unit.num_boilers > 0 then -- turbine annunciator panels
main.line_break()
annunciator.line_break() if unit.num_boilers == 0 then
TextBox{parent=rcs_tags,text="T1",width=2,height=1,fg_bg=bw_fg_bg}
else
rcs_tags.line_break()
rcs_annunc.line_break()
TextBox{parent=rcs_tags,text="T1",width=2,height=1,fg_bg=bw_fg_bg}
end end
TextBox{parent=main,x=32,text="T1",width=2,height=1,fg_bg=cpair(colors.black, colors.white)} local t1_sdo = TriIndicatorLight{parent=rcs_annunc,label="Steam Relief Valve Open",c1=colors.gray,c2=colors.yellow,c3=colors.red}
local t1_sdo = TriIndicatorLight{parent=annunciator,label="Steam Dump Open",c1=colors.gray,c2=colors.yellow,c3=colors.red}
t_ps[1].subscribe("SteamDumpOpen", t1_sdo.update) t_ps[1].subscribe("SteamDumpOpen", t1_sdo.update)
TextBox{parent=main,x=32,text="T1",width=2,height=1,fg_bg=cpair(colors.black, colors.white)} TextBox{parent=rcs_tags,text="T1",width=2,height=1,fg_bg=bw_fg_bg}
local t1_tos = IndicatorLight{parent=annunciator,label="Turbine Over Speed",colors=cpair(colors.red,colors.gray)} local t1_tos = IndicatorLight{parent=rcs_annunc,label="Turbine Over Speed",colors=cpair(colors.red,colors.gray)}
t_ps[1].subscribe("TurbineOverSpeed", t1_tos.update) t_ps[1].subscribe("TurbineOverSpeed", t1_tos.update)
TextBox{parent=main,x=32,text="T1",width=2,height=1,fg_bg=cpair(colors.black, colors.white)} TextBox{parent=rcs_tags,text="T1",width=2,height=1,fg_bg=bw_fg_bg}
local t1_trp = IndicatorLight{parent=annunciator,label="Turbine Trip",colors=cpair(colors.red,colors.gray)} local t1_trp = IndicatorLight{parent=rcs_annunc,label="Turbine Trip",colors=cpair(colors.red,colors.gray),flash=true,period=period.BLINK_250_MS}
t_ps[1].subscribe("TurbineTrip", t1_trp.update) t_ps[1].subscribe("TurbineTrip", t1_trp.update)
main.line_break()
annunciator.line_break()
if unit.num_turbines > 1 then if unit.num_turbines > 1 then
TextBox{parent=main,x=32,text="T2",width=2,height=1,fg_bg=cpair(colors.black, colors.white)} TextBox{parent=rcs_tags,text="T2",width=2,height=1,fg_bg=bw_fg_bg}
local t2_sdo = TriIndicatorLight{parent=annunciator,label="Steam Dump Open",c1=colors.gray,c2=colors.yellow,c3=colors.red} local t2_sdo = TriIndicatorLight{parent=rcs_annunc,label="Steam Relief Valve Open",c1=colors.gray,c2=colors.yellow,c3=colors.red}
t_ps[2].subscribe("SteamDumpOpen", t2_sdo.update) t_ps[2].subscribe("SteamDumpOpen", t2_sdo.update)
TextBox{parent=main,x=32,text="T2",width=2,height=1,fg_bg=cpair(colors.black, colors.white)} TextBox{parent=rcs_tags,text="T2",width=2,height=1,fg_bg=bw_fg_bg}
local t2_tos = IndicatorLight{parent=annunciator,label="Turbine Over Speed",colors=cpair(colors.red,colors.gray)} local t2_tos = IndicatorLight{parent=rcs_annunc,label="Turbine Over Speed",colors=cpair(colors.red,colors.gray)}
t_ps[2].subscribe("TurbineOverSpeed", t2_tos.update) t_ps[2].subscribe("TurbineOverSpeed", t2_tos.update)
TextBox{parent=main,x=32,text="T2",width=2,height=1,fg_bg=cpair(colors.black, colors.white)} TextBox{parent=rcs_tags,text="T2",width=2,height=1,fg_bg=bw_fg_bg}
local t2_trp = IndicatorLight{parent=annunciator,label="Turbine Trip",colors=cpair(colors.red,colors.gray)} local t2_trp = IndicatorLight{parent=rcs_annunc,label="Turbine Trip",colors=cpair(colors.red,colors.gray),flash=true,period=period.BLINK_250_MS}
t_ps[2].subscribe("TurbineTrip", t2_trp.update) t_ps[2].subscribe("TurbineTrip", t2_trp.update)
main.line_break()
annunciator.line_break()
end end
if unit.num_turbines > 2 then if unit.num_turbines > 2 then
TextBox{parent=main,x=32,text="T3",width=2,height=1,fg_bg=cpair(colors.black, colors.white)} TextBox{parent=rcs_tags,text="T3",width=2,height=1,fg_bg=bw_fg_bg}
local t3_sdo = TriIndicatorLight{parent=annunciator,label="Steam Dump Open",c1=colors.gray,c2=colors.yellow,c3=colors.red} local t3_sdo = TriIndicatorLight{parent=rcs_annunc,label="Steam Relief Valve Open",c1=colors.gray,c2=colors.yellow,c3=colors.red}
t_ps[3].subscribe("SteamDumpOpen", t3_sdo.update) t_ps[3].subscribe("SteamDumpOpen", t3_sdo.update)
TextBox{parent=main,x=32,text="T3",width=2,height=1,fg_bg=cpair(colors.black, colors.white)} TextBox{parent=rcs_tags,text="T3",width=2,height=1,fg_bg=bw_fg_bg}
local t3_tos = IndicatorLight{parent=annunciator,label="Turbine Over Speed",colors=cpair(colors.red,colors.gray)} local t3_tos = IndicatorLight{parent=rcs_annunc,label="Turbine Over Speed",colors=cpair(colors.red,colors.gray)}
t_ps[3].subscribe("TurbineOverSpeed", t3_tos.update) t_ps[3].subscribe("TurbineOverSpeed", t3_tos.update)
TextBox{parent=main,x=32,text="T3",width=2,height=1,fg_bg=cpair(colors.black, colors.white)} TextBox{parent=rcs_tags,text="T3",width=2,height=1,fg_bg=bw_fg_bg}
local t3_trp = IndicatorLight{parent=annunciator,label="Turbine Trip",colors=cpair(colors.red,colors.gray)} local t3_trp = IndicatorLight{parent=rcs_annunc,label="Turbine Trip",colors=cpair(colors.red,colors.gray),flash=true,period=period.BLINK_250_MS}
t_ps[3].subscribe("TurbineTrip", t3_trp.update) t_ps[3].subscribe("TurbineTrip", t3_trp.update)
annunciator.line_break()
end end
---@todo radiation monitor ----------------------
IndicatorLight{parent=annunciator,label="Radiation Monitor",colors=cpair(colors.green,colors.gray)}
IndicatorLight{parent=annunciator,label="Radiation Alarm",colors=cpair(colors.red,colors.gray)}
DataIndicator{parent=main,x=34,y=51,label="",format="%10.1f",value=0,unit="mSv/h",lu_colors=lu_cpair,width=18,fg_bg=stat_fg_bg}
-- reactor controls -- -- reactor controls --
----------------------
StartButton{parent=main,x=12,y=44,callback=unit.start,fg_bg=scram_fg_bg} local dis_colors = cpair(colors.white, colors.lightGray)
SCRAMButton{parent=main,x=22,y=44,callback=unit.scram,fg_bg=scram_fg_bg}
local burn_control = Div{parent=main,x=12,y=40,width=19,height=3,fg_bg=cpair(colors.gray,colors.white)} local burn_control = Div{parent=main,x=12,y=28,width=19,height=3,fg_bg=cpair(colors.gray,colors.white)}
local burn_rate = SpinboxNumeric{parent=burn_control,x=2,y=1,whole_num_precision=4,fractional_precision=1,arrow_fg_bg=cpair(colors.gray,colors.white),fg_bg=cpair(colors.black,colors.white)} local burn_rate = SpinboxNumeric{parent=burn_control,x=2,y=1,whole_num_precision=4,fractional_precision=1,min=0.1,arrow_fg_bg=cpair(colors.gray,colors.white),fg_bg=bw_fg_bg}
TextBox{parent=burn_control,x=9,y=2,text="mB/t"} TextBox{parent=burn_control,x=9,y=2,text="mB/t"}
local set_burn = function () unit.set_burn(burn_rate.get_value()) end local set_burn = function () unit.set_burn(burn_rate.get_value()) end
PushButton{parent=burn_control,x=14,y=2,text="SET",min_width=5,fg_bg=cpair(colors.black,colors.yellow),active_fg_bg=cpair(colors.white,colors.gray),callback=set_burn} local set_burn_btn = PushButton{parent=burn_control,x=14,y=2,text="SET",min_width=5,fg_bg=cpair(colors.black,colors.yellow),active_fg_bg=cpair(colors.white,colors.gray),dis_fg_bg=dis_colors,callback=set_burn}
local opts = { u_ps.subscribe("burn_rate", burn_rate.set_value)
{ u_ps.subscribe("max_burn", burn_rate.set_max)
text = "Auto",
fg_bg = cpair(colors.black, colors.lightGray),
active_fg_bg = cpair(colors.white, colors.gray)
},
{
text = "Pu",
fg_bg = cpair(colors.black, colors.lightGray),
active_fg_bg = cpair(colors.black, colors.lime)
},
{
text = "Po",
fg_bg = cpair(colors.black, colors.lightGray),
active_fg_bg = cpair(colors.black, colors.cyan)
},
{
text = "AM",
fg_bg = cpair(colors.black, colors.lightGray),
active_fg_bg = cpair(colors.black, colors.purple)
}
}
---@todo waste selection local start = HazardButton{parent=main,x=2,y=28,text="START",accent=colors.lightBlue,dis_colors=dis_colors,callback=unit.start,fg_bg=hzd_fg_bg}
local waste_sel_f = function (s) print("waste: " .. s) end local ack_a = HazardButton{parent=main,x=12,y=32,text="ACK \x13",accent=colors.orange,dis_colors=dis_colors,callback=unit.ack_alarms,fg_bg=hzd_fg_bg}
local waste_sel = Div{parent=main,x=2,y=48,width=29,height=2,fg_bg=cpair(colors.black, colors.white)} local scram = HazardButton{parent=main,x=2,y=32,text="SCRAM",accent=colors.yellow,dis_colors=dis_colors,callback=unit.scram,fg_bg=hzd_fg_bg}
local reset = HazardButton{parent=main,x=22,y=32,text="RESET",accent=colors.red,dis_colors=dis_colors,callback=unit.reset_rps,fg_bg=hzd_fg_bg}
MultiButton{parent=waste_sel,x=1,y=1,options=opts,callback=waste_sel_f,min_width=6,fg_bg=cpair(colors.black, colors.white)} unit.start_ack = start.on_response
TextBox{parent=waste_sel,text="Waste Processing",alignment=TEXT_ALIGN.CENTER,x=1,y=1,height=1} unit.scram_ack = scram.on_response
unit.reset_rps_ack = reset.on_response
unit.ack_alarms_ack = ack_a.on_response
---@fixme test code local function start_button_en_check()
main.line_break() if (unit.reactor_data ~= nil) and (unit.reactor_data.mek_status ~= nil) then
ColorMap{parent=main,x=2,y=51} local can_start = (not unit.reactor_data.mek_status.status) and
(not unit.reactor_data.rps_tripped) and
(unit.a_group == 0)
if can_start then start.enable() else start.disable() end
end
end
u_ps.subscribe("status", start_button_en_check)
u_ps.subscribe("rps_tripped", start_button_en_check)
u_ps.subscribe("rps_tripped", function (active) if active then reset.enable() else reset.disable() end end)
TextBox{parent=main,text="WASTE PROCESSING",fg_bg=cpair(colors.black,colors.brown),alignment=TEXT_ALIGN.CENTER,width=33,height=1,x=46,y=48}
local waste_proc = Rectangle{parent=main,border=border(1,colors.brown,true),thin=true,width=33,height=3,x=46,y=49}
local waste_div = Div{parent=waste_proc,x=2,y=1,width=31,height=1}
local waste_mode = MultiButton{parent=waste_div,x=1,y=1,options=waste_opts,callback=unit.set_waste,min_width=6}
u_ps.subscribe("U_WasteMode", waste_mode.set_value)
----------------------
-- alarm management --
----------------------
local alarm_panel = Div{parent=main,x=2,y=36,width=29,height=16,fg_bg=bw_fg_bg}
local a_brc = AlarmLight{parent=alarm_panel,x=6,y=2,label="Containment Breach",c1=colors.gray,c2=colors.red,c3=colors.green,flash=true,period=period.BLINK_250_MS}
local a_rad = AlarmLight{parent=alarm_panel,x=6,label="Containment Radiation",c1=colors.gray,c2=colors.red,c3=colors.green,flash=true,period=period.BLINK_250_MS}
local a_dmg = AlarmLight{parent=alarm_panel,x=6,label="Critical Damage",c1=colors.gray,c2=colors.red,c3=colors.green,flash=true,period=period.BLINK_250_MS}
alarm_panel.line_break()
local a_rcl = AlarmLight{parent=alarm_panel,x=6,label="Reactor Lost",c1=colors.gray,c2=colors.red,c3=colors.green,flash=true,period=period.BLINK_250_MS}
local a_rcd = AlarmLight{parent=alarm_panel,x=6,label="Reactor Damage",c1=colors.gray,c2=colors.red,c3=colors.green,flash=true,period=period.BLINK_250_MS}
local a_rot = AlarmLight{parent=alarm_panel,x=6,label="Reactor Over Temp",c1=colors.gray,c2=colors.red,c3=colors.green,flash=true,period=period.BLINK_250_MS}
local a_rht = AlarmLight{parent=alarm_panel,x=6,label="Reactor High Temp",c1=colors.gray,c2=colors.yellow,c3=colors.green,flash=true,period=period.BLINK_500_MS}
local a_rwl = AlarmLight{parent=alarm_panel,x=6,label="Reactor Waste Leak",c1=colors.gray,c2=colors.red,c3=colors.green,flash=true,period=period.BLINK_250_MS}
local a_rwh = AlarmLight{parent=alarm_panel,x=6,label="Reactor Waste High",c1=colors.gray,c2=colors.yellow,c3=colors.green,flash=true,period=period.BLINK_500_MS}
alarm_panel.line_break()
local a_rps = AlarmLight{parent=alarm_panel,x=6,label="RPS Transient",c1=colors.gray,c2=colors.yellow,c3=colors.green,flash=true,period=period.BLINK_500_MS}
local a_clt = AlarmLight{parent=alarm_panel,x=6,label="RCS Transient",c1=colors.gray,c2=colors.yellow,c3=colors.green,flash=true,period=period.BLINK_500_MS}
local a_tbt = AlarmLight{parent=alarm_panel,x=6,label="Turbine Trip",c1=colors.gray,c2=colors.red,c3=colors.green,flash=true,period=period.BLINK_250_MS}
u_ps.subscribe("Alarm_1", a_brc.update)
u_ps.subscribe("Alarm_2", a_rad.update)
u_ps.subscribe("Alarm_4", a_dmg.update)
u_ps.subscribe("Alarm_3", a_rcl.update)
u_ps.subscribe("Alarm_5", a_rcd.update)
u_ps.subscribe("Alarm_6", a_rot.update)
u_ps.subscribe("Alarm_7", a_rht.update)
u_ps.subscribe("Alarm_8", a_rwl.update)
u_ps.subscribe("Alarm_9", a_rwh.update)
u_ps.subscribe("Alarm_10", a_rps.update)
u_ps.subscribe("Alarm_11", a_clt.update)
u_ps.subscribe("Alarm_12", a_tbt.update)
-- ack's and resets
local c = unit.alarm_callbacks
local ack_fg_bg = cpair(colors.black, colors.orange)
local rst_fg_bg = cpair(colors.black, colors.lime)
local active_fg_bg = cpair(colors.white, colors.gray)
PushButton{parent=alarm_panel,x=2,y=2,text="\x13",callback=c.c_breach.ack,fg_bg=ack_fg_bg,active_fg_bg=active_fg_bg}
PushButton{parent=alarm_panel,x=4,y=2,text="R",callback=c.c_breach.reset,fg_bg=rst_fg_bg,active_fg_bg=active_fg_bg}
PushButton{parent=alarm_panel,x=2,y=3,text="\x13",callback=c.radiation.ack,fg_bg=ack_fg_bg,active_fg_bg=active_fg_bg}
PushButton{parent=alarm_panel,x=4,y=3,text="R",callback=c.radiation.reset,fg_bg=rst_fg_bg,active_fg_bg=active_fg_bg}
PushButton{parent=alarm_panel,x=2,y=4,text="\x13",callback=c.dmg_crit.ack,fg_bg=ack_fg_bg,active_fg_bg=active_fg_bg}
PushButton{parent=alarm_panel,x=4,y=4,text="R",callback=c.dmg_crit.reset,fg_bg=rst_fg_bg,active_fg_bg=active_fg_bg}
PushButton{parent=alarm_panel,x=2,y=6,text="\x13",callback=c.r_lost.ack,fg_bg=ack_fg_bg,active_fg_bg=active_fg_bg}
PushButton{parent=alarm_panel,x=4,y=6,text="R",callback=c.r_lost.reset,fg_bg=rst_fg_bg,active_fg_bg=active_fg_bg}
PushButton{parent=alarm_panel,x=2,y=7,text="\x13",callback=c.damage.ack,fg_bg=ack_fg_bg,active_fg_bg=active_fg_bg}
PushButton{parent=alarm_panel,x=4,y=7,text="R",callback=c.damage.reset,fg_bg=rst_fg_bg,active_fg_bg=active_fg_bg}
PushButton{parent=alarm_panel,x=2,y=8,text="\x13",callback=c.over_temp.ack,fg_bg=ack_fg_bg,active_fg_bg=active_fg_bg}
PushButton{parent=alarm_panel,x=4,y=8,text="R",callback=c.over_temp.reset,fg_bg=rst_fg_bg,active_fg_bg=active_fg_bg}
PushButton{parent=alarm_panel,x=2,y=9,text="\x13",callback=c.high_temp.ack,fg_bg=ack_fg_bg,active_fg_bg=active_fg_bg}
PushButton{parent=alarm_panel,x=4,y=9,text="R",callback=c.high_temp.reset,fg_bg=rst_fg_bg,active_fg_bg=active_fg_bg}
PushButton{parent=alarm_panel,x=2,y=10,text="\x13",callback=c.waste_leak.ack,fg_bg=ack_fg_bg,active_fg_bg=active_fg_bg}
PushButton{parent=alarm_panel,x=4,y=10,text="R",callback=c.waste_leak.reset,fg_bg=rst_fg_bg,active_fg_bg=active_fg_bg}
PushButton{parent=alarm_panel,x=2,y=11,text="\x13",callback=c.waste_high.ack,fg_bg=ack_fg_bg,active_fg_bg=active_fg_bg}
PushButton{parent=alarm_panel,x=4,y=11,text="R",callback=c.waste_high.reset,fg_bg=rst_fg_bg,active_fg_bg=active_fg_bg}
PushButton{parent=alarm_panel,x=2,y=13,text="\x13",callback=c.rps_trans.ack,fg_bg=ack_fg_bg,active_fg_bg=active_fg_bg}
PushButton{parent=alarm_panel,x=4,y=13,text="R",callback=c.rps_trans.reset,fg_bg=rst_fg_bg,active_fg_bg=active_fg_bg}
PushButton{parent=alarm_panel,x=2,y=14,text="\x13",callback=c.rcs_trans.ack,fg_bg=ack_fg_bg,active_fg_bg=active_fg_bg}
PushButton{parent=alarm_panel,x=4,y=14,text="R",callback=c.rcs_trans.reset,fg_bg=rst_fg_bg,active_fg_bg=active_fg_bg}
PushButton{parent=alarm_panel,x=2,y=15,text="\x13",callback=c.t_trip.ack,fg_bg=ack_fg_bg,active_fg_bg=active_fg_bg}
PushButton{parent=alarm_panel,x=4,y=15,text="R",callback=c.t_trip.reset,fg_bg=rst_fg_bg,active_fg_bg=active_fg_bg}
-- color tags
TextBox{parent=alarm_panel,x=5,y=13,text="\x95",width=1,height=1,fg_bg=cpair(colors.white,colors.cyan)}
TextBox{parent=alarm_panel,x=5,text="\x95",width=1,height=1,fg_bg=cpair(colors.white,colors.blue)}
TextBox{parent=alarm_panel,x=5,text="\x95",width=1,height=1,fg_bg=cpair(colors.white,colors.blue)}
--------------------------------
-- automatic control settings --
--------------------------------
TextBox{parent=main,text="AUTO CTRL",fg_bg=cpair(colors.black,colors.purple),alignment=TEXT_ALIGN.CENTER,width=13,height=1,x=32,y=36}
local auto_ctl = Rectangle{parent=main,border=border(1,colors.purple,true),thin=true,width=13,height=15,x=32,y=37}
local auto_div = Div{parent=auto_ctl,width=13,height=15,x=1,y=1}
local ctl_opts = { "Manual", "Primary", "Secondary", "Tertiary", "Backup" }
local group = RadioButton{parent=auto_div,options=ctl_opts,callback=function()end,radio_colors=cpair(colors.blue,colors.white),radio_bg=colors.gray}
u_ps.subscribe("auto_group_id", function (gid) group.set_value(gid + 1) end)
auto_div.line_break()
local function set_group() unit.set_group(group.get_value() - 1) end
local set_grp_btn = PushButton{parent=auto_div,text="SET",x=4,min_width=5,fg_bg=cpair(colors.black,colors.yellow),active_fg_bg=cpair(colors.white,colors.gray),dis_fg_bg=cpair(colors.gray,colors.white),callback=set_group}
auto_div.line_break()
TextBox{parent=auto_div,text="Prio. Group",height=1,width=11,fg_bg=style.label}
local auto_grp = TextBox{parent=auto_div,text="Manual",height=1,width=11,fg_bg=bw_fg_bg}
u_ps.subscribe("auto_group", auto_grp.set_value)
auto_div.line_break()
local a_rdy = IndicatorLight{parent=auto_div,label="Ready",x=2,colors=cpair(colors.green,colors.gray)}
local a_stb = IndicatorLight{parent=auto_div,label="Standby",x=2,colors=cpair(colors.white,colors.gray),flash=true,period=period.BLINK_1000_MS}
u_ps.subscribe("U_AutoReady", a_rdy.update)
-- update standby indicator
u_ps.subscribe("status", function (active)
a_stb.update(unit.annunciator.AutoControl and (not active))
end)
-- enable and disable controls based on group assignment
u_ps.subscribe("auto_group_id", function (gid)
start_button_en_check()
if gid == 0 then
burn_rate.enable()
set_burn_btn.enable()
else
burn_rate.disable()
set_burn_btn.disable()
end
end)
-- enable and disable controls based on auto control state (start button is handled separately)
u_ps.subscribe("AutoControl", function (auto_active)
start_button_en_check()
if auto_active then
a_stb.update(unit.reactor_data.mek_status.status == false)
else a_stb.update(false) end
end)
-- can't change group if auto is engaged regardless of if this unit is part of auto control
f_ps.subscribe("auto_active", function (auto_active)
if auto_active then set_grp_btn.disable() else set_grp_btn.enable() end
end)
return main return main
end end

View File

@@ -2,29 +2,27 @@
-- Basic Unit Overview -- Basic Unit Overview
-- --
local core = require("graphics.core") local core = require("graphics.core")
local style = require("coordinator.ui.style") local style = require("coordinator.ui.style")
local reactor_view = require("coordinator.ui.components.reactor") local reactor_view = require("coordinator.ui.components.reactor")
local boiler_view = require("coordinator.ui.components.boiler") local boiler_view = require("coordinator.ui.components.boiler")
local turbine_view = require("coordinator.ui.components.turbine") local turbine_view = require("coordinator.ui.components.turbine")
local Div = require("graphics.elements.div") local Div = require("graphics.elements.div")
local PipeNetwork = require("graphics.elements.pipenet") local PipeNetwork = require("graphics.elements.pipenet")
local TextBox = require("graphics.elements.textbox") local TextBox = require("graphics.elements.textbox")
local TEXT_ALIGN = core.graphics.TEXT_ALIGN local TEXT_ALIGN = core.graphics.TEXT_ALIGN
local cpair = core.graphics.cpair
local border = core.graphics.border
local pipe = core.graphics.pipe local pipe = core.graphics.pipe
-- make a new unit overview window -- make a new unit overview window
---@param parent graphics_element parent ---@param parent graphics_element parent
---@param x integer top left x ---@param x integer top left x
---@param y integer top left y ---@param y integer top left y
---@param unit ioctl_entry unit database entry ---@param unit ioctl_unit unit database entry
local function make(parent, x, y, unit) local function make(parent, x, y, unit)
local height = 0 local height = 0
local num_boilers = #unit.boiler_data_tbl local num_boilers = #unit.boiler_data_tbl
@@ -41,17 +39,19 @@ local function make(parent, x, y, unit)
height = 25 height = 25
end end
assert(parent.height() >= (y + height), "main display not of sufficient vertical resolution (add an additional row of monitors)")
-- bounding box div -- bounding box div
local root = Div{parent=parent,x=x,y=y,width=80,height=height} local root = Div{parent=parent,x=x,y=y,width=80,height=height}
-- unit header message -- unit header message
TextBox{parent=root,text="Unit #" .. unit.unit_id,alignment=TEXT_ALIGN.CENTER,height=1,fg_bg=style.header} TextBox{parent=root,text="Unit #"..unit.unit_id,alignment=TEXT_ALIGN.CENTER,height=1,fg_bg=style.header}
------------- -------------
-- REACTOR -- -- REACTOR --
------------- -------------
reactor_view(root, 1, 3, unit.reactor_data, unit.reactor_ps) reactor_view(root, 1, 3, unit.reactor_data, unit.unit_ps)
if num_boilers > 0 then if num_boilers > 0 then
local coolant_pipes = {} local coolant_pipes = {}
@@ -101,16 +101,16 @@ local function make(parent, x, y, unit)
local steam_pipes_b = {} local steam_pipes_b = {}
if no_boilers then if no_boilers then
table.insert(steam_pipes_b, pipe(0, 1, 3, 1, colors.white)) -- steam to turbine 1 table.insert(steam_pipes_b, pipe(0, 1, 3, 1, colors.white)) -- steam to turbine 1
table.insert(steam_pipes_b, pipe(0, 2, 3, 2, colors.blue)) -- water to turbine 1 table.insert(steam_pipes_b, pipe(0, 2, 3, 2, colors.blue)) -- water to turbine 1
if num_turbines >= 2 then if num_turbines >= 2 then
table.insert(steam_pipes_b, pipe(1, 2, 3, 9, colors.white)) -- steam to turbine 2 table.insert(steam_pipes_b, pipe(1, 2, 3, 9, colors.white)) -- steam to turbine 2
table.insert(steam_pipes_b, pipe(2, 3, 3, 10, colors.blue)) -- water to turbine 2 table.insert(steam_pipes_b, pipe(2, 3, 3, 10, colors.blue)) -- water to turbine 2
end end
if num_turbines >= 3 then if num_turbines >= 3 then
table.insert(steam_pipes_b, pipe(1, 9, 3, 17, colors.white)) -- steam boiler 1 to turbine 1 junction end table.insert(steam_pipes_b, pipe(1, 9, 3, 17, colors.white)) -- steam boiler 1 to turbine 1 junction end
table.insert(steam_pipes_b, pipe(2, 10, 3, 18, colors.blue)) -- water boiler 1 to turbine 1 junction start table.insert(steam_pipes_b, pipe(2, 10, 3, 18, colors.blue)) -- water boiler 1 to turbine 1 junction start
end end
else else

View File

@@ -1,5 +1,5 @@
-- --
-- Reactor Unit SCADA Coordinator GUI -- Reactor Unit Waiting Spinner
-- --
local style = require("coordinator.ui.style") local style = require("coordinator.ui.style")

View File

@@ -1,14 +1,16 @@
local completion = require("cc.completion") local completion = require("cc.completion")
local util = require("scada-common.util") local util = require("scada-common.util")
local print = util.print local print = util.print
local println = util.println
local print_ts = util.print_ts
local println_ts = util.println_ts
local dialog = {} local dialog = {}
-- ask the user yes or no
---@nodiscard
---@param question string
---@param default boolean
---@return boolean|nil
function dialog.ask_y_n(question, default) function dialog.ask_y_n(question, default)
print(question) print(question)
@@ -31,6 +33,11 @@ function dialog.ask_y_n(question, default)
end end
end end
-- ask the user for an input within a set of options
---@nodiscard
---@param options table
---@param cancel string
---@return boolean|string|nil
function dialog.ask_options(options, cancel) function dialog.ask_options(options, cancel)
print("> ") print("> ")
local response = read(nil, nil, function(text) return completion.choice(text, options) end) local response = read(nil, nil, function(text) return completion.choice(text, options) end)

View File

@@ -2,45 +2,98 @@
-- Main SCADA Coordinator GUI -- Main SCADA Coordinator GUI
-- --
local util = require("scada-common.util")
local iocontrol = require("coordinator.iocontrol") local iocontrol = require("coordinator.iocontrol")
local sounder = require("coordinator.sounder")
local style = require("coordinator.ui.style") local style = require("coordinator.ui.style")
local imatrix = require("coordinator.ui.components.imatrix")
local process_ctl = require("coordinator.ui.components.processctl")
local unit_overview = require("coordinator.ui.components.unit_overview") local unit_overview = require("coordinator.ui.components.unit_overview")
local core = require("graphics.core") local core = require("graphics.core")
local ColorMap = require("graphics.elements.colormap")
local DisplayBox = require("graphics.elements.displaybox") local DisplayBox = require("graphics.elements.displaybox")
local Div = require("graphics.elements.div")
local TextBox = require("graphics.elements.textbox") local TextBox = require("graphics.elements.textbox")
local PushButton = require("graphics.elements.controls.push_button")
local SwitchButton = require("graphics.elements.controls.switch_button")
local DataIndicator = require("graphics.elements.indicators.data")
local TEXT_ALIGN = core.graphics.TEXT_ALIGN local TEXT_ALIGN = core.graphics.TEXT_ALIGN
local cpair = core.graphics.cpair
-- create new main view -- create new main view
---@param monitor table main viewscreen ---@param monitor table main viewscreen
local function init(monitor) local function init(monitor)
local facility = iocontrol.get_db().facility
local units = iocontrol.get_db().units
local main = DisplayBox{window=monitor,fg_bg=style.root} local main = DisplayBox{window=monitor,fg_bg=style.root}
-- window header message -- window header message
TextBox{parent=main,text="Nuclear Generation Facility SCADA Coordinator",alignment=TEXT_ALIGN.CENTER,height=1,fg_bg=style.header} local header = TextBox{parent=main,y=1,text="Nuclear Generation Facility SCADA Coordinator",alignment=TEXT_ALIGN.CENTER,height=1,fg_bg=style.header}
local ping = DataIndicator{parent=main,x=1,y=1,label="SVTT",format="%d",value=0,unit="ms",lu_colors=cpair(colors.lightGray, colors.white),width=12,fg_bg=style.header}
-- max length example: "01:23:45 AM - Wednesday, September 28 2022"
local datetime = TextBox{parent=main,x=(header.width()-42),y=1,text="",alignment=TEXT_ALIGN.RIGHT,width=42,height=1,fg_bg=style.header}
local db = iocontrol.get_db() facility.ps.subscribe("sv_ping", ping.update)
facility.ps.subscribe("date_time", datetime.set_value)
local uo_1, uo_2, uo_3, uo_4 ---@type graphics_element local uo_1, uo_2, uo_3, uo_4 ---@type graphics_element
local cnc_y_start = 3
local row_1_height = 0
-- unit overviews -- unit overviews
if db.facility.num_units >= 1 then uo_1 = unit_overview(main, 2, 3, db.units[1]) end if facility.num_units >= 1 then
if db.facility.num_units >= 2 then uo_2 = unit_overview(main, 84, 3, db.units[2]) end uo_1 = unit_overview(main, 2, 3, units[1])
row_1_height = uo_1.height()
end
if db.facility.num_units >= 3 then if facility.num_units >= 2 then
uo_2 = unit_overview(main, 84, 3, units[2])
row_1_height = math.max(row_1_height, uo_2.height())
end
cnc_y_start = cnc_y_start + row_1_height + 1
if facility.num_units >= 3 then
-- base offset 3, spacing 1, max height of units 1 and 2 -- base offset 3, spacing 1, max height of units 1 and 2
local row_2_offset = 3 + 1 + math.max(uo_1.height(), uo_2.height()) local row_2_offset = cnc_y_start
uo_3 = unit_overview(main, 2, row_2_offset, db.units[3]) uo_3 = unit_overview(main, 2, row_2_offset, units[3])
if db.facility.num_units == 4 then uo_4 = unit_overview(main, 84, row_2_offset, db.units[4]) end cnc_y_start = row_2_offset + uo_3.height() + 1
if facility.num_units == 4 then
uo_4 = unit_overview(main, 84, row_2_offset, units[4])
cnc_y_start = math.max(cnc_y_start, row_2_offset + uo_4.height() + 1)
end
end end
-- command & control -- command & control
cnc_y_start = cnc_y_start
-- induction matrix and process control interfaces are 24 tall + space needed for divider
local cnc_bottom_align_start = main.height() - 26
assert(cnc_bottom_align_start >= cnc_y_start, "main display not of sufficient vertical resolution (add an additional row of monitors)")
TextBox{parent=main,y=cnc_bottom_align_start,text=util.strrep("\x8c", header.width()),alignment=TEXT_ALIGN.CENTER,height=1,fg_bg=cpair(colors.lightGray,colors.gray)}
cnc_bottom_align_start = cnc_bottom_align_start + 2
process_ctl(main, 2, cnc_bottom_align_start)
imatrix(main, 131, cnc_bottom_align_start, facility.induction_data_tbl[1], facility.induction_ps_tbl[1])
return main return main
end end

View File

@@ -2,15 +2,11 @@
-- Reactor Unit SCADA Coordinator GUI -- Reactor Unit SCADA Coordinator GUI
-- --
local tcallbackdsp = require("scada-common.tcallbackdsp") local style = require("coordinator.ui.style")
local iocontrol = require("coordinator.iocontrol") local unit_detail = require("coordinator.ui.components.unit_detail")
local style = require("coordinator.ui.style") local DisplayBox = require("graphics.elements.displaybox")
local unit_detail = require("coordinator.ui.components.unit_detail")
local DisplayBox = require("graphics.elements.displaybox")
-- create a unit view -- create a unit view
---@param monitor table ---@param monitor table

View File

@@ -1,3 +1,6 @@
--
-- Graphics Style Options
--
local core = require("graphics.core") local core = require("graphics.core")
@@ -15,7 +18,7 @@ style.colors = {
{ c = colors.red, hex = 0xdf4949 }, { c = colors.red, hex = 0xdf4949 },
{ c = colors.orange, hex = 0xffb659 }, { c = colors.orange, hex = 0xffb659 },
{ c = colors.yellow, hex = 0xfffc79 }, { c = colors.yellow, hex = 0xfffc79 },
{ c = colors.lime, hex = 0x64dd20 }, { c = colors.lime, hex = 0x80ff80 },
{ c = colors.green, hex = 0x4aee8a }, { c = colors.green, hex = 0x4aee8a },
{ c = colors.cyan, hex = 0x34bac8 }, { c = colors.cyan, hex = 0x34bac8 },
{ c = colors.lightBlue, hex = 0x6cc0f2 }, { c = colors.lightBlue, hex = 0x6cc0f2 },
@@ -39,6 +42,14 @@ style.reactor = {
color = cpair(colors.black, colors.yellow), color = cpair(colors.black, colors.yellow),
text = "PLC OFF-LINE" text = "PLC OFF-LINE"
}, },
{
color = cpair(colors.black, colors.orange),
text = "NOT FORMED"
},
{
color = cpair(colors.black, colors.orange),
text = "PLC FAULT"
},
{ {
color = cpair(colors.white, colors.gray), color = cpair(colors.white, colors.gray),
text = "DISABLED" text = "DISABLED"
@@ -52,8 +63,8 @@ style.reactor = {
text = "SCRAMMED" text = "SCRAMMED"
}, },
{ {
color = cpair(colors.black, colors.orange), color = cpair(colors.black, colors.red),
text = "PLC FAULT" text = "FORCE DISABLED"
} }
} }
} }
@@ -65,6 +76,14 @@ style.boiler = {
color = cpair(colors.black, colors.yellow), color = cpair(colors.black, colors.yellow),
text = "OFF-LINE" text = "OFF-LINE"
}, },
{
color = cpair(colors.black, colors.orange),
text = "NOT FORMED"
},
{
color = cpair(colors.black, colors.orange),
text = "RTU FAULT"
},
{ {
color = cpair(colors.white, colors.gray), color = cpair(colors.white, colors.gray),
text = "IDLE" text = "IDLE"
@@ -83,6 +102,14 @@ style.turbine = {
color = cpair(colors.black, colors.yellow), color = cpair(colors.black, colors.yellow),
text = "OFF-LINE" text = "OFF-LINE"
}, },
{
color = cpair(colors.black, colors.orange),
text = "NOT FORMED"
},
{
color = cpair(colors.black, colors.orange),
text = "RTU FAULT"
},
{ {
color = cpair(colors.white, colors.gray), color = cpair(colors.white, colors.gray),
text = "IDLE" text = "IDLE"
@@ -98,4 +125,34 @@ style.turbine = {
} }
} }
style.imatrix = {
-- induction matrix states
states = {
{
color = cpair(colors.black, colors.yellow),
text = "OFF-LINE"
},
{
color = cpair(colors.black, colors.orange),
text = "NOT FORMED"
},
{
color = cpair(colors.black, colors.orange),
text = "RTU FAULT"
},
{
color = cpair(colors.black, colors.green),
text = "ONLINE"
},
{
color = cpair(colors.black, colors.yellow),
text = "LOW CHARGE"
},
{
color = cpair(colors.black, colors.yellow),
text = "HIGH CHARGE"
},
}
}
return style return style

View File

@@ -4,6 +4,10 @@
local core = {} local core = {}
local flasher = require("graphics.flasher")
core.flasher = flasher
local events = {} local events = {}
---@class monitor_touch ---@class monitor_touch
@@ -12,6 +16,7 @@ local events = {}
---@field y integer ---@field y integer
-- create a new touch event definition -- create a new touch event definition
---@nodiscard
---@param monitor string ---@param monitor string
---@param x integer ---@param x integer
---@param y integer ---@param y integer
@@ -28,7 +33,7 @@ core.events = events
local graphics = {} local graphics = {}
---@alias TEXT_ALIGN integer ---@enum TEXT_ALIGN
graphics.TEXT_ALIGN = { graphics.TEXT_ALIGN = {
LEFT = 1, LEFT = 1,
CENTER = 2, CENTER = 2,
@@ -43,6 +48,7 @@ graphics.TEXT_ALIGN = {
---@alias element_id string|integer ---@alias element_id string|integer
-- create a new border definition -- create a new border definition
---@nodiscard
---@param width integer border width ---@param width integer border width
---@param color color border color ---@param color color border color
---@param even? boolean whether to pad width extra to account for rectangular pixels, defaults to false ---@param even? boolean whether to pad width extra to account for rectangular pixels, defaults to false
@@ -62,6 +68,7 @@ end
---@field h integer ---@field h integer
-- create a new graphics frame definition -- create a new graphics frame definition
---@nodiscard
---@param x integer ---@param x integer
---@param y integer ---@param y integer
---@param w integer ---@param w integer
@@ -87,6 +94,7 @@ end
---@field blit_bkg string ---@field blit_bkg string
-- create a new color pair definition -- create a new color pair definition
---@nodiscard
---@param a color ---@param a color
---@param b color ---@param b color
---@return cpair ---@return cpair
@@ -116,9 +124,9 @@ end
---@field thin boolean true for 1 subpixel, false (default) for 2 ---@field thin boolean true for 1 subpixel, false (default) for 2
---@field align_tr boolean false to align bottom left (default), true to align top right ---@field align_tr boolean false to align bottom left (default), true to align top right
-- create a new pipe -- create a new pipe<br>
--
-- note: pipe coordinate origin is (0, 0) -- note: pipe coordinate origin is (0, 0)
---@nodiscard
---@param x1 integer starting x, origin is 0 ---@param x1 integer starting x, origin is 0
---@param y1 integer starting y, origin is 0 ---@param y1 integer starting y, origin is 0
---@param x2 integer ending x, origin is 0 ---@param x2 integer ending x, origin is 0

View File

@@ -19,8 +19,36 @@ local element = {}
---@field gframe? graphics_frame frame instead of x/y/width/height ---@field gframe? graphics_frame frame instead of x/y/width/height
---@field fg_bg? cpair foreground/background colors ---@field fg_bg? cpair foreground/background colors
---@alias graphics_args graphics_args_generic
---|waiting_args
---|hazard_button_args
---|multi_button_args
---|push_button_args
---|radio_button_args
---|spinbox_args
---|switch_button_args
---|alarm_indicator_light
---|core_map_args
---|data_indicator_args
---|hbar_args
---|icon_indicator_args
---|indicator_light_args
---|power_indicator_args
---|rad_indicator_args
---|state_indicator_args
---|tristate_indicator_light_args
---|vbar_args
---|colormap_args
---|displaybox_args
---|div_args
---|pipenet_args
---|rectangle_args
---|textbox_args
---|tiling_args
-- a base graphics element, should not be created on its own -- a base graphics element, should not be created on its own
---@param args graphics_args_generic arguments ---@nodiscard
---@param args graphics_args arguments
function element.new(args) function element.new(args)
local self = { local self = {
id = -1, id = -1,
@@ -37,6 +65,7 @@ function element.new(args)
---@class graphics_template ---@class graphics_template
local protected = { local protected = {
enabled = true,
value = nil, ---@type any value = nil, ---@type any
window = nil, ---@type table window = nil, ---@type table
fg_bg = core.graphics.cpair(colors.white, colors.black), fg_bg = core.graphics.cpair(colors.white, colors.black),
@@ -134,10 +163,17 @@ function element.new(args)
end end
-- handle data value changes -- handle data value changes
---@vararg any value(s)
function protected.on_update(...) function protected.on_update(...)
end end
-- callback on control press responses
---@param result any
function protected.response_callback(result)
end
-- get value -- get value
---@nodiscard
function protected.get_value() function protected.get_value()
return protected.value return protected.value
end end
@@ -145,7 +181,24 @@ function element.new(args)
-- set value -- set value
---@param value any value to set ---@param value any value to set
function protected.set_value(value) function protected.set_value(value)
return nil end
-- set minimum input value
---@param min integer minimum allowed value
function protected.set_min(min)
end
-- set maximum input value
---@param max integer maximum allowed value
function protected.set_max(max)
end
-- enable the control
function protected.enable()
end
-- disable the control
function protected.disable()
end end
-- custom recolor command, varies by element if implemented -- custom recolor command, varies by element if implemented
@@ -167,6 +220,7 @@ function element.new(args)
end end
-- get public interface -- get public interface
---@nodiscard
---@return graphics_element element, element_id id ---@return graphics_element element, element_id id
function protected.get() return public, self.id end function protected.get() return public, self.id end
@@ -195,11 +249,13 @@ function element.new(args)
---------------------- ----------------------
-- get the window object -- get the window object
---@nodiscard
function public.window() return protected.window end function public.window() return protected.window end
-- CHILD ELEMENTS -- -- CHILD ELEMENTS --
-- add a child element -- add a child element
---@nodiscard
---@param key string|nil id ---@param key string|nil id
---@param child graphics_template ---@param child graphics_template
---@return integer|string key ---@return integer|string key
@@ -220,6 +276,7 @@ function element.new(args)
end end
-- get a child element -- get a child element
---@nodiscard
---@return graphics_element ---@return graphics_element
function public.get_child(key) return self.children[key] end function public.get_child(key) return self.children[key] end
@@ -228,6 +285,7 @@ function element.new(args)
function public.remove(key) self.children[key] = nil end function public.remove(key) self.children[key] = nil end
-- attempt to get a child element by ID (does not include this element itself) -- attempt to get a child element by ID (does not include this element itself)
---@nodiscard
---@param id element_id ---@param id element_id
---@return graphics_element|nil element ---@return graphics_element|nil element
function public.get_element_by_id(id) function public.get_element_by_id(id)
@@ -246,27 +304,49 @@ function element.new(args)
-- AUTO-PLACEMENT -- -- AUTO-PLACEMENT --
-- skip a line for automatically placed elements -- skip a line for automatically placed elements
function public.line_break() self.next_y = self.next_y + 1 end function public.line_break()
self.next_y = self.next_y + 1
end
-- PROPERTIES -- -- PROPERTIES --
-- get the foreground/background colors -- get the foreground/background colors
---@nodiscard
---@return cpair fg_bg ---@return cpair fg_bg
function public.get_fg_bg() return protected.fg_bg end function public.get_fg_bg()
return protected.fg_bg
end
-- get element x
---@nodiscard
---@return integer x
function public.get_x()
return protected.frame.x
end
-- get element y
---@nodiscard
---@return integer y
function public.get_y()
return protected.frame.y
end
-- get element width -- get element width
---@nodiscard
---@return integer width ---@return integer width
function public.width() function public.width()
return protected.frame.w return protected.frame.w
end end
-- get element height -- get element height
---@nodiscard
---@return integer height ---@return integer height
function public.height() function public.height()
return protected.frame.h return protected.frame.h
end end
-- get the element value -- get the element value
---@nodiscard
---@return any value ---@return any value
function public.get_value() function public.get_value()
return protected.get_value() return protected.get_value()
@@ -278,6 +358,36 @@ function element.new(args)
protected.set_value(value) protected.set_value(value)
end end
-- set minimum input value
---@param min integer minimum allowed value
function public.set_min(min)
protected.set_min(min)
end
-- set maximum input value
---@param max integer maximum allowed value
function public.set_max(max)
protected.set_max(max)
end
-- enable the element
function public.enable()
protected.enabled = true
protected.enable()
end
-- disable the element
function public.disable()
protected.enabled = false
protected.disable()
end
-- custom recolor command, varies by element if implemented
---@vararg cpair|color color(s)
function public.recolor(...)
protected.recolor(...)
end
-- resize attributes of the element value if supported -- resize attributes of the element value if supported
---@vararg number dimensions (element specific) ---@vararg number dimensions (element specific)
function public.resize(...) function public.resize(...)
@@ -309,6 +419,12 @@ function element.new(args)
protected.on_update(...) protected.on_update(...)
end end
-- on a control request response
---@param result any
function public.on_response(result)
protected.response_callback(result)
end
-- VISIBILITY -- -- VISIBILITY --
-- show the element -- show the element

View File

@@ -85,7 +85,7 @@ local function waiting(args)
if state >= 12 then state = 0 end if state >= 12 then state = 0 end
if run_animation then if run_animation then
tcd.dispatch(0.5, animate) tcd.dispatch_unique(0.5, animate)
end end
end end

View File

@@ -0,0 +1,208 @@
-- Hazard-bordered Button Graphics Element
local tcd = require("scada-common.tcallbackdsp")
local util = require("scada-common.util")
local core = require("graphics.core")
local element = require("graphics.element")
---@class hazard_button_args
---@field text string text to show on button
---@field accent color accent color for hazard border
---@field dis_colors? cpair text color and border color when disabled
---@field callback function function to call on touch
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
---@field y? integer 1 if omitted
---@field fg_bg? cpair foreground/background colors
-- new hazard button
---@param args hazard_button_args
---@return graphics_element element, element_id id
local function hazard_button(args)
assert(type(args.text) == "string", "graphics.elements.controls.hazard_button: text is a required field")
assert(type(args.accent) == "number", "graphics.elements.controls.hazard_button: accent is a required field")
assert(type(args.callback) == "function", "graphics.elements.controls.hazard_button: callback is a required field")
-- static dimensions
args.height = 3
args.width = string.len(args.text) + 4
-- create new graphics element base object
local e = element.new(args)
-- write the button text
e.window.setCursorPos(3, 2)
e.window.write(args.text)
-- draw border
---@param accent color accent color
local function draw_border(accent)
-- top
e.window.setTextColor(accent)
e.window.setBackgroundColor(args.fg_bg.bkg)
e.window.setCursorPos(1, 1)
e.window.write("\x99" .. util.strrep("\x89", args.width - 2) .. "\x99")
-- center left
e.window.setCursorPos(1, 2)
e.window.setTextColor(args.fg_bg.bkg)
e.window.setBackgroundColor(accent)
e.window.write("\x99")
-- center right
e.window.setTextColor(args.fg_bg.bkg)
e.window.setBackgroundColor(accent)
e.window.setCursorPos(args.width, 2)
e.window.write("\x99")
-- bottom
e.window.setTextColor(accent)
e.window.setBackgroundColor(args.fg_bg.bkg)
e.window.setCursorPos(1, 3)
e.window.write("\x99" .. util.strrep("\x98", args.width - 2) .. "\x99")
end
-- on request timeout: recursively calls itself to double flash button text
---@param n integer call count
local function on_timeout(n)
-- start at 0
if n == nil then n = 0 end
if n == 0 then
-- go back off
e.window.setTextColor(args.fg_bg.fgd)
e.window.setCursorPos(3, 2)
e.window.write(args.text)
end
if n >= 4 then
-- done
elseif n % 2 == 0 then
-- toggle text color on after 0.25 seconds
tcd.dispatch(0.25, function ()
e.window.setTextColor(args.accent)
e.window.setCursorPos(3, 2)
e.window.write(args.text)
on_timeout(n + 1)
on_timeout(n + 1)
end)
elseif n % 1 then
-- toggle text color off after 0.25 seconds
tcd.dispatch(0.25, function ()
e.window.setTextColor(args.fg_bg.fgd)
e.window.setCursorPos(3, 2)
e.window.write(args.text)
on_timeout(n + 1)
end)
end
end
-- blink routine for success indication
local function on_success()
e.window.setTextColor(args.fg_bg.fgd)
e.window.setCursorPos(3, 2)
e.window.write(args.text)
end
-- blink routine for failure indication
---@param n integer call count
local function on_failure(n)
-- start at 0
if n == nil then n = 0 end
if n == 0 then
-- go back off
e.window.setTextColor(args.fg_bg.fgd)
e.window.setCursorPos(3, 2)
e.window.write(args.text)
end
if n >= 2 then
-- done
elseif n % 2 == 0 then
-- toggle text color on after 0.5 seconds
tcd.dispatch(0.5, function ()
e.window.setTextColor(args.accent)
e.window.setCursorPos(3, 2)
e.window.write(args.text)
on_failure(n + 1)
end)
elseif n % 1 then
-- toggle text color off after 0.25 seconds
tcd.dispatch(0.25, function ()
e.window.setTextColor(args.fg_bg.fgd)
e.window.setCursorPos(3, 2)
e.window.write(args.text)
on_failure(n + 1)
end)
end
end
-- handle touch
---@param event monitor_touch monitor touch event
---@diagnostic disable-next-line: unused-local
function e.handle_touch(event)
if e.enabled then
-- change text color to indicate clicked
e.window.setTextColor(args.accent)
e.window.setCursorPos(3, 2)
e.window.write(args.text)
-- abort any other callbacks
tcd.abort(on_timeout)
tcd.abort(on_success)
tcd.abort(on_failure)
-- 1.5 second timeout
tcd.dispatch(1.5, on_timeout)
-- call the touch callback
args.callback()
end
end
-- callback on request response
---@param result boolean true for success, false for failure
function e.response_callback(result)
tcd.abort(on_timeout)
if result then
on_success()
else
on_failure(0)
end
end
-- set the value (true simulates pressing the button)
---@param val boolean new value
function e.set_value(val)
if val then e.handle_touch(core.events.touch("", 1, 1)) end
end
-- show the button as disabled
function e.disable()
if args.dis_colors then
draw_border(args.dis_colors.color_a)
e.window.setTextColor(args.dis_colors.color_b)
e.window.setCursorPos(3, 2)
e.window.write(args.text)
end
end
-- show the button as enabled
function e.enable()
draw_border(args.accent)
e.window.setTextColor(args.fg_bg.fgd)
e.window.setCursorPos(3, 2)
e.window.write(args.text)
end
-- initial draw of border
draw_border(args.accent)
return e.get()
end
return hazard_button

View File

@@ -1,9 +1,9 @@
-- Button Graphics Element -- Multi Button Graphics Element
local element = require("graphics.element")
local util = require("scada-common.util") local util = require("scada-common.util")
local element = require("graphics.element")
---@class button_option ---@class button_option
---@field text string ---@field text string
---@field fg_bg cpair ---@field fg_bg cpair
@@ -15,7 +15,7 @@ local util = require("scada-common.util")
---@class multi_button_args ---@class multi_button_args
---@field options table button options ---@field options table button options
---@field callback function function to call on touch ---@field callback function function to call on touch
---@field default? boolean default state, defaults to options[1] ---@field default? integer default state, defaults to options[1]
---@field min_width? integer text length + 2 if omitted ---@field min_width? integer text length + 2 if omitted
---@field parent graphics_element ---@field parent graphics_element
---@field id? string element id ---@field id? string element id
@@ -29,10 +29,15 @@ local util = require("scada-common.util")
---@return graphics_element element, element_id id ---@return graphics_element element, element_id id
local function multi_button(args) local function multi_button(args)
assert(type(args.options) == "table", "graphics.elements.controls.multi_button: options is a required field") assert(type(args.options) == "table", "graphics.elements.controls.multi_button: options is a required field")
assert(#args.options > 0, "graphics.elements.controls.multi_button: at least one option is required")
assert(type(args.callback) == "function", "graphics.elements.controls.multi_button: callback is a required field") assert(type(args.callback) == "function", "graphics.elements.controls.multi_button: callback is a required field")
assert(type(args.default) == "nil" or (type(args.default) == "number" and args.default > 0),
"graphics.elements.controls.multi_button: default must be nil or a number > 0")
assert(type(args.min_width) == "nil" or (type(args.min_width) == "number" and args.min_width > 0),
"graphics.elements.controls.multi_button: min_width must be nil or a number > 0")
-- single line -- single line
args.height = 3 args.height = 1
-- determine widths -- determine widths
local max_width = 1 local max_width = 1
@@ -43,7 +48,7 @@ local function multi_button(args)
end end
end end
local button_width = math.max(max_width, args.min_width or 1) local button_width = math.max(max_width, args.min_width or 0)
args.width = (button_width * #args.options) + #args.options + 1 args.width = (button_width * #args.options) + #args.options + 1
@@ -71,7 +76,7 @@ local function multi_button(args)
for i = 1, #args.options do for i = 1, #args.options do
local opt = args.options[i] ---@type button_option local opt = args.options[i] ---@type button_option
e.window.setCursorPos(opt._start_x, 2) e.window.setCursorPos(opt._start_x, 1)
if e.value == i then if e.value == i then
-- show as pressed -- show as pressed
@@ -91,7 +96,7 @@ local function multi_button(args)
---@param event monitor_touch monitor touch event ---@param event monitor_touch monitor touch event
function e.handle_touch(event) function e.handle_touch(event)
-- determine what was pressed -- determine what was pressed
if event.y == 2 then if e.enabled and event.y == 1 then
for i = 1, #args.options do for i = 1, #args.options do
local opt = args.options[i] ---@type button_option local opt = args.options[i] ---@type button_option
@@ -109,7 +114,6 @@ local function multi_button(args)
function e.set_value(val) function e.set_value(val)
e.value = val e.value = val
draw() draw()
args.callback(e.value)
end end
-- initial draw -- initial draw

View File

@@ -10,6 +10,7 @@ local element = require("graphics.element")
---@field callback function function to call on touch ---@field callback function function to call on touch
---@field min_width? integer text length + 2 if omitted ---@field min_width? integer text length + 2 if omitted
---@field active_fg_bg? cpair foreground/background colors when pressed ---@field active_fg_bg? cpair foreground/background colors when pressed
---@field dis_fg_bg? cpair foreground/background colors when disabled
---@field parent graphics_element ---@field parent graphics_element
---@field id? string element id ---@field id? string element id
---@field x? integer 1 if omitted ---@field x? integer 1 if omitted
@@ -24,13 +25,12 @@ local function push_button(args)
assert(type(args.text) == "string", "graphics.elements.controls.push_button: text is a required field") assert(type(args.text) == "string", "graphics.elements.controls.push_button: text is a required field")
assert(type(args.callback) == "function", "graphics.elements.controls.push_button: callback is a required field") assert(type(args.callback) == "function", "graphics.elements.controls.push_button: callback is a required field")
-- single line
args.height = 1
args.min_width = args.min_width or 0
local text_width = string.len(args.text) local text_width = string.len(args.text)
args.width = math.max(text_width + 2, args.min_width)
-- single line height, calculate width
args.height = 1
args.min_width = args.min_width or 0
args.width = math.max(text_width, args.min_width)
-- create new graphics element base object -- create new graphics element base object
local e = element.new(args) local e = element.new(args)
@@ -51,32 +51,56 @@ local function push_button(args)
---@param event monitor_touch monitor touch event ---@param event monitor_touch monitor touch event
---@diagnostic disable-next-line: unused-local ---@diagnostic disable-next-line: unused-local
function e.handle_touch(event) function e.handle_touch(event)
if args.active_fg_bg ~= nil then if e.enabled then
-- show as pressed if args.active_fg_bg ~= nil then
e.value = true -- show as pressed
e.window.setTextColor(args.active_fg_bg.fgd) e.value = true
e.window.setBackgroundColor(args.active_fg_bg.bkg) e.window.setTextColor(args.active_fg_bg.fgd)
draw() e.window.setBackgroundColor(args.active_fg_bg.bkg)
-- show as unpressed in 0.25 seconds
tcd.dispatch(0.25, function ()
e.value = false
e.window.setTextColor(e.fg_bg.fgd)
e.window.setBackgroundColor(e.fg_bg.bkg)
draw() draw()
end)
end
-- call the touch callback -- show as unpressed in 0.25 seconds
args.callback() tcd.dispatch(0.25, function ()
e.value = false
if e.enabled then
e.window.setTextColor(e.fg_bg.fgd)
e.window.setBackgroundColor(e.fg_bg.bkg)
end
draw()
end)
end
-- call the touch callback
args.callback()
end
end end
-- set the value -- set the value (true simulates pressing the button)
---@param val boolean new value ---@param val boolean new value
function e.set_value(val) function e.set_value(val)
if val then e.handle_touch(core.events.touch("", 1, 1)) end if val then e.handle_touch(core.events.touch("", 1, 1)) end
end end
-- show butten as enabled
function e.enable()
if args.dis_fg_bg ~= nil then
e.value = false
e.window.setTextColor(e.fg_bg.fgd)
e.window.setBackgroundColor(e.fg_bg.bkg)
draw()
end
end
-- show button as disabled
function e.disable()
if args.dis_fg_bg ~= nil then
e.value = false
e.window.setTextColor(args.dis_fg_bg.fgd)
e.window.setBackgroundColor(args.dis_fg_bg.bkg)
draw()
end
end
-- initial draw -- initial draw
draw() draw()

View File

@@ -0,0 +1,108 @@
-- Radio Button Graphics Element
local element = require("graphics.element")
---@class radio_button_args
---@field options table button options
---@field callback function function to call on touch
---@field radio_colors cpair colors for radio button center dot when active (a) or inactive (b)
---@field radio_bg color background color of radio button
---@field default? integer default state, defaults to options[1]
---@field min_width? integer text length + 2 if omitted
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
---@field y? integer 1 if omitted
---@field fg_bg? cpair foreground/background colors
-- new radio button list (latch selection, exclusively one button at a time)
---@param args radio_button_args
---@return graphics_element element, element_id id
local function radio_button(args)
assert(type(args.options) == "table", "graphics.elements.controls.radio_button: options is a required field")
assert(#args.options > 0, "graphics.elements.controls.radio_button: at least one option is required")
assert(type(args.callback) == "function", "graphics.elements.controls.radio_button: callback is a required field")
assert(type(args.default) == "nil" or (type(args.default) == "number" and args.default > 0),
"graphics.elements.controls.radio_button: default must be nil or a number > 0")
assert(type(args.min_width) == "nil" or (type(args.min_width) == "number" and args.min_width > 0),
"graphics.elements.controls.radio_button: min_width must be nil or a number > 0")
-- one line per option
args.height = #args.options
-- determine widths
local max_width = 1
for i = 1, #args.options do
local opt = args.options[i] ---@type string
if string.len(opt) > max_width then
max_width = string.len(opt)
end
end
local button_text_width = math.max(max_width, args.min_width or 0)
args.width = button_text_width + 2
-- create new graphics element base object
local e = element.new(args)
-- button state (convert nil to 1 if missing)
e.value = args.default or 1
-- show the button state
local function draw()
for i = 1, #args.options do
local opt = args.options[i] ---@type string
e.window.setCursorPos(1, i)
if e.value == i then
-- show as selected
e.window.setTextColor(args.radio_colors.color_a)
e.window.setBackgroundColor(args.radio_bg)
else
-- show as unselected
e.window.setTextColor(args.radio_colors.color_b)
e.window.setBackgroundColor(args.radio_bg)
end
e.window.write("\x88")
e.window.setTextColor(args.radio_bg)
e.window.setBackgroundColor(e.fg_bg.bkg)
e.window.write("\x95")
-- write button text
e.window.setTextColor(e.fg_bg.fgd)
e.window.setBackgroundColor(e.fg_bg.bkg)
e.window.write(opt)
end
end
-- handle touch
---@param event monitor_touch monitor touch event
function e.handle_touch(event)
-- determine what was pressed
if e.enabled then
if args.options[event.y] ~= nil then
e.value = event.y
draw()
args.callback(e.value)
end
end
end
-- set the value
---@param val integer new value
function e.set_value(val)
e.value = val
draw()
end
-- initial draw
draw()
return e.get()
end
return radio_button

View File

@@ -1,76 +0,0 @@
-- SCRAM Button Graphics Element
local tcd = require("scada-common.tcallbackdsp")
local core = require("graphics.core")
local element = require("graphics.element")
---@class scram_button_args
---@field callback function function to call on touch
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
---@field y? integer 1 if omitted
---@field fg_bg? cpair foreground/background colors
-- new scram button
---@param args scram_button_args
---@return graphics_element element, element_id id
local function scram_button(args)
assert(type(args.callback) == "function", "graphics.elements.controls.scram_button: callback is a required field")
-- static dimensions
args.height = 3
args.width = 9
-- create new graphics element base object
local e = element.new(args)
-- write the button text
e.window.setCursorPos(3, 2)
e.window.write("SCRAM")
-- draw border
-- top
e.window.setTextColor(colors.yellow)
e.window.setBackgroundColor(args.fg_bg.bkg)
e.window.setCursorPos(1, 1)
e.window.write("\x99\x89\x89\x89\x89\x89\x89\x89\x99")
-- center left
e.window.setCursorPos(1, 2)
e.window.setTextColor(args.fg_bg.bkg)
e.window.setBackgroundColor(colors.yellow)
e.window.write("\x99")
-- center right
e.window.setTextColor(args.fg_bg.bkg)
e.window.setBackgroundColor(colors.yellow)
e.window.setCursorPos(9, 2)
e.window.write("\x99")
-- bottom
e.window.setTextColor(colors.yellow)
e.window.setBackgroundColor(args.fg_bg.bkg)
e.window.setCursorPos(1, 3)
e.window.write("\x99\x98\x98\x98\x98\x98\x98\x98\x99")
-- handle touch
---@param event monitor_touch monitor touch event
---@diagnostic disable-next-line: unused-local
function e.handle_touch(event)
-- call the touch callback
args.callback()
end
-- set the value
---@param val boolean new value
function e.set_value(val)
if val then e.handle_touch(core.events.touch("", 1, 1)) end
end
return e.get()
end
return scram_button

View File

@@ -1,14 +1,17 @@
-- Spinbox Numeric Graphics Element -- Spinbox Numeric Graphics Element
local element = require("graphics.element")
local util = require("scada-common.util") local util = require("scada-common.util")
local element = require("graphics.element")
---@class spinbox_args ---@class spinbox_args
---@field default? number default value, defaults to 0.0 ---@field default? number default value, defaults to 0.0
---@field min? number default 0, currently must be 0 or greater
---@field max? number default max number that can be displayed with the digits configuration
---@field whole_num_precision integer number of whole number digits ---@field whole_num_precision integer number of whole number digits
---@field fractional_precision integer number of fractional digits ---@field fractional_precision integer number of fractional digits
---@field arrow_fg_bg cpair arrow foreground/background colors ---@field arrow_fg_bg cpair arrow foreground/background colors
---@field arrow_disable? color color when disabled (default light gray)
---@field parent graphics_element ---@field parent graphics_element
---@field id? string element id ---@field id? string element id
---@field x? integer 1 if omitted ---@field x? integer 1 if omitted
@@ -27,8 +30,17 @@ local function spinbox(args)
assert(util.is_int(wn_prec), "graphics.element.controls.spinbox_numeric: whole number precision must be an integer") assert(util.is_int(wn_prec), "graphics.element.controls.spinbox_numeric: whole number precision must be an integer")
assert(util.is_int(fr_prec), "graphics.element.controls.spinbox_numeric: fractional precision must be an integer") assert(util.is_int(fr_prec), "graphics.element.controls.spinbox_numeric: fractional precision must be an integer")
local fmt = "%" .. (wn_prec + fr_prec + 1) .. "." .. fr_prec .. "f" local fmt = ""
local fmt_init = "%0" .. (wn_prec + fr_prec + 1) .. "." .. fr_prec .. "f" local fmt_init = ""
if fr_prec > 0 then
fmt = "%" .. (wn_prec + fr_prec + 1) .. "." .. fr_prec .. "f"
fmt_init = "%0" .. (wn_prec + fr_prec + 1) .. "." .. fr_prec .. "f"
else
fmt = "%" .. wn_prec .. "d"
fmt_init = "%0" .. wn_prec .. "d"
end
local dec_point_x = args.whole_num_precision + 1 local dec_point_x = args.whole_num_precision + 1
assert(type(args.arrow_fg_bg) == "table", "graphics.element.spinbox_numeric: arrow_fg_bg is a required field") assert(type(args.arrow_fg_bg) == "table", "graphics.element.spinbox_numeric: arrow_fg_bg is a required field")
@@ -41,39 +53,33 @@ local function spinbox(args)
local e = element.new(args) local e = element.new(args)
-- set initial value -- set initial value
e.value = args.default or 0.0 e.value = args.default or 0
local initial_str = util.sprintf(fmt_init, e.value)
---@diagnostic disable-next-line: discard-returns
initial_str:gsub("%d", function (char) table.insert(digits, char) end)
-- draw the arrows -- draw the arrows
e.window.setBackgroundColor(args.arrow_fg_bg.bkg) local function draw_arrows(color)
e.window.setTextColor(args.arrow_fg_bg.fgd) e.window.setBackgroundColor(args.arrow_fg_bg.bkg)
e.window.setCursorPos(1, 1) e.window.setTextColor(color)
e.window.write(util.strrep("\x1e", wn_prec)) e.window.setCursorPos(1, 1)
e.window.setCursorPos(1, 3) e.window.write(util.strrep("\x1e", wn_prec))
e.window.write(util.strrep("\x1f", wn_prec)) e.window.setCursorPos(1, 3)
if fr_prec > 0 then e.window.write(util.strrep("\x1f", wn_prec))
e.window.setCursorPos(1 + wn_prec, 1) if fr_prec > 0 then
e.window.write(" " .. util.strrep("\x1e", fr_prec)) e.window.setCursorPos(1 + wn_prec, 1)
e.window.setCursorPos(1 + wn_prec, 3) e.window.write(" " .. util.strrep("\x1e", fr_prec))
e.window.write(" " .. util.strrep("\x1f", fr_prec)) e.window.setCursorPos(1 + wn_prec, 3)
e.window.write(" " .. util.strrep("\x1f", fr_prec))
end
end end
-- zero the value draw_arrows(args.arrow_fg_bg.fgd)
local function zero()
for i = 1, #digits do digits[i] = 0 end
e.value = 0
end
-- print out the current value -- populate digits from current value
local function show_num() local function set_digits()
e.window.setBackgroundColor(e.fg_bg.bkg) local initial_str = util.sprintf(fmt_init, e.value)
e.window.setTextColor(e.fg_bg.fgd)
e.window.setCursorPos(1, 2) digits = {}
e.window.write(util.sprintf(fmt, e.value)) ---@diagnostic disable-next-line: discard-returns
initial_str:gsub("%d", function (char) table.insert(digits, char) end)
end end
-- update the value per digits table -- update the value per digits table
@@ -89,26 +95,33 @@ local function spinbox(args)
end end
end end
-- enforce numeric limits -- print out the current value
local function enforce_limits() local function show_num()
-- min 0 -- enforce limits
if e.value < 0 then if (type(args.min) == "number") and (e.value < args.min) then
zero() e.value = args.min
-- max printable set_digits()
elseif string.len(util.sprintf(fmt, e.value)) > args.width then elseif e.value < 0 then
-- max out e.value = 0
for i = 1, #digits do digits[i] = 9 end set_digits()
else
-- re-update value if string.len(util.sprintf(fmt, e.value)) > args.width then
update_value() -- max printable exceeded, so max out to all 9s
for i = 1, #digits do digits[i] = 9 end
update_value()
elseif (type(args.max) == "number") and (e.value > args.max) then
e.value = args.max
set_digits()
else
set_digits()
end
end end
end
-- update value and show -- draw
local function parse_and_show() e.window.setBackgroundColor(e.fg_bg.bkg)
update_value() e.window.setTextColor(e.fg_bg.fgd)
enforce_limits() e.window.setCursorPos(1, 2)
show_num() e.window.write(util.sprintf(fmt, e.value))
end end
-- init with the default value -- init with the default value
@@ -118,17 +131,20 @@ local function spinbox(args)
---@param event monitor_touch monitor touch event ---@param event monitor_touch monitor touch event
function e.handle_touch(event) function e.handle_touch(event)
-- only handle if on an increment or decrement arrow -- only handle if on an increment or decrement arrow
if event.x ~= dec_point_x then if e.enabled and event.x ~= dec_point_x then
local idx = util.trinary(event.x > dec_point_x, event.x - 1, event.x) local idx = util.trinary(event.x > dec_point_x, event.x - 1, event.x)
if event.y == 1 then if digits[idx] ~= nil then
-- increment if event.y == 1 then
digits[idx] = digits[idx] + 1 -- increment
elseif event.y == 3 then digits[idx] = digits[idx] + 1
-- decrement elseif event.y == 3 then
digits[idx] = digits[idx] - 1 -- decrement
end digits[idx] = digits[idx] - 1
end
parse_and_show() update_value()
show_num()
end
end end
end end
@@ -136,9 +152,39 @@ local function spinbox(args)
---@param val number number to show ---@param val number number to show
function e.set_value(val) function e.set_value(val)
e.value = val e.value = val
parse_and_show() show_num()
end end
-- set minimum input value
---@param min integer minimum allowed value
function e.set_min(min)
if min >= 0 then
args.min = min
show_num()
end
end
-- set maximum input value
---@param max integer maximum allowed value
function e.set_max(max)
args.max = max
show_num()
end
-- enable this input
function e.enable()
draw_arrows(args.arrow_fg_bg.fgd)
end
-- disable this input
function e.disable()
draw_arrows(args.arrow_disable or colors.lightGray)
end
-- default to zero, init digits table
e.value = 0
set_digits()
return e.get() return e.get()
end end

View File

@@ -1,76 +0,0 @@
-- SCRAM Button Graphics Element
local tcd = require("scada-common.tcallbackdsp")
local core = require("graphics.core")
local element = require("graphics.element")
---@class start_button_args
---@field callback function function to call on touch
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
---@field y? integer 1 if omitted
---@field fg_bg? cpair foreground/background colors
-- new start button
---@param args start_button_args
---@return graphics_element element, element_id id
local function start_button(args)
assert(type(args.callback) == "function", "graphics.elements.controls.start_button: callback is a required field")
-- static dimensions
args.height = 3
args.width = 9
-- create new graphics element base object
local e = element.new(args)
-- write the button text
e.window.setCursorPos(3, 2)
e.window.write("START")
-- draw border
-- top
e.window.setTextColor(colors.orange)
e.window.setBackgroundColor(args.fg_bg.bkg)
e.window.setCursorPos(1, 1)
e.window.write("\x99\x89\x89\x89\x89\x89\x89\x89\x99")
-- center left
e.window.setCursorPos(1, 2)
e.window.setTextColor(args.fg_bg.bkg)
e.window.setBackgroundColor(colors.orange)
e.window.write("\x99")
-- center right
e.window.setTextColor(args.fg_bg.bkg)
e.window.setBackgroundColor(colors.orange)
e.window.setCursorPos(9, 2)
e.window.write("\x99")
-- bottom
e.window.setTextColor(colors.orange)
e.window.setBackgroundColor(args.fg_bg.bkg)
e.window.setCursorPos(1, 3)
e.window.write("\x99\x98\x98\x98\x98\x98\x98\x98\x99")
-- handle touch
---@param event monitor_touch monitor touch event
---@diagnostic disable-next-line: unused-local
function e.handle_touch(event)
-- call the touch callback
args.callback()
end
-- set the value
---@param val boolean new value
function e.set_value(val)
if val then e.handle_touch(core.events.touch("", 1, 1)) end
end
return e.get()
end
return start_button

View File

@@ -36,7 +36,7 @@ local function switch_button(args)
-- button state (convert nil to false if missing) -- button state (convert nil to false if missing)
e.value = args.default or false e.value = args.default or false
local h_pad = math.floor((e.frame.w - text_width) / 2) local h_pad = math.floor((e.frame.w - text_width) / 2) + 1
local v_pad = math.floor(e.frame.h / 2) + 1 local v_pad = math.floor(e.frame.h / 2) + 1
-- show the button state -- show the button state
@@ -51,6 +51,9 @@ local function switch_button(args)
e.window.setBackgroundColor(e.fg_bg.bkg) e.window.setBackgroundColor(e.fg_bg.bkg)
end end
-- clear to redraw background
e.window.clear()
-- write the button text -- write the button text
e.window.setCursorPos(h_pad, v_pad) e.window.setCursorPos(h_pad, v_pad)
e.window.write(args.text) e.window.write(args.text)
@@ -63,12 +66,14 @@ local function switch_button(args)
---@param event monitor_touch monitor touch event ---@param event monitor_touch monitor touch event
---@diagnostic disable-next-line: unused-local ---@diagnostic disable-next-line: unused-local
function e.handle_touch(event) function e.handle_touch(event)
-- toggle state if e.enabled then
e.value = not e.value -- toggle state
draw_state() e.value = not e.value
draw_state()
-- call the touch callback with state -- call the touch callback with state
args.callback(e.value) args.callback(e.value)
end
end end
-- set the value -- set the value
@@ -77,9 +82,6 @@ local function switch_button(args)
-- set state -- set state
e.value = val e.value = val
draw_state() draw_state()
-- call the touch callback with state
args.callback(e.value)
end end
return e.get() return e.get()

View File

@@ -12,6 +12,7 @@ local element = require("graphics.element")
---@field fg_bg? cpair foreground/background colors ---@field fg_bg? cpair foreground/background colors
-- new root display box -- new root display box
---@nodiscard
---@param args displaybox_args ---@param args displaybox_args
local function displaybox(args) local function displaybox(args)
-- create new graphics element base object -- create new graphics element base object

View File

@@ -13,6 +13,7 @@ local element = require("graphics.element")
---@field fg_bg? cpair foreground/background colors ---@field fg_bg? cpair foreground/background colors
-- new div element -- new div element
---@nodiscard
---@param args div_args ---@param args div_args
---@return graphics_element element, element_id id ---@return graphics_element element, element_id id
local function div(args) local function div(args)

View File

@@ -0,0 +1,114 @@
-- Tri-State Alarm Indicator Light Graphics Element
local util = require("scada-common.util")
local element = require("graphics.element")
local flasher = require("graphics.flasher")
---@class alarm_indicator_light
---@field label string indicator label
---@field c1 color color for off state
---@field c2 color color for alarm state
---@field c3 color color for ring-back state
---@field min_label_width? integer label length if omitted
---@field flash? boolean whether to flash on alarm state rather than stay on
---@field period? PERIOD flash period
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
---@field y? integer 1 if omitted
---@field fg_bg? cpair foreground/background colors
-- new alarm indicator light
---@nodiscard
---@param args alarm_indicator_light
---@return graphics_element element, element_id id
local function alarm_indicator_light(args)
assert(type(args.label) == "string", "graphics.elements.indicators.alight: label is a required field")
assert(type(args.c1) == "number", "graphics.elements.indicators.alight: c1 is a required field")
assert(type(args.c2) == "number", "graphics.elements.indicators.alight: c2 is a required field")
assert(type(args.c3) == "number", "graphics.elements.indicators.alight: c3 is a required field")
if args.flash then
assert(util.is_int(args.period), "graphics.elements.indicators.alight: period is a required field if flash is enabled")
end
-- single line
args.height = 1
-- determine width
args.width = math.max(args.min_label_width or 1, string.len(args.label)) + 2
-- flasher state
local flash_on = true
-- blit translations
local c1 = colors.toBlit(args.c1)
local c2 = colors.toBlit(args.c2)
local c3 = colors.toBlit(args.c3)
-- create new graphics element base object
local e = element.new(args)
-- called by flasher when enabled
local function flash_callback()
e.window.setCursorPos(1, 1)
if flash_on then
if e.value == 2 then
e.window.blit(" \x95", "0" .. c2, c2 .. e.fg_bg.blit_bkg)
end
else
if e.value == 3 then
e.window.blit(" \x95", "0" .. c3, c3 .. e.fg_bg.blit_bkg)
else
e.window.blit(" \x95", "0" .. c1, c1 .. e.fg_bg.blit_bkg)
end
end
flash_on = not flash_on
end
-- on state change
---@param new_state integer indicator state
function e.on_update(new_state)
local was_off = e.value ~= 2
e.value = new_state
e.window.setCursorPos(1, 1)
if args.flash then
if was_off and (new_state == 2) then
flash_on = true
flasher.start(flash_callback, args.period)
elseif new_state ~= 2 then
flash_on = false
flasher.stop(flash_callback)
if new_state == 3 then
e.window.blit(" \x95", "0" .. c3, c3 .. e.fg_bg.blit_bkg)
else
e.window.blit(" \x95", "0" .. c1, c1 .. e.fg_bg.blit_bkg)
end
end
elseif new_state == 2 then
e.window.blit(" \x95", "0" .. c2, c2 .. e.fg_bg.blit_bkg)
elseif new_state == 3 then
e.window.blit(" \x95", "0" .. c3, c3 .. e.fg_bg.blit_bkg)
else
e.window.blit(" \x95", "0" .. c1, c1 .. e.fg_bg.blit_bkg)
end
end
-- set indicator state
---@param val integer indicator state
function e.set_value(val) e.on_update(val) end
-- write label and initial indicator light
e.on_update(1)
e.window.write(args.label)
return e.get()
end
return alarm_indicator_light

View File

@@ -14,6 +14,7 @@ local element = require("graphics.element")
---@field y? integer 1 if omitted ---@field y? integer 1 if omitted
-- new core map box -- new core map box
---@nodiscard
---@param args core_map_args ---@param args core_map_args
---@return graphics_element element, element_id id ---@return graphics_element element, element_id id
local function core_map(args) local function core_map(args)
@@ -115,6 +116,9 @@ local function core_map(args)
if inner_width % 2 == 0 then alternator = not alternator end if inner_width % 2 == 0 then alternator = not alternator end
end end
-- reset alternator
alternator = true
end end
-- on state change -- on state change

View File

@@ -4,34 +4,11 @@ local util = require("scada-common.util")
local element = require("graphics.element") local element = require("graphics.element")
-- format a number string with commas as the thousands separator
--
-- subtracts from spaces at the start if present for each comma used
---@param num string number string
---@return string
local function comma_format(num)
local formatted = num
local commas = 0
local i = 1
while i > 0 do
formatted, i = formatted:gsub("^(%s-%d+)(%d%d%d)", '%1,%2')
if i > 0 then commas = commas + 1 end
end
local _, num_spaces = formatted:gsub(" %s-", "")
local remove = math.min(num_spaces, commas)
formatted = string.sub(formatted, remove + 1)
return formatted
end
---@class data_indicator_args ---@class data_indicator_args
---@field label string indicator label ---@field label string indicator label
---@field unit? string indicator unit ---@field unit? string indicator unit
---@field format string data format (lua string format) ---@field format string data format (lua string format)
---@field commas boolean whether to use commas if a number is given (default to false) ---@field commas? boolean whether to use commas if a number is given (default to false)
---@field lu_colors? cpair label foreground color (a), unit foreground color (b) ---@field lu_colors? cpair label foreground color (a), unit foreground color (b)
---@field value any default value ---@field value any default value
---@field parent graphics_element ---@field parent graphics_element
@@ -42,6 +19,7 @@ end
---@field fg_bg? cpair foreground/background colors ---@field fg_bg? cpair foreground/background colors
-- new data indicator -- new data indicator
---@nodiscard
---@param args data_indicator_args ---@param args data_indicator_args
---@return graphics_element element, element_id id ---@return graphics_element element, element_id id
local function data(args) local function data(args)
@@ -65,20 +43,30 @@ local function data(args)
e.window.setCursorPos(1, 1) e.window.setCursorPos(1, 1)
e.window.write(args.label) e.window.write(args.label)
local data_start = string.len(args.label) + 2 local label_len = string.len(args.label)
local data_start = 1
local clear_width = args.width
if label_len > 0 then
data_start = data_start + (label_len + 1)
clear_width = args.width - (label_len + 1)
end
-- on state change -- on state change
---@param value any new value ---@param value any new value
function e.on_update(value) function e.on_update(value)
e.value = value e.value = value
local data_str = util.sprintf(args.format, value) -- clear old data and label
e.window.setCursorPos(data_start, 1)
e.window.write(util.spaces(clear_width))
-- write data -- write data
local data_str = util.sprintf(args.format, value)
e.window.setCursorPos(data_start, 1) e.window.setCursorPos(data_start, 1)
e.window.setTextColor(e.fg_bg.fgd) e.window.setTextColor(e.fg_bg.fgd)
if args.commas then if args.commas then
e.window.write(comma_format(data_str)) e.window.write(util.comma_format(data_str))
else else
e.window.write(data_str) e.window.write(data_str)
end end

View File

@@ -17,9 +17,9 @@ local element = require("graphics.element")
---@field fg_bg? cpair foreground/background colors ---@field fg_bg? cpair foreground/background colors
-- new horizontal bar -- new horizontal bar
---@nodiscard
---@param args hbar_args ---@param args hbar_args
---@return graphics_element element, element_id id ---@return graphics_element element, element_id id
---@return graphics_element element, element_id id
local function hbar(args) local function hbar(args)
-- properties/state -- properties/state
local last_num_bars = -1 local last_num_bars = -1
@@ -107,7 +107,9 @@ local function hbar(args)
-- re-draw -- re-draw
last_num_bars = 0 last_num_bars = 0
e.on_update(e.value) if type(e.value) == "number" then
e.on_update(e.value)
end
end end
-- set the percentage value -- set the percentage value

View File

@@ -20,6 +20,7 @@ local element = require("graphics.element")
---@field fg_bg? cpair foreground/background colors ---@field fg_bg? cpair foreground/background colors
-- new icon indicator -- new icon indicator
---@nodiscard
---@param args icon_indicator_args ---@param args icon_indicator_args
---@return graphics_element element, element_id id ---@return graphics_element element, element_id id
local function icon(args) local function icon(args)

View File

@@ -1,11 +1,16 @@
-- Indicator Light Graphics Element -- Indicator Light Graphics Element
local util = require("scada-common.util")
local element = require("graphics.element") local element = require("graphics.element")
local flasher = require("graphics.flasher")
---@class indicator_light_args ---@class indicator_light_args
---@field label string indicator label ---@field label string indicator label
---@field colors cpair on/off colors (a/b respectively) ---@field colors cpair on/off colors (a/b respectively)
---@field min_label_width? integer label length if omitted ---@field min_label_width? integer label length if omitted
---@field flash? boolean whether to flash on true rather than stay on
---@field period? PERIOD flash period
---@field parent graphics_element ---@field parent graphics_element
---@field id? string element id ---@field id? string element id
---@field x? integer 1 if omitted ---@field x? integer 1 if omitted
@@ -13,31 +18,69 @@ local element = require("graphics.element")
---@field fg_bg? cpair foreground/background colors ---@field fg_bg? cpair foreground/background colors
-- new indicator light -- new indicator light
---@nodiscard
---@param args indicator_light_args ---@param args indicator_light_args
---@return graphics_element element, element_id id ---@return graphics_element element, element_id id
local function indicator_light(args) local function indicator_light(args)
assert(type(args.label) == "string", "graphics.elements.indicators.light: label is a required field") assert(type(args.label) == "string", "graphics.elements.indicators.light: label is a required field")
assert(type(args.colors) == "table", "graphics.elements.indicators.light: colors is a required field") assert(type(args.colors) == "table", "graphics.elements.indicators.light: colors is a required field")
if args.flash then
assert(util.is_int(args.period), "graphics.elements.indicators.light: period is a required field if flash is enabled")
end
-- single line -- single line
args.height = 1 args.height = 1
-- determine width -- determine width
args.width = math.max(args.min_label_width or 1, string.len(args.label)) + 2 args.width = math.max(args.min_label_width or 1, string.len(args.label)) + 2
-- flasher state
local flash_on = true
-- create new graphics element base object -- create new graphics element base object
local e = element.new(args) local e = element.new(args)
-- called by flasher when enabled
local function flash_callback()
e.window.setCursorPos(1, 1)
if flash_on then
e.window.blit(" \x95", "0" .. args.colors.blit_a, args.colors.blit_a .. e.fg_bg.blit_bkg)
else
e.window.blit(" \x95", "0" .. args.colors.blit_b, args.colors.blit_b .. e.fg_bg.blit_bkg)
end
flash_on = not flash_on
end
-- enable light or start flashing
local function enable()
if args.flash then
flash_on = true
flasher.start(flash_callback, args.period)
else
e.window.setCursorPos(1, 1)
e.window.blit(" \x95", "0" .. args.colors.blit_a, args.colors.blit_a .. e.fg_bg.blit_bkg)
end
end
-- disable light or stop flashing
local function disable()
if args.flash then
flash_on = false
flasher.stop(flash_callback)
end
e.window.setCursorPos(1, 1)
e.window.blit(" \x95", "0" .. args.colors.blit_b, args.colors.blit_b .. e.fg_bg.blit_bkg)
end
-- on state change -- on state change
---@param new_state boolean indicator state ---@param new_state boolean indicator state
function e.on_update(new_state) function e.on_update(new_state)
e.value = new_state e.value = new_state
e.window.setCursorPos(1, 1) if new_state then enable() else disable() end
if new_state then
e.window.blit(" \x95", "0" .. args.colors.blit_a, args.colors.blit_a .. e.fg_bg.blit_bkg)
else
e.window.blit(" \x95", "0" .. args.colors.blit_b, args.colors.blit_b .. e.fg_bg.blit_bkg)
end
end end
-- set indicator state -- set indicator state
@@ -46,6 +89,7 @@ local function indicator_light(args)
-- write label and initial indicator light -- write label and initial indicator light
e.on_update(false) e.on_update(false)
e.window.setCursorPos(3, 1)
e.window.write(args.label) e.window.write(args.label)
return e.get() return e.get()

View File

@@ -0,0 +1,85 @@
-- Power Indicator Graphics Element
local util = require("scada-common.util")
local element = require("graphics.element")
---@class power_indicator_args
---@field label string indicator label
---@field format string power format override (lua string format)
---@field rate boolean? whether to append /t to the end (power per tick)
---@field lu_colors? cpair label foreground color (a), unit foreground color (b)
---@field value any default value
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
---@field y? integer 1 if omitted
---@field width integer length
---@field fg_bg? cpair foreground/background colors
-- new power indicator
---@nodiscard
---@param args power_indicator_args
---@return graphics_element element, element_id id
local function power(args)
assert(args.value ~= nil, "graphics.elements.indicators.power: value is a required field")
assert(util.is_int(args.width), "graphics.elements.indicators.power: width is a required field")
-- single line
args.height = 1
-- create new graphics element base object
local e = element.new(args)
-- label color
if args.lu_colors ~= nil then
e.window.setTextColor(args.lu_colors.color_a)
end
-- write label
e.window.setCursorPos(1, 1)
e.window.write(args.label)
local data_start = string.len(args.label) + 2
if string.len(args.label) == 0 then data_start = 1 end
-- on state change
---@param value any new value
function e.on_update(value)
e.value = value
local data_str, unit = util.power_format(value, false, args.format)
-- write data
e.window.setCursorPos(data_start, 1)
e.window.setTextColor(e.fg_bg.fgd)
e.window.write(util.comma_format(data_str))
-- write unit
if args.lu_colors ~= nil then
e.window.setTextColor(args.lu_colors.color_b)
end
-- append per tick if rate is set
-- add space to FE so we don't end up with FEE (after having kFE for example)
if args.rate == true then
unit = unit .. "/t"
if unit == "FE/t" then unit = "FE/t " end
else
if unit == "FE" then unit = "FE " end
end
e.window.write(" " .. unit)
end
-- set the value
---@param val any new value
function e.set_value(val) e.on_update(val) end
-- initial value draw
e.on_update(args.value)
return e.get()
end
return power

View File

@@ -0,0 +1,90 @@
-- Radiation Indicator Graphics Element
local types = require("scada-common.types")
local util = require("scada-common.util")
local element = require("graphics.element")
---@class rad_indicator_args
---@field label string indicator label
---@field format string data format (lua string format)
---@field commas? boolean whether to use commas if a number is given (default to false)
---@field lu_colors? cpair label foreground color (a), unit foreground color (b)
---@field value any default value
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
---@field y? integer 1 if omitted
---@field width integer length
---@field fg_bg? cpair foreground/background colors
-- new radiation indicator
---@nodiscard
---@param args rad_indicator_args
---@return graphics_element element, element_id id
local function rad(args)
assert(type(args.label) == "string", "graphics.elements.indicators.rad: label is a required field")
assert(type(args.format) == "string", "graphics.elements.indicators.rad: format is a required field")
assert(util.is_int(args.width), "graphics.elements.indicators.rad: width is a required field")
-- single line
args.height = 1
-- create new graphics element base object
local e = element.new(args)
-- label color
if args.lu_colors ~= nil then
e.window.setTextColor(args.lu_colors.color_a)
end
-- write label
e.window.setCursorPos(1, 1)
e.window.write(args.label)
local label_len = string.len(args.label)
local data_start = 1
local clear_width = args.width
if label_len > 0 then
data_start = data_start + (label_len + 1)
clear_width = args.width - (label_len + 1)
end
-- on state change
---@param value any new value
function e.on_update(value)
e.value = value.radiation
-- clear old data and label
e.window.setCursorPos(data_start, 1)
e.window.write(util.spaces(clear_width))
-- write data
local data_str = util.sprintf(args.format, e.value)
e.window.setCursorPos(data_start, 1)
e.window.setTextColor(e.fg_bg.fgd)
if args.commas then
e.window.write(util.comma_format(data_str))
else
e.window.write(data_str)
end
-- write unit
if args.lu_colors ~= nil then
e.window.setTextColor(args.lu_colors.color_b)
end
e.window.write(" " .. value.unit)
end
-- set the value
---@param val any new value
function e.set_value(val) e.on_update(val) end
-- initial value draw
e.on_update(types.new_zero_radiation_reading())
return e.get()
end
return rad

View File

@@ -20,6 +20,7 @@ local element = require("graphics.element")
---@field fg_bg? cpair foreground/background colors ---@field fg_bg? cpair foreground/background colors
-- new state indicator -- new state indicator
---@nodiscard
---@param args state_indicator_args ---@param args state_indicator_args
---@return graphics_element element, element_id id ---@return graphics_element element, element_id id
local function state_indicator(args) local function state_indicator(args)

View File

@@ -1,6 +1,9 @@
-- Tri-State Indicator Light Graphics Element -- Tri-State Indicator Light Graphics Element
local util = require("scada-common.util")
local element = require("graphics.element") local element = require("graphics.element")
local flasher = require("graphics.flasher")
---@class tristate_indicator_light_args ---@class tristate_indicator_light_args
---@field label string indicator label ---@field label string indicator label
@@ -8,13 +11,16 @@ local element = require("graphics.element")
---@field c2 color color for state 2 ---@field c2 color color for state 2
---@field c3 color color for state 3 ---@field c3 color color for state 3
---@field min_label_width? integer label length if omitted ---@field min_label_width? integer label length if omitted
---@field flash? boolean whether to flash on state 2 or 3 rather than stay on
---@field period? PERIOD flash period
---@field parent graphics_element ---@field parent graphics_element
---@field id? string element id ---@field id? string element id
---@field x? integer 1 if omitted ---@field x? integer 1 if omitted
---@field y? integer 1 if omitted ---@field y? integer 1 if omitted
---@field fg_bg? cpair foreground/background colors ---@field fg_bg? cpair foreground/background colors
-- new indicator light -- new tri-state indicator light
---@nodiscard
---@param args tristate_indicator_light_args ---@param args tristate_indicator_light_args
---@return graphics_element element, element_id id ---@return graphics_element element, element_id id
local function tristate_indicator_light(args) local function tristate_indicator_light(args)
@@ -23,12 +29,19 @@ local function tristate_indicator_light(args)
assert(type(args.c2) == "number", "graphics.elements.indicators.trilight: c2 is a required field") assert(type(args.c2) == "number", "graphics.elements.indicators.trilight: c2 is a required field")
assert(type(args.c3) == "number", "graphics.elements.indicators.trilight: c3 is a required field") assert(type(args.c3) == "number", "graphics.elements.indicators.trilight: c3 is a required field")
if args.flash then
assert(util.is_int(args.period), "graphics.elements.indicators.trilight: period is a required field if flash is enabled")
end
-- single line -- single line
args.height = 1 args.height = 1
-- determine width -- determine width
args.width = math.max(args.min_label_width or 1, string.len(args.label)) + 2 args.width = math.max(args.min_label_width or 1, string.len(args.label)) + 2
-- flasher state
local flash_on = true
-- blit translations -- blit translations
local c1 = colors.toBlit(args.c1) local c1 = colors.toBlit(args.c1)
local c2 = colors.toBlit(args.c2) local c2 = colors.toBlit(args.c2)
@@ -37,12 +50,45 @@ local function tristate_indicator_light(args)
-- create new graphics element base object -- create new graphics element base object
local e = element.new(args) local e = element.new(args)
-- init value for initial check in on_update
e.value = 1
-- called by flasher when enabled
local function flash_callback()
e.window.setCursorPos(1, 1)
if flash_on then
if e.value == 2 then
e.window.blit(" \x95", "0" .. c2, c2 .. e.fg_bg.blit_bkg)
elseif e.value == 3 then
e.window.blit(" \x95", "0" .. c3, c3 .. e.fg_bg.blit_bkg)
end
else
e.window.blit(" \x95", "0" .. c1, c1 .. e.fg_bg.blit_bkg)
end
flash_on = not flash_on
end
-- on state change -- on state change
---@param new_state integer indicator state ---@param new_state integer indicator state
function e.on_update(new_state) function e.on_update(new_state)
local was_off = e.value <= 1
e.value = new_state e.value = new_state
e.window.setCursorPos(1, 1) e.window.setCursorPos(1, 1)
if new_state == 2 then
if args.flash then
if was_off and (new_state > 1) then
flash_on = true
flasher.start(flash_callback, args.period)
elseif new_state <= 1 then
flash_on = false
flasher.stop(flash_callback)
e.window.blit(" \x95", "0" .. c1, c1 .. e.fg_bg.blit_bkg)
end
elseif new_state == 2 then
e.window.blit(" \x95", "0" .. c2, c2 .. e.fg_bg.blit_bkg) e.window.blit(" \x95", "0" .. c2, c2 .. e.fg_bg.blit_bkg)
elseif new_state == 3 then elseif new_state == 3 then
e.window.blit(" \x95", "0" .. c3, c3 .. e.fg_bg.blit_bkg) e.window.blit(" \x95", "0" .. c3, c3 .. e.fg_bg.blit_bkg)
@@ -56,7 +102,7 @@ local function tristate_indicator_light(args)
function e.set_value(val) e.on_update(val) end function e.set_value(val) e.on_update(val) end
-- write label and initial indicator light -- write label and initial indicator light
e.on_update(0) e.on_update(1)
e.window.write(args.label) e.window.write(args.label)
return e.get() return e.get()

View File

@@ -15,6 +15,7 @@ local element = require("graphics.element")
---@field fg_bg? cpair foreground/background colors ---@field fg_bg? cpair foreground/background colors
-- new vertical bar -- new vertical bar
---@nodiscard
---@param args vbar_args ---@param args vbar_args
---@return graphics_element element, element_id id ---@return graphics_element element, element_id id
local function vbar(args) local function vbar(args)
@@ -89,7 +90,9 @@ local function vbar(args)
-- re-draw -- re-draw
last_num_bars = 0 last_num_bars = 0
e.on_update(e.value) if type(e.value) == "number" then
e.on_update(e.value)
end
end end
-- set the percentage value -- set the percentage value

View File

@@ -6,6 +6,7 @@ local element = require("graphics.element")
---@class rectangle_args ---@class rectangle_args
---@field border? graphics_border ---@field border? graphics_border
---@field thin? boolean true to use extra thin even borders
---@field parent graphics_element ---@field parent graphics_element
---@field id? string element id ---@field id? string element id
---@field x? integer 1 if omitted ---@field x? integer 1 if omitted
@@ -19,6 +20,14 @@ local element = require("graphics.element")
---@param args rectangle_args ---@param args rectangle_args
---@return graphics_element element, element_id id ---@return graphics_element element, element_id id
local function rectangle(args) local function rectangle(args)
assert(args.border ~= nil or args.thin ~= true, "graphics.elements.rectangle: thin requires border to be provided")
-- if thin, then width will always need to be 1
if args.thin == true then
args.border.width = 1
args.border.even = true
end
-- offset children -- offset children
if args.border ~= nil then if args.border ~= nil then
args.offset_x = args.border.width args.offset_x = args.border.width
@@ -52,22 +61,42 @@ local function rectangle(args)
-- form the basic line strings and top/bottom blit strings -- form the basic line strings and top/bottom blit strings
local spaces = util.spaces(e.frame.w) local spaces = util.spaces(e.frame.w)
local blit_fg = util.strrep(e.fg_bg.blit_fgd, e.frame.w) local blit_fg = util.strrep(e.fg_bg.blit_fgd, e.frame.w)
local blit_fg_sides = blit_fg
local blit_bg_sides = "" local blit_bg_sides = ""
local blit_bg_top_bot = util.strrep(border_blit, e.frame.w) local blit_bg_top_bot = util.strrep(border_blit, e.frame.w)
-- partial bars -- partial bars
local p_a = util.spaces(border_width) .. util.strrep("\x8f", inner_width) .. util.spaces(border_width) local p_a = util.spaces(border_width) .. util.strrep("\x8f", inner_width) .. util.spaces(border_width)
local p_b = util.spaces(border_width) .. util.strrep("\x83", inner_width) .. util.spaces(border_width) local p_b = util.spaces(border_width) .. util.strrep("\x83", inner_width) .. util.spaces(border_width)
local p_s = spaces
if args.thin == true then
p_a = "\x97" .. util.strrep("\x83", inner_width) .. "\x94"
p_b = "\x8a" .. util.strrep("\x8f", inner_width) .. "\x85"
p_s = "\x95" .. util.spaces(inner_width) .. "\x95"
end
local p_inv_fg = util.strrep(border_blit, border_width) .. util.strrep(e.fg_bg.blit_bkg, inner_width) .. local p_inv_fg = util.strrep(border_blit, border_width) .. util.strrep(e.fg_bg.blit_bkg, inner_width) ..
util.strrep(border_blit, border_width) util.strrep(border_blit, border_width)
local p_inv_bg = util.strrep(e.fg_bg.blit_bkg, border_width) .. util.strrep(border_blit, inner_width) .. local p_inv_bg = util.strrep(e.fg_bg.blit_bkg, border_width) .. util.strrep(border_blit, inner_width) ..
util.strrep(e.fg_bg.blit_bkg, border_width) util.strrep(e.fg_bg.blit_bkg, border_width)
if args.thin == true then
p_inv_fg = e.fg_bg.blit_bkg .. util.strrep(e.fg_bg.blit_bkg, inner_width) .. util.strrep(border_blit, border_width)
p_inv_bg = border_blit .. util.strrep(border_blit, inner_width) .. util.strrep(e.fg_bg.blit_bkg, border_width)
blit_fg_sides = border_blit .. util.strrep(e.fg_bg.blit_bkg, inner_width) .. e.fg_bg.blit_bkg
end
-- form the body blit strings (sides are border, inside is normal) -- form the body blit strings (sides are border, inside is normal)
for x = 1, e.frame.w do for x = 1, e.frame.w do
-- edges get border color, center gets normal -- edges get border color, center gets normal
if x <= border_width or x > (e.frame.w - border_width) then if x <= border_width or x > (e.frame.w - border_width) then
blit_bg_sides = blit_bg_sides .. border_blit if args.thin and x == 1 then
blit_bg_sides = blit_bg_sides .. e.fg_bg.blit_bkg
else
blit_bg_sides = blit_bg_sides .. border_blit
end
else else
blit_bg_sides = blit_bg_sides .. e.fg_bg.blit_bkg blit_bg_sides = blit_bg_sides .. e.fg_bg.blit_bkg
end end
@@ -76,36 +105,50 @@ local function rectangle(args)
-- draw rectangle with borders -- draw rectangle with borders
for y = 1, e.frame.h do for y = 1, e.frame.h do
e.window.setCursorPos(1, y) e.window.setCursorPos(1, y)
-- top border
if y <= border_height then if y <= border_height then
-- partial pixel fill -- partial pixel fill
if args.border.even and y == border_height then if args.border.even and y == border_height then
if width_x2 % 3 == 1 then if args.thin == true then
e.window.blit(p_b, p_inv_bg, p_inv_fg)
elseif width_x2 % 3 == 2 then
e.window.blit(p_a, p_inv_bg, p_inv_fg) e.window.blit(p_a, p_inv_bg, p_inv_fg)
else else
-- skip line if width_x2 % 3 == 1 then
e.window.blit(spaces, blit_fg, blit_bg_sides) e.window.blit(p_b, p_inv_bg, p_inv_fg)
elseif width_x2 % 3 == 2 then
e.window.blit(p_a, p_inv_bg, p_inv_fg)
else
-- skip line
e.window.blit(spaces, blit_fg, blit_bg_sides)
end
end end
else else
e.window.blit(spaces, blit_fg, blit_bg_top_bot) e.window.blit(spaces, blit_fg, blit_bg_top_bot)
end end
-- bottom border
elseif y > (e.frame.h - border_width) then elseif y > (e.frame.h - border_width) then
-- partial pixel fill -- partial pixel fill
if args.border.even and y == ((e.frame.h - border_width) + 1) then if args.border.even and y == ((e.frame.h - border_width) + 1) then
if width_x2 % 3 == 1 then if args.thin == true then
e.window.blit(p_a, p_inv_fg, blit_bg_top_bot) e.window.blit(p_b, util.strrep(e.fg_bg.blit_bkg, e.frame.w), blit_bg_top_bot)
elseif width_x2 % 3 == 2 then
e.window.blit(p_b, p_inv_fg, blit_bg_top_bot)
else else
-- skip line if width_x2 % 3 == 1 then
e.window.blit(spaces, blit_fg, blit_bg_sides) e.window.blit(p_a, p_inv_fg, blit_bg_top_bot)
elseif width_x2 % 3 == 2 or (args.thin == true) then
e.window.blit(p_b, p_inv_fg, blit_bg_top_bot)
else
-- skip line
e.window.blit(spaces, blit_fg, blit_bg_sides)
end
end end
else else
e.window.blit(spaces, blit_fg, blit_bg_top_bot) e.window.blit(spaces, blit_fg, blit_bg_top_bot)
end end
else else
e.window.blit(spaces, blit_fg, blit_bg_sides) if args.thin == true then
e.window.blit(p_s, blit_fg_sides, blit_bg_sides)
else
e.window.blit(p_s, blit_fg, blit_bg_sides)
end
end end
end end
end end

View File

@@ -60,7 +60,7 @@ local function tiling(args)
-- create pattern -- create pattern
for y = start_y, inner_height + (start_y - 1) do for y = start_y, inner_height + (start_y - 1) do
e.window.setCursorPos(start_x, y) e.window.setCursorPos(start_x, y)
for x = 1, inner_width do for _ = 1, inner_width do
if alternator then if alternator then
if even then if even then
e.window.blit(" ", "00", fill_a .. fill_a) e.window.blit(" ", "00", fill_a .. fill_a)

80
graphics/flasher.lua Normal file
View File

@@ -0,0 +1,80 @@
--
-- Indicator Light Flasher
--
local tcd = require("scada-common.tcallbackdsp")
local flasher = {}
-- note: no additional call needs to be made in a main loop as this class automatically uses the TCD to operate
---@alias PERIOD integer
local PERIOD = {
BLINK_250_MS = 1,
BLINK_500_MS = 2,
BLINK_1000_MS = 3
}
flasher.PERIOD = PERIOD
local active = false
local registry = { {}, {}, {} } -- one registry table per period
local callback_counter = 0
-- blink registered indicators<br>
-- this assumes it is called every 250ms, it does no checking of time on its own
local function callback_250ms()
if active then
for _, f in ipairs(registry[PERIOD.BLINK_250_MS]) do f() end
if callback_counter % 2 == 0 then
for _, f in ipairs(registry[PERIOD.BLINK_500_MS]) do f() end
end
if callback_counter % 4 == 0 then
for _, f in ipairs(registry[PERIOD.BLINK_1000_MS]) do f() end
end
callback_counter = callback_counter + 1
tcd.dispatch_unique(0.25, callback_250ms)
end
end
-- start/resume the flasher periodic
function flasher.run()
active = true
callback_250ms()
end
-- clear all blinking indicators and stop the flasher periodic
function flasher.clear()
active = false
callback_counter = 0
registry = { {}, {}, {} }
end
-- register a function to be called on the selected blink period<br>
-- times are not strictly enforced, but all with a given period will be set at the same time
---@param f function function to call each period
---@param period PERIOD time period option (1, 2, or 3)
function flasher.start(f, period)
if type(registry[period]) == "table" then
table.insert(registry[period], f)
end
end
-- stop a function from being called at the blink period
---@param f function function callback registered
function flasher.stop(f)
for i = 1, #registry do
for key, val in ipairs(registry[i]) do
if val == f then
table.remove(registry[i], key)
return
end
end
end
end
return flasher

106
imgen.py Normal file
View File

@@ -0,0 +1,106 @@
import json
import os
# list files in a directory
def list_files(path):
list = []
for (root, dirs, files) in os.walk(path):
for f in files:
list.append(root[2:] + "/" + f)
return list
# get size of all files in a directory
def dir_size(path):
total = 0
for (root, dirs, files) in os.walk(path):
for f in files:
total += os.path.getsize(root + "/" + f)
return total
# get the version of an application at the provided path
def get_version(path, is_comms = False):
ver = ""
string = "comms.version = \""
if not is_comms:
string = "_VERSION = \""
f = open(path, "r")
for line in f:
pos = line.find(string)
if pos >= 0:
ver = line[(pos + len(string)):(len(line) - 2)]
break
f.close()
return ver
# generate installation manifest object
def make_manifest(size):
manifest = {
"versions" : {
"installer" : get_version("./ccmsi.lua"),
"bootloader" : get_version("./startup.lua"),
"comms" : get_version("./scada-common/comms.lua", True),
"reactor-plc" : get_version("./reactor-plc/startup.lua"),
"rtu" : get_version("./rtu/startup.lua"),
"supervisor" : get_version("./supervisor/startup.lua"),
"coordinator" : get_version("./coordinator/startup.lua"),
"pocket" : get_version("./pocket/startup.lua")
},
"files" : {
# common files
"system" : [ "initenv.lua", "startup.lua" ],
"common" : list_files("./scada-common"),
"graphics" : list_files("./graphics"),
"lockbox" : list_files("./lockbox"),
# platform files
"reactor-plc" : list_files("./reactor-plc"),
"rtu" : list_files("./rtu"),
"supervisor" : list_files("./supervisor"),
"coordinator" : list_files("./coordinator"),
"pocket" : list_files("./pocket"),
},
"depends" : {
"reactor-plc" : [ "system", "common" ],
"rtu" : [ "system", "common" ],
"supervisor" : [ "system", "common" ],
"coordinator" : [ "system", "common", "graphics" ],
"pocket" : [ "system", "common", "graphics" ]
},
"sizes" : {
# manifest file estimate
"manifest" : size,
# common files
"system" : os.path.getsize("initenv.lua") + os.path.getsize("startup.lua"),
"common" : dir_size("./scada-common"),
"graphics" : dir_size("./graphics"),
"lockbox" : dir_size("./lockbox"),
# platform files
"reactor-plc" : dir_size("./reactor-plc"),
"rtu" : dir_size("./rtu"),
"supervisor" : dir_size("./supervisor"),
"coordinator" : dir_size("./coordinator"),
"pocket" : dir_size("./pocket"),
}
}
return manifest
# write initial manifest with placeholder size
f = open("install_manifest.json", "w")
json.dump(make_manifest("-----"), f)
f.close()
manifest_size = os.path.getsize("install_manifest.json")
# calculate file size then regenerate with embedded size
f = open("install_manifest.json", "w")
json.dump(make_manifest(manifest_size), f)
f.close()

1
install_manifest.json Normal file
View File

@@ -0,0 +1 @@
{"versions": {"installer": "v1.0", "bootloader": "0.2", "comms": "1.4.0", "reactor-plc": "v1.0.0", "rtu": "v0.13.0", "supervisor": "v0.14.0", "coordinator": "v0.12.2", "pocket": "alpha-v0.0.0"}, "files": {"system": ["initenv.lua", "startup.lua"], "common": ["scada-common/crypto.lua", "scada-common/ppm.lua", "scada-common/comms.lua", "scada-common/psil.lua", "scada-common/tcallbackdsp.lua", "scada-common/rsio.lua", "scada-common/constants.lua", "scada-common/mqueue.lua", "scada-common/crash.lua", "scada-common/log.lua", "scada-common/types.lua", "scada-common/util.lua"], "graphics": ["graphics/element.lua", "graphics/flasher.lua", "graphics/core.lua", "graphics/elements/textbox.lua", "graphics/elements/displaybox.lua", "graphics/elements/pipenet.lua", "graphics/elements/rectangle.lua", "graphics/elements/div.lua", "graphics/elements/tiling.lua", "graphics/elements/colormap.lua", "graphics/elements/indicators/alight.lua", "graphics/elements/indicators/icon.lua", "graphics/elements/indicators/power.lua", "graphics/elements/indicators/rad.lua", "graphics/elements/indicators/state.lua", "graphics/elements/indicators/light.lua", "graphics/elements/indicators/vbar.lua", "graphics/elements/indicators/coremap.lua", "graphics/elements/indicators/data.lua", "graphics/elements/indicators/hbar.lua", "graphics/elements/indicators/trilight.lua", "graphics/elements/controls/switch_button.lua", "graphics/elements/controls/spinbox_numeric.lua", "graphics/elements/controls/hazard_button.lua", "graphics/elements/controls/push_button.lua", "graphics/elements/controls/radio_button.lua", "graphics/elements/controls/multi_button.lua", "graphics/elements/animations/waiting.lua"], "lockbox": ["lockbox/init.lua", "lockbox/LICENSE", "lockbox/kdf/pbkdf2.lua", "lockbox/util/bit.lua", "lockbox/util/array.lua", "lockbox/util/stream.lua", "lockbox/util/queue.lua", "lockbox/digest/sha2_224.lua", "lockbox/digest/sha1.lua", "lockbox/digest/sha2_256.lua", "lockbox/cipher/aes128.lua", "lockbox/cipher/aes256.lua", "lockbox/cipher/aes192.lua", "lockbox/cipher/mode/ofb.lua", "lockbox/cipher/mode/cbc.lua", "lockbox/cipher/mode/ctr.lua", "lockbox/cipher/mode/cfb.lua", "lockbox/mac/hmac.lua", "lockbox/padding/ansix923.lua", "lockbox/padding/pkcs7.lua", "lockbox/padding/zero.lua", "lockbox/padding/isoiec7816.lua"], "reactor-plc": ["reactor-plc/threads.lua", "reactor-plc/plc.lua", "reactor-plc/config.lua", "reactor-plc/startup.lua"], "rtu": ["rtu/threads.lua", "rtu/rtu.lua", "rtu/modbus.lua", "rtu/config.lua", "rtu/startup.lua", "rtu/dev/sps_rtu.lua", "rtu/dev/envd_rtu.lua", "rtu/dev/boilerv_rtu.lua", "rtu/dev/redstone_rtu.lua", "rtu/dev/sna_rtu.lua", "rtu/dev/imatrix_rtu.lua", "rtu/dev/turbinev_rtu.lua"], "supervisor": ["supervisor/supervisor.lua", "supervisor/unit.lua", "supervisor/config.lua", "supervisor/startup.lua", "supervisor/unitlogic.lua", "supervisor/facility.lua", "supervisor/session/coordinator.lua", "supervisor/session/svqtypes.lua", "supervisor/session/svsessions.lua", "supervisor/session/rtu.lua", "supervisor/session/plc.lua", "supervisor/session/rsctl.lua", "supervisor/session/rtu/boilerv.lua", "supervisor/session/rtu/txnctrl.lua", "supervisor/session/rtu/unit_session.lua", "supervisor/session/rtu/turbinev.lua", "supervisor/session/rtu/envd.lua", "supervisor/session/rtu/imatrix.lua", "supervisor/session/rtu/sps.lua", "supervisor/session/rtu/qtypes.lua", "supervisor/session/rtu/sna.lua", "supervisor/session/rtu/redstone.lua"], "coordinator": ["coordinator/coordinator.lua", "coordinator/renderer.lua", "coordinator/iocontrol.lua", "coordinator/sounder.lua", "coordinator/config.lua", "coordinator/startup.lua", "coordinator/apisessions.lua", "coordinator/process.lua", "coordinator/ui/dialog.lua", "coordinator/ui/style.lua", "coordinator/ui/layout/main_view.lua", "coordinator/ui/layout/unit_view.lua", "coordinator/ui/components/reactor.lua", "coordinator/ui/components/processctl.lua", "coordinator/ui/components/unit_overview.lua", "coordinator/ui/components/boiler.lua", "coordinator/ui/components/unit_detail.lua", "coordinator/ui/components/imatrix.lua", "coordinator/ui/components/unit_waiting.lua", "coordinator/ui/components/turbine.lua"], "pocket": ["pocket/config.lua", "pocket/startup.lua"]}, "depends": {"reactor-plc": ["system", "common"], "rtu": ["system", "common"], "supervisor": ["system", "common"], "coordinator": ["system", "common", "graphics"], "pocket": ["system", "common", "graphics"]}, "sizes": {"manifest": 4646, "system": 1982, "common": 91084, "graphics": 99858, "lockbox": 100797, "reactor-plc": 75529, "rtu": 82913, "supervisor": 274491, "coordinator": 180346, "pocket": 335}}

View File

@@ -3,3 +3,14 @@
-- --
require("/initenv").init_env() require("/initenv").init_env()
local util = require("scada-common.util")
local POCKET_VERSION = "alpha-v0.0.0"
local print = util.print
local println = util.println
local print_ts = util.print_ts
local println_ts = util.println_ts
println("Sorry, this isn't written yet :(")

View File

@@ -4,10 +4,16 @@ local config = {}
config.NETWORKED = true config.NETWORKED = true
-- unique reactor ID -- unique reactor ID
config.REACTOR_ID = 1 config.REACTOR_ID = 1
-- port to send packets TO server -- port to send packets TO server
config.SERVER_PORT = 16000 config.SERVER_PORT = 16000
-- port to listen to incoming packets FROM server -- port to listen to incoming packets FROM server
config.LISTEN_PORT = 14001 config.LISTEN_PORT = 14001
-- max trusted modem message distance (0 to disable check)
config.TRUSTED_RANGE = 0
-- time in seconds (>= 2) before assuming a remote device is no longer active
config.COMMS_TIMEOUT = 5
-- log path -- log path
config.LOG_PATH = "/log.txt" config.LOG_PATH = "/log.txt"
-- log mode -- log mode

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@
require("/initenv").init_env() require("/initenv").init_env()
local crash = require("scada-common.crash")
local log = require("scada-common.log") local log = require("scada-common.log")
local mqueue = require("scada-common.mqueue") local mqueue = require("scada-common.mqueue")
local ppm = require("scada-common.ppm") local ppm = require("scada-common.ppm")
@@ -13,7 +14,7 @@ local config = require("reactor-plc.config")
local plc = require("reactor-plc.plc") local plc = require("reactor-plc.plc")
local threads = require("reactor-plc.threads") local threads = require("reactor-plc.threads")
local R_PLC_VERSION = "beta-v0.8.2" local R_PLC_VERSION = "v1.0.0"
local print = util.print local print = util.print
local println = util.println local println = util.println
@@ -30,8 +31,12 @@ cfv.assert_type_bool(config.NETWORKED)
cfv.assert_type_int(config.REACTOR_ID) cfv.assert_type_int(config.REACTOR_ID)
cfv.assert_port(config.SERVER_PORT) cfv.assert_port(config.SERVER_PORT)
cfv.assert_port(config.LISTEN_PORT) cfv.assert_port(config.LISTEN_PORT)
cfv.assert_type_int(config.TRUSTED_RANGE)
cfv.assert_type_num(config.COMMS_TIMEOUT)
cfv.assert_min(config.COMMS_TIMEOUT, 2)
cfv.assert_type_str(config.LOG_PATH) cfv.assert_type_str(config.LOG_PATH)
cfv.assert_type_int(config.LOG_MODE) cfv.assert_type_int(config.LOG_MODE)
assert(cfv.valid(), "bad config file: missing/invalid fields") assert(cfv.valid(), "bad config file: missing/invalid fields")
---------------------------------------- ----------------------------------------
@@ -45,153 +50,175 @@ log.info("BOOTING reactor-plc.startup " .. R_PLC_VERSION)
log.info("========================================") log.info("========================================")
println(">> Reactor PLC " .. R_PLC_VERSION .. " <<") println(">> Reactor PLC " .. R_PLC_VERSION .. " <<")
crash.set_env("plc", R_PLC_VERSION)
---------------------------------------- ----------------------------------------
-- startup -- main application
---------------------------------------- ----------------------------------------
-- mount connected devices local function main()
ppm.mount_all() ----------------------------------------
-- startup
----------------------------------------
-- shared memory across threads -- mount connected devices
---@class plc_shared_memory ppm.mount_all()
local __shared_memory = {
-- networked setting
networked = config.NETWORKED, ---@type boolean
-- PLC system state flags -- shared memory across threads
---@class plc_state ---@class plc_shared_memory
plc_state = { local __shared_memory = {
init_ok = true, -- networked setting
shutdown = false, networked = config.NETWORKED, ---@type boolean
degraded = false,
no_reactor = false,
no_modem = false
},
-- control setpoints -- PLC system state flags
---@class setpoints ---@class plc_state
setpoints = { plc_state = {
burn_rate_en = false, init_ok = true,
burn_rate = 0.0 shutdown = false,
}, degraded = false,
reactor_formed = true,
no_reactor = false,
no_modem = false
},
-- core PLC devices -- control setpoints
plc_dev = { ---@class setpoints
reactor = ppm.get_fission_reactor(), setpoints = {
modem = ppm.get_wireless_modem() burn_rate_en = false,
}, burn_rate = 0.0
},
-- system objects -- core PLC devices
plc_sys = { plc_dev = {
rps = nil, ---@type rps reactor = ppm.get_fission_reactor(),
plc_comms = nil, ---@type plc_comms modem = ppm.get_wireless_modem()
conn_watchdog = nil ---@type watchdog },
},
-- message queues -- system objects
q = { plc_sys = {
mq_rps = mqueue.new(), rps = nil, ---@type rps
mq_comms_tx = mqueue.new(), plc_comms = nil, ---@type plc_comms
mq_comms_rx = mqueue.new() conn_watchdog = nil ---@type watchdog
},
-- message queues
q = {
mq_rps = mqueue.new(),
mq_comms_tx = mqueue.new(),
mq_comms_rx = mqueue.new()
}
} }
}
local smem_dev = __shared_memory.plc_dev local smem_dev = __shared_memory.plc_dev
local smem_sys = __shared_memory.plc_sys local smem_sys = __shared_memory.plc_sys
local plc_state = __shared_memory.plc_state local plc_state = __shared_memory.plc_state
-- we need a reactor and a modem -- we need a reactor, can at least do some things even if it isn't formed though
if smem_dev.reactor == nil then if smem_dev.reactor == nil then
println("boot> fission reactor not found"); println("init> fission reactor not found");
log.warning("no reactor on startup") log.warning("init> no reactor on startup")
plc_state.init_ok = false plc_state.init_ok = false
plc_state.degraded = true plc_state.degraded = true
plc_state.no_reactor = true plc_state.no_reactor = true
end elseif not smem_dev.reactor.isFormed() then
if __shared_memory.networked and smem_dev.modem == nil then println("init> fission reactor not formed");
println("boot> wireless modem not found") log.warning("init> reactor logic adapter present, but reactor is not formed")
log.warning("no wireless modem on startup")
if smem_dev.reactor ~= nil then plc_state.degraded = true
smem_dev.reactor.scram() plc_state.reactor_formed = false
end end
plc_state.init_ok = false -- modem is required if networked
plc_state.degraded = true if __shared_memory.networked and smem_dev.modem == nil then
plc_state.no_modem = true println("init> wireless modem not found")
end log.warning("init> no wireless modem on startup")
-- PLC init -- scram reactor if present and enabled
local function init() if (smem_dev.reactor ~= nil) and plc_state.reactor_formed and smem_dev.reactor.getStatus() then
if plc_state.init_ok then smem_dev.reactor.scram()
-- just booting up, no fission allowed (neutrons stay put thanks)
smem_dev.reactor.scram()
-- init reactor protection system
smem_sys.rps = plc.rps_init(smem_dev.reactor)
log.debug("init> rps init")
if __shared_memory.networked then
-- comms watchdog, 3 second timeout
smem_sys.conn_watchdog = util.new_watchdog(3)
log.debug("init> conn watchdog started")
-- start comms
smem_sys.plc_comms = plc.comms(config.REACTOR_ID, R_PLC_VERSION, smem_dev.modem, config.LISTEN_PORT, config.SERVER_PORT,
smem_dev.reactor, smem_sys.rps, smem_sys.conn_watchdog)
log.debug("init> comms init")
else
println("boot> starting in offline mode");
log.debug("init> running without networking")
end end
---@diagnostic disable-next-line: undefined-field plc_state.init_ok = false
os.queueEvent("clock_start") plc_state.degraded = true
plc_state.no_modem = true
end
println("boot> completed"); -- PLC init<br>
log.debug("init> boot completed") --- EVENT_CONSUMER: this function consumes events
local function init()
if plc_state.init_ok then
-- just booting up, no fission allowed (neutrons stay put thanks)
if plc_state.reactor_formed and smem_dev.reactor.getStatus() then
smem_dev.reactor.scram()
end
-- init reactor protection system
smem_sys.rps = plc.rps_init(smem_dev.reactor, plc_state.reactor_formed)
log.debug("init> rps init")
if __shared_memory.networked then
-- comms watchdog
smem_sys.conn_watchdog = util.new_watchdog(config.COMMS_TIMEOUT)
log.debug("init> conn watchdog started")
-- start comms
smem_sys.plc_comms = plc.comms(config.REACTOR_ID, R_PLC_VERSION, smem_dev.modem, config.LISTEN_PORT, config.SERVER_PORT,
config.TRUSTED_RANGE, smem_dev.reactor, smem_sys.rps, smem_sys.conn_watchdog)
log.debug("init> comms init")
else
println("init> starting in offline mode")
log.info("init> running without networking")
end
util.push_event("clock_start")
println("init> completed")
log.info("init> startup completed")
else
println("init> system in degraded state, awaiting devices...")
log.warning("init> started in a degraded state, awaiting peripheral connections...")
end
end
----------------------------------------
-- start system
----------------------------------------
-- initialize PLC
init()
-- init threads
local main_thread = threads.thread__main(__shared_memory, init)
local rps_thread = threads.thread__rps(__shared_memory)
if __shared_memory.networked then
-- init comms threads
local comms_thread_tx = threads.thread__comms_tx(__shared_memory)
local comms_thread_rx = threads.thread__comms_rx(__shared_memory)
-- setpoint control only needed when networked
local sp_ctrl_thread = threads.thread__setpoint_control(__shared_memory)
-- run threads
parallel.waitForAll(main_thread.p_exec, rps_thread.p_exec, comms_thread_tx.p_exec, comms_thread_rx.p_exec, sp_ctrl_thread.p_exec)
if plc_state.init_ok then
-- send status one last time after RPS shutdown
smem_sys.plc_comms.send_status(plc_state.no_reactor, plc_state.reactor_formed)
smem_sys.plc_comms.send_rps_status()
-- close connection
smem_sys.plc_comms.close()
end
else else
println("boot> system in degraded state, awaiting devices...") -- run threads, excluding comms
log.warning("init> booted in a degraded state, awaiting peripheral connections...") parallel.waitForAll(main_thread.p_exec, rps_thread.p_exec)
end end
println_ts("exited")
log.info("exited")
end end
---------------------------------------- if not xpcall(main, crash.handler) then crash.exit() end
-- start system
----------------------------------------
-- initialize PLC
init()
-- init threads
local main_thread = threads.thread__main(__shared_memory, init)
local rps_thread = threads.thread__rps(__shared_memory)
if __shared_memory.networked then
-- init comms threads
local comms_thread_tx = threads.thread__comms_tx(__shared_memory)
local comms_thread_rx = threads.thread__comms_rx(__shared_memory)
-- setpoint control only needed when networked
local sp_ctrl_thread = threads.thread__setpoint_control(__shared_memory)
-- run threads
parallel.waitForAll(main_thread.p_exec, rps_thread.p_exec, comms_thread_tx.p_exec, comms_thread_rx.p_exec, sp_ctrl_thread.p_exec)
if plc_state.init_ok then
-- send status one last time after RPS shutdown
smem_sys.plc_comms.send_status(plc_state.degraded)
smem_sys.plc_comms.send_rps_status()
-- close connection
smem_sys.plc_comms.close()
end
else
-- run threads, excluding comms
parallel.waitForAll(main_thread.p_exec, rps_thread.p_exec)
end
println_ts("exited")
log.info("exited")

View File

@@ -10,7 +10,7 @@ local println = util.println
local print_ts = util.print_ts local print_ts = util.print_ts
local println_ts = util.println_ts local println_ts = util.println_ts
local MAIN_CLOCK = 1 -- (1Hz, 20 ticks) local MAIN_CLOCK = 0.5 -- (2Hz, 10 ticks)
local RPS_SLEEP = 250 -- (250ms, 5 ticks) local RPS_SLEEP = 250 -- (250ms, 5 ticks)
local COMMS_SLEEP = 150 -- (150ms, 3 ticks) local COMMS_SLEEP = 150 -- (150ms, 3 ticks)
local SP_CTRL_SLEEP = 250 -- (250ms, 5 ticks) local SP_CTRL_SLEEP = 250 -- (250ms, 5 ticks)
@@ -28,25 +28,27 @@ local MQ__COMM_CMD = {
} }
-- main thread -- main thread
---@nodiscard
---@param smem plc_shared_memory ---@param smem plc_shared_memory
---@param init function ---@param init function
function threads.thread__main(smem, init) function threads.thread__main(smem, init)
local public = {} ---@class thread ---@class parallel_thread
local public = {}
-- execute thread -- execute thread
function public.exec() function public.exec()
log.debug("main thread init, clock inactive") log.debug("main thread init, clock inactive")
-- send status updates at 1Hz (every 20 server ticks) (every loop tick) -- send status updates at 2Hz (every 10 server ticks) (every loop tick)
-- send link requests at 0.5Hz (every 40 server ticks) (every 4 loop ticks) -- send link requests at 0.5Hz (every 40 server ticks) (every 8 loop ticks)
local LINK_TICKS = 4 local LINK_TICKS = 8
local ticks_to_update = 0 local ticks_to_update = 0
local loop_clock = util.new_clock(MAIN_CLOCK) local loop_clock = util.new_clock(MAIN_CLOCK)
-- load in from shared memory -- load in from shared memory
local networked = smem.networked local networked = smem.networked
local plc_state = smem.plc_state local plc_state = smem.plc_state
local plc_dev = smem.plc_dev local plc_dev = smem.plc_dev
-- event loop -- event loop
while true do while true do
@@ -78,6 +80,59 @@ function threads.thread__main(smem, init)
end end
end end
end end
-- are we now formed after waiting to be formed?
if (not plc_state.reactor_formed) and rps.is_formed() then
-- push a connect event and unmount it from the PPM
local iface = ppm.get_iface(plc_dev.reactor)
if iface then
log.info("unmounting and remounting unformed reactor")
ppm.unmount(plc_dev.reactor)
local type, device = ppm.mount(iface)
if type == "fissionReactorLogicAdapter" and device ~= nil then
-- reconnect reactor
plc_dev.reactor = device
-- we need to assume formed here as we cannot check in this main loop
-- RPS will identify if it isn't and this will get set false later
plc_state.reactor_formed = true
println_ts("reactor reconnected.")
log.info("reactor reconnected")
-- SCRAM newly connected reactor
smem.q.mq_rps.push_command(MQ__RPS_CMD.SCRAM)
-- determine if we are still in a degraded state
if not networked or not plc_state.no_modem then
plc_state.degraded = false
end
rps.reconnect_reactor(plc_dev.reactor)
if networked then
plc_comms.reconnect_reactor(plc_dev.reactor)
end
-- reset RPS for newly connected reactor
-- without this, is_formed will be out of date and cause it to think its no longer formed again
rps.reset()
else
-- fully lost the reactor now :(
println_ts("reactor lost (failed reconnect)!")
log.error("reactor lost (failed reconnect)")
plc_state.no_reactor = true
plc_state.degraded = true
end
else
log.error("failed to get interface of previously connected reactor", true)
end
elseif not rps.is_formed() then
-- reactor no longer formed
plc_state.reactor_formed = false
end
elseif event == "modem_message" and networked and plc_state.init_ok and not plc_state.no_modem then elseif event == "modem_message" and networked and plc_state.init_ok and not plc_state.no_modem then
-- got a packet -- got a packet
local packet = plc_comms.parse_packet(param1, param2, param3, param4, param5) local packet = plc_comms.parse_packet(param1, param2, param3, param4, param5)
@@ -94,16 +149,18 @@ function threads.thread__main(smem, init)
local type, device = ppm.handle_unmount(param1) local type, device = ppm.handle_unmount(param1)
if type ~= nil and device ~= nil then if type ~= nil and device ~= nil then
if type == "fissionReactor" then if type == "fissionReactorLogicAdapter" then
println_ts("reactor disconnected!") println_ts("reactor disconnected!")
log.error("reactor disconnected!") log.error("reactor logic adapter disconnected")
plc_state.no_reactor = true plc_state.no_reactor = true
plc_state.degraded = true plc_state.degraded = true
elseif networked and type == "modem" then elseif networked and type == "modem" then
-- we only care if this is our wireless modem -- we only care if this is our wireless modem
if device == plc_dev.modem then if device == plc_dev.modem then
println_ts("wireless modem disconnected!") println_ts("comms modem disconnected!")
log.error("comms modem disconnected!") log.error("comms modem disconnected")
plc_state.no_modem = true plc_state.no_modem = true
if plc_state.init_ok then if plc_state.init_ok then
@@ -122,26 +179,35 @@ function threads.thread__main(smem, init)
local type, device = ppm.mount(param1) local type, device = ppm.mount(param1)
if type ~= nil and device ~= nil then if type ~= nil and device ~= nil then
if type == "fissionReactor" then if type == "fissionReactorLogicAdapter" then
-- reconnected reactor -- reconnected reactor
plc_dev.reactor = device plc_dev.reactor = device
smem.q.mq_rps.push_command(MQ__RPS_CMD.SCRAM)
println_ts("reactor reconnected.") println_ts("reactor reconnected.")
log.info("reactor reconnected") log.info("reactor reconnected")
plc_state.no_reactor = false plc_state.no_reactor = false
-- we need to assume formed here as we cannot check in this main loop
-- RPS will identify if it isn't and this will get set false later
plc_state.reactor_formed = true
-- determine if we are still in a degraded state
if (not networked or not plc_state.no_modem) and plc_state.reactor_formed then
plc_state.degraded = false
end
if plc_state.init_ok then if plc_state.init_ok then
smem.q.mq_rps.push_command(MQ__RPS_CMD.SCRAM)
rps.reconnect_reactor(plc_dev.reactor) rps.reconnect_reactor(plc_dev.reactor)
if networked then if networked then
plc_comms.reconnect_reactor(plc_dev.reactor) plc_comms.reconnect_reactor(plc_dev.reactor)
end end
end
-- determine if we are still in a degraded state -- reset RPS for newly connected reactor
if not networked or not plc_state.no_modem then -- without this, is_formed will be out of date and cause it to think its no longer formed again
plc_state.degraded = false rps.reset()
end end
elseif networked and type == "modem" then elseif networked and type == "modem" then
if device.isWireless() then if device.isWireless() then
@@ -194,7 +260,7 @@ function threads.thread__main(smem, init)
while not plc_state.shutdown do while not plc_state.shutdown do
local status, result = pcall(public.exec) local status, result = pcall(public.exec)
if status == false then if status == false then
log.fatal(result) log.fatal(util.strval(result))
end end
-- if status is true, then we are probably exiting, so this won't matter -- if status is true, then we are probably exiting, so this won't matter
@@ -202,9 +268,7 @@ function threads.thread__main(smem, init)
-- this thread cannot be slept because it will miss events (namely "terminate" otherwise) -- this thread cannot be slept because it will miss events (namely "terminate" otherwise)
if not plc_state.shutdown then if not plc_state.shutdown then
log.info("main thread restarting now...") log.info("main thread restarting now...")
util.push_event("clock_start")
---@diagnostic disable-next-line: undefined-field
os.queueEvent("clock_start")
end end
end end
end end
@@ -213,9 +277,11 @@ function threads.thread__main(smem, init)
end end
-- RPS operation thread -- RPS operation thread
---@nodiscard
---@param smem plc_shared_memory ---@param smem plc_shared_memory
function threads.thread__rps(smem) function threads.thread__rps(smem)
local public = {} ---@class thread ---@class parallel_thread
local public = {}
-- execute thread -- execute thread
function public.exec() function public.exec()
@@ -234,10 +300,10 @@ function threads.thread__rps(smem)
-- thread loop -- thread loop
while true do while true do
-- get plc_sys fields (may have been set late due to degraded boot) -- get plc_sys fields (may have been set late due to degraded boot)
local rps = smem.plc_sys.rps local rps = smem.plc_sys.rps
local plc_comms = smem.plc_sys.plc_comms local plc_comms = smem.plc_sys.plc_comms
-- get reactor, may have changed do to disconnect/reconnect -- get reactor, may have changed do to disconnect/reconnect
local reactor = plc_dev.reactor local reactor = plc_dev.reactor
-- RPS checks -- RPS checks
if plc_state.init_ok then if plc_state.init_ok then
@@ -255,13 +321,13 @@ function threads.thread__rps(smem)
-- if we tried to SCRAM but failed, keep trying -- if we tried to SCRAM but failed, keep trying
-- in that case, SCRAM won't be called until it reconnects (this is the expected use of this check) -- in that case, SCRAM won't be called until it reconnects (this is the expected use of this check)
---@diagnostic disable-next-line: need-check-nil ---@diagnostic disable-next-line: need-check-nil
if not plc_state.no_reactor and rps.is_tripped() and reactor.getStatus() then if (not plc_state.no_reactor) and rps.is_formed() and rps.is_tripped() and reactor.getStatus() then
rps.scram() rps.scram()
end end
-- if we are in standalone mode, continuously reset RPS -- if we are in standalone mode, continuously reset RPS
-- RPS will trip again if there are faults, but if it isn't cleared, the user can't re-enable -- RPS will trip again if there are faults, but if it isn't cleared, the user can't re-enable
if not networked then rps.reset() end if not networked then rps.reset(true) end
-- check safety (SCRAM occurs if tripped) -- check safety (SCRAM occurs if tripped)
if not plc_state.no_reactor then if not plc_state.no_reactor then
@@ -337,7 +403,7 @@ function threads.thread__rps(smem)
while not plc_state.shutdown do while not plc_state.shutdown do
local status, result = pcall(public.exec) local status, result = pcall(public.exec)
if status == false then if status == false then
log.fatal(result) log.fatal(util.strval(result))
end end
if not plc_state.shutdown then if not plc_state.shutdown then
@@ -352,9 +418,11 @@ function threads.thread__rps(smem)
end end
-- communications sender thread -- communications sender thread
---@nodiscard
---@param smem plc_shared_memory ---@param smem plc_shared_memory
function threads.thread__comms_tx(smem) function threads.thread__comms_tx(smem)
local public = {} ---@class thread ---@class parallel_thread
local public = {}
-- execute thread -- execute thread
function public.exec() function public.exec()
@@ -380,7 +448,7 @@ function threads.thread__comms_tx(smem)
-- received a command -- received a command
if msg.message == MQ__COMM_CMD.SEND_STATUS then if msg.message == MQ__COMM_CMD.SEND_STATUS then
-- send PLC/RPS status -- send PLC/RPS status
plc_comms.send_status(plc_state.degraded) plc_comms.send_status(plc_state.no_reactor, plc_state.reactor_formed)
plc_comms.send_rps_status() plc_comms.send_rps_status()
end end
elseif msg.qtype == mqueue.TYPE.DATA then elseif msg.qtype == mqueue.TYPE.DATA then
@@ -412,7 +480,7 @@ function threads.thread__comms_tx(smem)
while not plc_state.shutdown do while not plc_state.shutdown do
local status, result = pcall(public.exec) local status, result = pcall(public.exec)
if status == false then if status == false then
log.fatal(result) log.fatal(util.strval(result))
end end
if not plc_state.shutdown then if not plc_state.shutdown then
@@ -426,9 +494,11 @@ function threads.thread__comms_tx(smem)
end end
-- communications handler thread -- communications handler thread
---@nodiscard
---@param smem plc_shared_memory ---@param smem plc_shared_memory
function threads.thread__comms_rx(smem) function threads.thread__comms_rx(smem)
local public = {} ---@class thread ---@class parallel_thread
local public = {}
-- execute thread -- execute thread
function public.exec() function public.exec()
@@ -460,7 +530,7 @@ function threads.thread__comms_rx(smem)
-- received a packet -- received a packet
-- handle the packet (setpoints passed to update burn rate setpoint) -- handle the packet (setpoints passed to update burn rate setpoint)
-- (plc_state passed to check if degraded) -- (plc_state passed to check if degraded)
plc_comms.handle_packet(msg.message, setpoints, plc_state) plc_comms.handle_packet(msg.message, plc_state, setpoints)
end end
end end
@@ -486,7 +556,7 @@ function threads.thread__comms_rx(smem)
while not plc_state.shutdown do while not plc_state.shutdown do
local status, result = pcall(public.exec) local status, result = pcall(public.exec)
if status == false then if status == false then
log.fatal(result) log.fatal(util.strval(result))
end end
if not plc_state.shutdown then if not plc_state.shutdown then
@@ -499,10 +569,12 @@ function threads.thread__comms_rx(smem)
return public return public
end end
-- apply setpoints -- ramp control outputs to desired setpoints
---@nodiscard
---@param smem plc_shared_memory ---@param smem plc_shared_memory
function threads.thread__setpoint_control(smem) function threads.thread__setpoint_control(smem)
local public = {} ---@class thread ---@class parallel_thread
local public = {}
-- execute thread -- execute thread
function public.exec() function public.exec()
@@ -516,7 +588,7 @@ function threads.thread__setpoint_control(smem)
local last_update = util.time() local last_update = util.time()
local running = false local running = false
local last_sp_burn = 0.0 local last_burn_sp = 0.0
-- do not use the actual elapsed time, it could spike -- do not use the actual elapsed time, it could spike
-- we do not want to have big jumps as that is what we are trying to avoid in the first place -- we do not want to have big jumps as that is what we are trying to avoid in the first place
@@ -529,23 +601,25 @@ function threads.thread__setpoint_control(smem)
-- get reactor, may have changed do to disconnect/reconnect -- get reactor, may have changed do to disconnect/reconnect
local reactor = plc_dev.reactor local reactor = plc_dev.reactor
if plc_state.init_ok and not plc_state.no_reactor then if plc_state.init_ok and (not plc_state.no_reactor) then
-- check if we should start ramping -- check if we should start ramping
if setpoints.burn_rate_en and setpoints.burn_rate ~= last_sp_burn then if setpoints.burn_rate_en and (setpoints.burn_rate ~= last_burn_sp) then
if rps.is_active() then ---@diagnostic disable-next-line: need-check-nil
if math.abs(setpoints.burn_rate - last_sp_burn) <= 5 then local cur_burn_rate = reactor.getBurnRate()
-- update without ramp if <= 5 mB/t change
log.debug("setting burn rate directly to " .. setpoints.burn_rate .. "mB/t") if (type(cur_burn_rate) == "number") and (setpoints.burn_rate ~= cur_burn_rate) and rps.is_active() then
last_burn_sp = setpoints.burn_rate
-- update without ramp if <= 2.5 mB/t change
running = math.abs(setpoints.burn_rate - cur_burn_rate) > 2.5
if running then
log.debug(util.c("SPCTL: starting burn rate ramp from ", cur_burn_rate, " mB/t to ", setpoints.burn_rate, " mB/t"))
else
log.debug(util.c("SPCTL: setting burn rate directly to ", setpoints.burn_rate, " mB/t"))
---@diagnostic disable-next-line: need-check-nil ---@diagnostic disable-next-line: need-check-nil
reactor.setBurnRate(setpoints.burn_rate) reactor.setBurnRate(setpoints.burn_rate)
else
log.debug("starting burn rate ramp from " .. last_sp_burn .. "mB/t to " .. setpoints.burn_rate .. "mB/t")
running = true
end end
last_sp_burn = setpoints.burn_rate
else
last_sp_burn = 0.0
end end
end end
@@ -561,34 +635,39 @@ function threads.thread__setpoint_control(smem)
local current_burn_rate = reactor.getBurnRate() local current_burn_rate = reactor.getBurnRate()
-- we yielded, check enable again -- we yielded, check enable again
if setpoints.burn_rate_en and (current_burn_rate ~= ppm.ACCESS_FAULT) and (current_burn_rate ~= setpoints.burn_rate) then if setpoints.burn_rate_en and (type(current_burn_rate) == "number") and (current_burn_rate ~= setpoints.burn_rate) then
-- calculate new burn rate -- calculate new burn rate
local new_burn_rate = current_burn_rate local new_burn_rate = current_burn_rate
if setpoints.burn_rate > current_burn_rate then if setpoints.burn_rate > current_burn_rate then
-- need to ramp up -- need to ramp up
local new_burn_rate = current_burn_rate + (BURN_RATE_RAMP_mB_s * min_elapsed_s) new_burn_rate = current_burn_rate + (BURN_RATE_RAMP_mB_s * min_elapsed_s)
if new_burn_rate > setpoints.burn_rate then if new_burn_rate > setpoints.burn_rate then new_burn_rate = setpoints.burn_rate end
new_burn_rate = setpoints.burn_rate
end
else else
-- need to ramp down -- need to ramp down
local new_burn_rate = current_burn_rate - (BURN_RATE_RAMP_mB_s * min_elapsed_s) new_burn_rate = current_burn_rate - (BURN_RATE_RAMP_mB_s * min_elapsed_s)
if new_burn_rate < setpoints.burn_rate then if new_burn_rate < setpoints.burn_rate then new_burn_rate = setpoints.burn_rate end
new_burn_rate = setpoints.burn_rate
end
end end
running = running or (new_burn_rate ~= setpoints.burn_rate)
-- set the burn rate -- set the burn rate
---@diagnostic disable-next-line: need-check-nil ---@diagnostic disable-next-line: need-check-nil
reactor.setBurnRate(new_burn_rate) reactor.setBurnRate(new_burn_rate)
running = running or (new_burn_rate ~= setpoints.burn_rate)
end end
else else
last_sp_burn = 0.0 log.debug("SPCTL: ramping aborted (reactor inactive)")
setpoints.burn_rate_en = false
end end
end end
elseif setpoints.burn_rate_en then
log.debug(util.c("SPCTL: ramping completed (setpoint of ", setpoints.burn_rate, " mB/t)"))
setpoints.burn_rate_en = false
end
-- if ramping completed or was aborted, reset last burn setpoint so that if it is requested again it will be re-attempted
if not setpoints.burn_rate_en then
last_burn_sp = 0
end end
end end
@@ -610,7 +689,7 @@ function threads.thread__setpoint_control(smem)
while not plc_state.shutdown do while not plc_state.shutdown do
local status, result = pcall(public.exec) local status, result = pcall(public.exec)
if status == false then if status == false then
log.fatal(result) log.fatal(util.strval(result))
end end
if not plc_state.shutdown then if not plc_state.shutdown then

View File

@@ -6,12 +6,18 @@ local config = {}
config.SERVER_PORT = 16000 config.SERVER_PORT = 16000
-- port to listen to incoming packets FROM server -- port to listen to incoming packets FROM server
config.LISTEN_PORT = 15001 config.LISTEN_PORT = 15001
-- max trusted modem message distance (< 1 to disable check)
config.TRUSTED_RANGE = 0
-- time in seconds (>= 2) before assuming a remote device is no longer active
config.COMMS_TIMEOUT = 5
-- log path -- log path
config.LOG_PATH = "/log.txt" config.LOG_PATH = "/log.txt"
-- log mode -- log mode
-- 0 = APPEND (adds to existing file on start) -- 0 = APPEND (adds to existing file on start)
-- 1 = NEW (replaces existing file on start) -- 1 = NEW (replaces existing file on start)
config.LOG_MODE = 0 config.LOG_MODE = 0
-- RTU peripheral devices (named: side/network device name) -- RTU peripheral devices (named: side/network device name)
config.RTU_DEVICES = { config.RTU_DEVICES = {
{ {
@@ -27,26 +33,31 @@ config.RTU_DEVICES = {
} }
-- RTU redstone interface definitions -- RTU redstone interface definitions
config.RTU_REDSTONE = { config.RTU_REDSTONE = {
{ -- {
for_reactor = 1, -- for_reactor = 1,
io = { -- io = {
{ -- {
channel = rsio.IO.WASTE_PO, -- port = rsio.IO.WASTE_PO,
side = "top", -- side = "top",
bundled_color = colors.blue -- bundled_color = colors.red
}, -- },
{ -- {
channel = rsio.IO.WASTE_PU, -- port = rsio.IO.WASTE_PU,
side = "top", -- side = "top",
bundled_color = colors.cyan -- bundled_color = colors.orange
}, -- },
{ -- {
channel = rsio.IO.WASTE_AM, -- port = rsio.IO.WASTE_POPL,
side = "top", -- side = "top",
bundled_color = colors.purple -- bundled_color = colors.yellow
} -- },
} -- {
} -- port = rsio.IO.WASTE_AM,
-- side = "top",
-- bundled_color = colors.lime
-- }
-- }
-- }
} }
return config return config

View File

@@ -1,48 +0,0 @@
local rtu = require("rtu.rtu")
local boiler_rtu = {}
-- create new boiler (mek 10.0) device
---@param boiler table
function boiler_rtu.new(boiler)
local unit = rtu.init_unit()
-- discrete inputs --
-- none
-- coils --
-- none
-- input registers --
-- build properties
unit.connect_input_reg(boiler.getBoilCapacity)
unit.connect_input_reg(boiler.getSteamCapacity)
unit.connect_input_reg(boiler.getWaterCapacity)
unit.connect_input_reg(boiler.getHeatedCoolantCapacity)
unit.connect_input_reg(boiler.getCooledCoolantCapacity)
unit.connect_input_reg(boiler.getSuperheaters)
unit.connect_input_reg(boiler.getMaxBoilRate)
-- current state
unit.connect_input_reg(boiler.getTemperature)
unit.connect_input_reg(boiler.getBoilRate)
-- tanks
unit.connect_input_reg(boiler.getSteam)
unit.connect_input_reg(boiler.getSteamNeeded)
unit.connect_input_reg(boiler.getSteamFilledPercentage)
unit.connect_input_reg(boiler.getWater)
unit.connect_input_reg(boiler.getWaterNeeded)
unit.connect_input_reg(boiler.getWaterFilledPercentage)
unit.connect_input_reg(boiler.getHeatedCoolant)
unit.connect_input_reg(boiler.getHeatedCoolantNeeded)
unit.connect_input_reg(boiler.getHeatedCoolantFilledPercentage)
unit.connect_input_reg(boiler.getCooledCoolant)
unit.connect_input_reg(boiler.getCooledCoolantNeeded)
unit.connect_input_reg(boiler.getCooledCoolantFilledPercentage)
-- holding registers --
-- none
return unit.interface()
end
return boiler_rtu

View File

@@ -3,6 +3,7 @@ local rtu = require("rtu.rtu")
local boilerv_rtu = {} local boilerv_rtu = {}
-- create new boiler (mek 10.1+) device -- create new boiler (mek 10.1+) device
---@nodiscard
---@param boiler table ---@param boiler table
function boilerv_rtu.new(boiler) function boilerv_rtu.new(boiler)
local unit = rtu.init_unit() local unit = rtu.init_unit()
@@ -28,10 +29,10 @@ function boilerv_rtu.new(boiler)
unit.connect_input_reg(boiler.getCooledCoolantCapacity) unit.connect_input_reg(boiler.getCooledCoolantCapacity)
unit.connect_input_reg(boiler.getSuperheaters) unit.connect_input_reg(boiler.getSuperheaters)
unit.connect_input_reg(boiler.getMaxBoilRate) unit.connect_input_reg(boiler.getMaxBoilRate)
unit.connect_input_reg(boiler.getEnvironmentalLoss)
-- current state -- current state
unit.connect_input_reg(boiler.getTemperature) unit.connect_input_reg(boiler.getTemperature)
unit.connect_input_reg(boiler.getBoilRate) unit.connect_input_reg(boiler.getBoilRate)
unit.connect_input_reg(boiler.getEnvironmentalLoss)
-- tanks -- tanks
unit.connect_input_reg(boiler.getSteam) unit.connect_input_reg(boiler.getSteam)
unit.connect_input_reg(boiler.getSteamNeeded) unit.connect_input_reg(boiler.getSteamNeeded)

View File

@@ -1,30 +0,0 @@
local rtu = require("rtu.rtu")
local energymachine_rtu = {}
-- create new energy machine device
---@param machine table
function energymachine_rtu.new(machine)
local unit = rtu.init_unit()
-- discrete inputs --
-- none
-- coils --
-- none
-- input registers --
-- build properties
unit.connect_input_reg(machine.getTotalMaxEnergy)
-- containers
unit.connect_input_reg(machine.getTotalEnergy)
unit.connect_input_reg(machine.getTotalEnergyNeeded)
unit.connect_input_reg(machine.getTotalEnergyFilledPercentage)
-- holding registers --
-- none
return unit.interface()
end
return energymachine_rtu

View File

@@ -3,6 +3,7 @@ local rtu = require("rtu.rtu")
local envd_rtu = {} local envd_rtu = {}
-- create new environment detector device -- create new environment detector device
---@nodiscard
---@param envd table ---@param envd table
function envd_rtu.new(envd) function envd_rtu.new(envd)
local unit = rtu.init_unit() local unit = rtu.init_unit()

View File

@@ -3,6 +3,7 @@ local rtu = require("rtu.rtu")
local imatrix_rtu = {} local imatrix_rtu = {}
-- create new induction matrix (mek 10.1+) device -- create new induction matrix (mek 10.1+) device
---@nodiscard
---@param imatrix table ---@param imatrix table
function imatrix_rtu.new(imatrix) function imatrix_rtu.new(imatrix)
local unit = rtu.init_unit() local unit = rtu.init_unit()

View File

@@ -1,21 +1,24 @@
local rtu = require("rtu.rtu")
local rsio = require("scada-common.rsio") local rsio = require("scada-common.rsio")
local rtu = require("rtu.rtu")
local redstone_rtu = {} local redstone_rtu = {}
local IO_LVL = rsio.IO_LVL
local digital_read = rsio.digital_read local digital_read = rsio.digital_read
local digital_write = rsio.digital_write local digital_write = rsio.digital_write
local digital_is_active = rsio.digital_is_active
-- create new redstone device -- create new redstone device
---@nodiscard
function redstone_rtu.new() function redstone_rtu.new()
local unit = rtu.init_unit() local unit = rtu.init_unit()
-- get RTU interface -- get RTU interface
local interface = unit.interface() local interface = unit.interface()
-- extends rtu_device; fields added manually to please Lua diagnostics
---@class rtu_rs_device ---@class rtu_rs_device
--- extends rtu_device; fields added manually to please Lua diagnostics
local public = { local public = {
io_count = interface.io_count, io_count = interface.io_count,
read_coil = interface.read_coil, read_coil = interface.read_coil,
@@ -46,10 +49,9 @@ function redstone_rtu.new()
end end
-- link digital output -- link digital output
---@param channel RS_IO
---@param side string ---@param side string
---@param color integer ---@param color integer
function public.link_do(channel, side, color) function public.link_do(side, color)
local f_read = nil local f_read = nil
local f_write = nil local f_write = nil
@@ -59,15 +61,17 @@ function redstone_rtu.new()
end end
f_write = function (level) f_write = function (level)
local output = rs.getBundledOutput(side) if level ~= IO_LVL.FLOATING and level ~= IO_LVL.DISCONNECT then
local output = rs.getBundledOutput(side)
if digital_write(channel, level) then if digital_write(level) then
output = colors.combine(output, color) output = colors.combine(output, color)
else else
output = colors.subtract(output, color) output = colors.subtract(output, color)
end
rs.setBundledOutput(side, output)
end end
rs.setBundledOutput(side, output)
end end
else else
f_read = function () f_read = function ()
@@ -75,7 +79,9 @@ function redstone_rtu.new()
end end
f_write = function (level) f_write = function (level)
rs.setOutput(side, digital_is_active(channel, level)) if level ~= IO_LVL.FLOATING and level ~= IO_LVL.DISCONNECT then
rs.setOutput(side, digital_write(level))
end
end end
end end

View File

@@ -2,7 +2,8 @@ local rtu = require("rtu.rtu")
local sna_rtu = {} local sna_rtu = {}
-- create new solar neutron activator (sna) device -- create new solar neutron activator (SNA) device
---@nodiscard
---@param sna table ---@param sna table
function sna_rtu.new(sna) function sna_rtu.new(sna)
local unit = rtu.init_unit() local unit = rtu.init_unit()

View File

@@ -2,7 +2,8 @@ local rtu = require("rtu.rtu")
local sps_rtu = {} local sps_rtu = {}
-- create new super-critical phase shifter (sps) device -- create new super-critical phase shifter (SPS) device
---@nodiscard
---@param sps table ---@param sps table
function sps_rtu.new(sps) function sps_rtu.new(sps)
local unit = rtu.init_unit() local unit = rtu.init_unit()

View File

@@ -1,43 +0,0 @@
local rtu = require("rtu.rtu")
local turbine_rtu = {}
-- create new turbine (mek 10.0) device
---@param turbine table
function turbine_rtu.new(turbine)
local unit = rtu.init_unit()
-- discrete inputs --
-- none
-- coils --
-- none
-- input registers --
-- build properties
unit.connect_input_reg(turbine.getBlades)
unit.connect_input_reg(turbine.getCoils)
unit.connect_input_reg(turbine.getVents)
unit.connect_input_reg(turbine.getDispersers)
unit.connect_input_reg(turbine.getCondensers)
unit.connect_input_reg(turbine.getSteamCapacity)
unit.connect_input_reg(turbine.getMaxFlowRate)
unit.connect_input_reg(turbine.getMaxProduction)
unit.connect_input_reg(turbine.getMaxWaterOutput)
-- current state
unit.connect_input_reg(turbine.getFlowRate)
unit.connect_input_reg(turbine.getProductionRate)
unit.connect_input_reg(turbine.getLastSteamInputRate)
unit.connect_input_reg(turbine.getDumpingMode)
-- tanks
unit.connect_input_reg(turbine.getSteam)
unit.connect_input_reg(turbine.getSteamNeeded)
unit.connect_input_reg(turbine.getSteamFilledPercentage)
-- holding registers --
-- none
return unit.interface()
end
return turbine_rtu

View File

@@ -3,6 +3,7 @@ local rtu = require("rtu.rtu")
local turbinev_rtu = {} local turbinev_rtu = {}
-- create new turbine (mek 10.1+) device -- create new turbine (mek 10.1+) device
---@nodiscard
---@param turbine table ---@param turbine table
function turbinev_rtu.new(turbine) function turbinev_rtu.new(turbine)
local unit = rtu.init_unit() local unit = rtu.init_unit()
@@ -46,7 +47,7 @@ function turbinev_rtu.new(turbine)
unit.connect_input_reg(turbine.getEnergyFilledPercentage) unit.connect_input_reg(turbine.getEnergyFilledPercentage)
-- holding registers -- -- holding registers --
unit.connect_holding_reg(turbine.setDumpingMode, turbine.getDumpingMode) unit.connect_holding_reg(turbine.getDumpingMode, turbine.setDumpingMode)
return unit.interface() return unit.interface()
end end

View File

@@ -7,40 +7,36 @@ local MODBUS_FCODE = types.MODBUS_FCODE
local MODBUS_EXCODE = types.MODBUS_EXCODE local MODBUS_EXCODE = types.MODBUS_EXCODE
-- new modbus comms handler object -- new modbus comms handler object
---@nodiscard
---@param rtu_dev rtu_device|rtu_rs_device RTU device ---@param rtu_dev rtu_device|rtu_rs_device RTU device
---@param use_parallel_read boolean whether or not to use parallel calls when reading ---@param use_parallel_read boolean whether or not to use parallel calls when reading
function modbus.new(rtu_dev, use_parallel_read) function modbus.new(rtu_dev, use_parallel_read)
local self = {
rtu = rtu_dev,
use_parallel = use_parallel_read
}
---@class modbus
local public = {}
local insert = table.insert local insert = table.insert
-- read a span of coils (digital outputs)<br>
-- returns a table of readings or a MODBUS_EXCODE error code
---@nodiscard
---@param c_addr_start integer ---@param c_addr_start integer
---@param count integer ---@param count integer
---@return boolean ok, table readings ---@return boolean ok, table|MODBUS_EXCODE readings
local function _1_read_coils(c_addr_start, count) local function _1_read_coils(c_addr_start, count)
local tasks = {} local tasks = {}
local readings = {} local readings = {} ---@type table|MODBUS_EXCODE
local access_fault = false local access_fault = false
local _, coils, _, _ = self.rtu.io_count() local _, coils, _, _ = rtu_dev.io_count()
local return_ok = ((c_addr_start + count) <= (coils + 1)) and (count > 0) local return_ok = ((c_addr_start + count) <= (coils + 1)) and (count > 0)
if return_ok then if return_ok then
for i = 1, count do for i = 1, count do
local addr = c_addr_start + i - 1 local addr = c_addr_start + i - 1
if self.use_parallel then if use_parallel_read then
insert(tasks, function () insert(tasks, function ()
local reading, fault = self.rtu.read_coil(addr) local reading, fault = rtu_dev.read_coil(addr)
if fault then access_fault = true else readings[i] = reading end if fault then access_fault = true else readings[i] = reading end
end) end)
else else
readings[i], access_fault = self.rtu.read_coil(addr) readings[i], access_fault = rtu_dev.read_coil(addr)
if access_fault then if access_fault then
return_ok = false return_ok = false
@@ -51,13 +47,13 @@ function modbus.new(rtu_dev, use_parallel_read)
end end
-- run parallel tasks if configured -- run parallel tasks if configured
if self.use_parallel then if use_parallel_read then
parallel.waitForAll(table.unpack(tasks)) parallel.waitForAll(table.unpack(tasks))
end
if access_fault then if access_fault or #readings ~= count then
return_ok = false return_ok = false
readings = MODBUS_EXCODE.SERVER_DEVICE_FAIL readings = MODBUS_EXCODE.SERVER_DEVICE_FAIL
end
end end
else else
readings = MODBUS_EXCODE.ILLEGAL_DATA_ADDR readings = MODBUS_EXCODE.ILLEGAL_DATA_ADDR
@@ -66,27 +62,30 @@ function modbus.new(rtu_dev, use_parallel_read)
return return_ok, readings return return_ok, readings
end end
-- read a span of discrete inputs (digital inputs)<br>
-- returns a table of readings or a MODBUS_EXCODE error code
---@nodiscard
---@param di_addr_start integer ---@param di_addr_start integer
---@param count integer ---@param count integer
---@return boolean ok, table readings ---@return boolean ok, table|MODBUS_EXCODE readings
local function _2_read_discrete_inputs(di_addr_start, count) local function _2_read_discrete_inputs(di_addr_start, count)
local tasks = {} local tasks = {}
local readings = {} local readings = {} ---@type table|MODBUS_EXCODE
local access_fault = false local access_fault = false
local discrete_inputs, _, _, _ = self.rtu.io_count() local discrete_inputs, _, _, _ = rtu_dev.io_count()
local return_ok = ((di_addr_start + count) <= (discrete_inputs + 1)) and (count > 0) local return_ok = ((di_addr_start + count) <= (discrete_inputs + 1)) and (count > 0)
if return_ok then if return_ok then
for i = 1, count do for i = 1, count do
local addr = di_addr_start + i - 1 local addr = di_addr_start + i - 1
if self.use_parallel then if use_parallel_read then
insert(tasks, function () insert(tasks, function ()
local reading, fault = self.rtu.read_di(addr) local reading, fault = rtu_dev.read_di(addr)
if fault then access_fault = true else readings[i] = reading end if fault then access_fault = true else readings[i] = reading end
end) end)
else else
readings[i], access_fault = self.rtu.read_di(addr) readings[i], access_fault = rtu_dev.read_di(addr)
if access_fault then if access_fault then
return_ok = false return_ok = false
@@ -97,13 +96,13 @@ function modbus.new(rtu_dev, use_parallel_read)
end end
-- run parallel tasks if configured -- run parallel tasks if configured
if self.use_parallel then if use_parallel_read then
parallel.waitForAll(table.unpack(tasks)) parallel.waitForAll(table.unpack(tasks))
end
if access_fault then if access_fault or #readings ~= count then
return_ok = false return_ok = false
readings = MODBUS_EXCODE.SERVER_DEVICE_FAIL readings = MODBUS_EXCODE.SERVER_DEVICE_FAIL
end
end end
else else
readings = MODBUS_EXCODE.ILLEGAL_DATA_ADDR readings = MODBUS_EXCODE.ILLEGAL_DATA_ADDR
@@ -112,27 +111,30 @@ function modbus.new(rtu_dev, use_parallel_read)
return return_ok, readings return return_ok, readings
end end
-- read a span of holding registers (analog outputs)<br>
-- returns a table of readings or a MODBUS_EXCODE error code
---@nodiscard
---@param hr_addr_start integer ---@param hr_addr_start integer
---@param count integer ---@param count integer
---@return boolean ok, table readings ---@return boolean ok, table|MODBUS_EXCODE readings
local function _3_read_multiple_holding_registers(hr_addr_start, count) local function _3_read_multiple_holding_registers(hr_addr_start, count)
local tasks = {} local tasks = {}
local readings = {} local readings = {} ---@type table|MODBUS_EXCODE
local access_fault = false local access_fault = false
local _, _, _, hold_regs = self.rtu.io_count() local _, _, _, hold_regs = rtu_dev.io_count()
local return_ok = ((hr_addr_start + count) <= (hold_regs + 1)) and (count > 0) local return_ok = ((hr_addr_start + count) <= (hold_regs + 1)) and (count > 0)
if return_ok then if return_ok then
for i = 1, count do for i = 1, count do
local addr = hr_addr_start + i - 1 local addr = hr_addr_start + i - 1
if self.use_parallel then if use_parallel_read then
insert(tasks, function () insert(tasks, function ()
local reading, fault = self.rtu.read_holding_reg(addr) local reading, fault = rtu_dev.read_holding_reg(addr)
if fault then access_fault = true else readings[i] = reading end if fault then access_fault = true else readings[i] = reading end
end) end)
else else
readings[i], access_fault = self.rtu.read_holding_reg(addr) readings[i], access_fault = rtu_dev.read_holding_reg(addr)
if access_fault then if access_fault then
return_ok = false return_ok = false
@@ -143,13 +145,13 @@ function modbus.new(rtu_dev, use_parallel_read)
end end
-- run parallel tasks if configured -- run parallel tasks if configured
if self.use_parallel then if use_parallel_read then
parallel.waitForAll(table.unpack(tasks)) parallel.waitForAll(table.unpack(tasks))
end
if access_fault then if access_fault or #readings ~= count then
return_ok = false return_ok = false
readings = MODBUS_EXCODE.SERVER_DEVICE_FAIL readings = MODBUS_EXCODE.SERVER_DEVICE_FAIL
end
end end
else else
readings = MODBUS_EXCODE.ILLEGAL_DATA_ADDR readings = MODBUS_EXCODE.ILLEGAL_DATA_ADDR
@@ -158,27 +160,30 @@ function modbus.new(rtu_dev, use_parallel_read)
return return_ok, readings return return_ok, readings
end end
-- read a span of input registers (analog inputs)<br>
-- returns a table of readings or a MODBUS_EXCODE error code
---@nodiscard
---@param ir_addr_start integer ---@param ir_addr_start integer
---@param count integer ---@param count integer
---@return boolean ok, table readings ---@return boolean ok, table|MODBUS_EXCODE readings
local function _4_read_input_registers(ir_addr_start, count) local function _4_read_input_registers(ir_addr_start, count)
local tasks = {} local tasks = {}
local readings = {} local readings = {} ---@type table|MODBUS_EXCODE
local access_fault = false local access_fault = false
local _, _, input_regs, _ = self.rtu.io_count() local _, _, input_regs, _ = rtu_dev.io_count()
local return_ok = ((ir_addr_start + count) <= (input_regs + 1)) and (count > 0) local return_ok = ((ir_addr_start + count) <= (input_regs + 1)) and (count > 0)
if return_ok then if return_ok then
for i = 1, count do for i = 1, count do
local addr = ir_addr_start + i - 1 local addr = ir_addr_start + i - 1
if self.use_parallel then if use_parallel_read then
insert(tasks, function () insert(tasks, function ()
local reading, fault = self.rtu.read_input_reg(addr) local reading, fault = rtu_dev.read_input_reg(addr)
if fault then access_fault = true else readings[i] = reading end if fault then access_fault = true else readings[i] = reading end
end) end)
else else
readings[i], access_fault = self.rtu.read_input_reg(addr) readings[i], access_fault = rtu_dev.read_input_reg(addr)
if access_fault then if access_fault then
return_ok = false return_ok = false
@@ -189,13 +194,13 @@ function modbus.new(rtu_dev, use_parallel_read)
end end
-- run parallel tasks if configured -- run parallel tasks if configured
if self.use_parallel then if use_parallel_read then
parallel.waitForAll(table.unpack(tasks)) parallel.waitForAll(table.unpack(tasks))
end
if access_fault then if access_fault or #readings ~= count then
return_ok = false return_ok = false
readings = MODBUS_EXCODE.SERVER_DEVICE_FAIL readings = MODBUS_EXCODE.SERVER_DEVICE_FAIL
end
end end
else else
readings = MODBUS_EXCODE.ILLEGAL_DATA_ADDR readings = MODBUS_EXCODE.ILLEGAL_DATA_ADDR
@@ -204,16 +209,18 @@ function modbus.new(rtu_dev, use_parallel_read)
return return_ok, readings return return_ok, readings
end end
-- write a single coil (digital output)
---@nodiscard
---@param c_addr integer ---@param c_addr integer
---@param value any ---@param value any
---@return boolean ok, MODBUS_EXCODE|nil ---@return boolean ok, MODBUS_EXCODE
local function _5_write_single_coil(c_addr, value) local function _5_write_single_coil(c_addr, value)
local response = nil local response = nil
local _, coils, _, _ = self.rtu.io_count() local _, coils, _, _ = rtu_dev.io_count()
local return_ok = c_addr <= coils local return_ok = c_addr <= coils
if return_ok then if return_ok then
local access_fault = self.rtu.write_coil(c_addr, value) local access_fault = rtu_dev.write_coil(c_addr, value)
if access_fault then if access_fault then
return_ok = false return_ok = false
@@ -226,16 +233,18 @@ function modbus.new(rtu_dev, use_parallel_read)
return return_ok, response return return_ok, response
end end
-- write a single holding register (analog output)
---@nodiscard
---@param hr_addr integer ---@param hr_addr integer
---@param value any ---@param value any
---@return boolean ok, MODBUS_EXCODE|nil ---@return boolean ok, MODBUS_EXCODE
local function _6_write_single_holding_register(hr_addr, value) local function _6_write_single_holding_register(hr_addr, value)
local response = nil local response = nil
local _, _, _, hold_regs = self.rtu.io_count() local _, _, _, hold_regs = rtu_dev.io_count()
local return_ok = hr_addr <= hold_regs local return_ok = hr_addr <= hold_regs
if return_ok then if return_ok then
local access_fault = self.rtu.write_holding_reg(hr_addr, value) local access_fault = rtu_dev.write_holding_reg(hr_addr, value)
if access_fault then if access_fault then
return_ok = false return_ok = false
@@ -248,19 +257,21 @@ function modbus.new(rtu_dev, use_parallel_read)
return return_ok, response return return_ok, response
end end
-- write multiple coils (digital outputs)
---@nodiscard
---@param c_addr_start integer ---@param c_addr_start integer
---@param values any ---@param values any
---@return boolean ok, MODBUS_EXCODE|nil ---@return boolean ok, MODBUS_EXCODE
local function _15_write_multiple_coils(c_addr_start, values) local function _15_write_multiple_coils(c_addr_start, values)
local response = nil local response = nil
local _, coils, _, _ = self.rtu.io_count() local _, coils, _, _ = rtu_dev.io_count()
local count = #values local count = #values
local return_ok = ((c_addr_start + count) <= (coils + 1)) and (count > 0) local return_ok = ((c_addr_start + count) <= (coils + 1)) and (count > 0)
if return_ok then if return_ok then
for i = 1, count do for i = 1, count do
local addr = c_addr_start + i - 1 local addr = c_addr_start + i - 1
local access_fault = self.rtu.write_coil(addr, values[i]) local access_fault = rtu_dev.write_coil(addr, values[i])
if access_fault then if access_fault then
return_ok = false return_ok = false
@@ -275,19 +286,21 @@ function modbus.new(rtu_dev, use_parallel_read)
return return_ok, response return return_ok, response
end end
-- write multiple holding registers (analog outputs)
---@nodiscard
---@param hr_addr_start integer ---@param hr_addr_start integer
---@param values any ---@param values any
---@return boolean ok, MODBUS_EXCODE|nil ---@return boolean ok, MODBUS_EXCODE
local function _16_write_multiple_holding_registers(hr_addr_start, values) local function _16_write_multiple_holding_registers(hr_addr_start, values)
local response = nil local response = nil
local _, _, _, hold_regs = self.rtu.io_count() local _, _, _, hold_regs = rtu_dev.io_count()
local count = #values local count = #values
local return_ok = ((hr_addr_start + count) <= (hold_regs + 1)) and (count > 0) local return_ok = ((hr_addr_start + count) <= (hold_regs + 1)) and (count > 0)
if return_ok then if return_ok then
for i = 1, count do for i = 1, count do
local addr = hr_addr_start + i - 1 local addr = hr_addr_start + i - 1
local access_fault = self.rtu.write_holding_reg(addr, values[i]) local access_fault = rtu_dev.write_holding_reg(addr, values[i])
if access_fault then if access_fault then
return_ok = false return_ok = false
@@ -302,7 +315,11 @@ function modbus.new(rtu_dev, use_parallel_read)
return return_ok, response return return_ok, response
end end
---@class modbus
local public = {}
-- validate a request without actually executing it -- validate a request without actually executing it
---@nodiscard
---@param packet modbus_frame ---@param packet modbus_frame
---@return boolean return_code, modbus_packet reply ---@return boolean return_code, modbus_packet reply
function public.check_request(packet) function public.check_request(packet)
@@ -344,13 +361,14 @@ function modbus.new(rtu_dev, use_parallel_read)
end end
-- handle a MODBUS TCP packet and generate a reply -- handle a MODBUS TCP packet and generate a reply
---@nodiscard
---@param packet modbus_frame ---@param packet modbus_frame
---@return boolean return_code, modbus_packet reply ---@return boolean return_code, modbus_packet reply
function public.handle_packet(packet) function public.handle_packet(packet)
local return_code = true local return_code = true
local response = nil local response = nil
if packet.length == 2 then if packet.length >= 2 then
-- handle by function code -- handle by function code
if packet.func_code == MODBUS_FCODE.READ_COILS then if packet.func_code == MODBUS_FCODE.READ_COILS then
return_code, response = _1_read_coils(packet.data[1], packet.data[2]) return_code, response = _1_read_coils(packet.data[1], packet.data[2])
@@ -365,9 +383,9 @@ function modbus.new(rtu_dev, use_parallel_read)
elseif packet.func_code == MODBUS_FCODE.WRITE_SINGLE_HOLD_REG then elseif packet.func_code == MODBUS_FCODE.WRITE_SINGLE_HOLD_REG then
return_code, response = _6_write_single_holding_register(packet.data[1], packet.data[2]) return_code, response = _6_write_single_holding_register(packet.data[1], packet.data[2])
elseif packet.func_code == MODBUS_FCODE.WRITE_MUL_COILS then elseif packet.func_code == MODBUS_FCODE.WRITE_MUL_COILS then
return_code, response = _15_write_multiple_coils(packet.data[1], packet.data[2]) return_code, response = _15_write_multiple_coils(packet.data[1], { table.unpack(packet.data, 2, packet.length) })
elseif packet.func_code == MODBUS_FCODE.WRITE_MUL_HOLD_REGS then elseif packet.func_code == MODBUS_FCODE.WRITE_MUL_HOLD_REGS then
return_code, response = _16_write_multiple_holding_registers(packet.data[1], packet.data[2]) return_code, response = _16_write_multiple_holding_registers(packet.data[1], { table.unpack(packet.data, 2, packet.length) })
else else
-- unknown function -- unknown function
return_code = false return_code = false
@@ -376,6 +394,7 @@ function modbus.new(rtu_dev, use_parallel_read)
else else
-- invalid length -- invalid length
return_code = false return_code = false
response = MODBUS_EXCODE.NEG_ACKNOWLEDGE
end end
-- default is to echo back -- default is to echo back
@@ -403,6 +422,8 @@ function modbus.new(rtu_dev, use_parallel_read)
end end
-- return a SERVER_DEVICE_BUSY error reply -- return a SERVER_DEVICE_BUSY error reply
---@nodiscard
---@param packet modbus_frame MODBUS packet frame
---@return modbus_packet reply ---@return modbus_packet reply
function modbus.reply__srv_device_busy(packet) function modbus.reply__srv_device_busy(packet)
-- reply back with error flag and exception code -- reply back with error flag and exception code
@@ -414,6 +435,8 @@ function modbus.reply__srv_device_busy(packet)
end end
-- return a NEG_ACKNOWLEDGE error reply -- return a NEG_ACKNOWLEDGE error reply
---@nodiscard
---@param packet modbus_frame MODBUS packet frame
---@return modbus_packet reply ---@return modbus_packet reply
function modbus.reply__neg_ack(packet) function modbus.reply__neg_ack(packet)
-- reply back with error flag and exception code -- reply back with error flag and exception code
@@ -425,6 +448,8 @@ function modbus.reply__neg_ack(packet)
end end
-- return a GATEWAY_PATH_UNAVAILABLE error reply -- return a GATEWAY_PATH_UNAVAILABLE error reply
---@nodiscard
---@param packet modbus_frame MODBUS packet frame
---@return modbus_packet reply ---@return modbus_packet reply
function modbus.reply__gw_unavailable(packet) function modbus.reply__gw_unavailable(packet)
-- reply back with error flag and exception code -- reply back with error flag and exception code

View File

@@ -1,25 +1,26 @@
local comms = require("scada-common.comms") local comms = require("scada-common.comms")
local ppm = require("scada-common.ppm") local ppm = require("scada-common.ppm")
local log = require("scada-common.log") local log = require("scada-common.log")
local types = require("scada-common.types") local types = require("scada-common.types")
local util = require("scada-common.util") local util = require("scada-common.util")
local modbus = require("rtu.modbus") local modbus = require("rtu.modbus")
local rtu = {} local rtu = {}
local rtu_t = types.rtu_t local PROTOCOL = comms.PROTOCOL
local DEVICE_TYPE = comms.DEVICE_TYPE
local PROTOCOLS = comms.PROTOCOLS local ESTABLISH_ACK = comms.ESTABLISH_ACK
local SCADA_MGMT_TYPES = comms.SCADA_MGMT_TYPES local SCADA_MGMT_TYPE = comms.SCADA_MGMT_TYPE
local RTU_UNIT_TYPES = comms.RTU_UNIT_TYPES local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE
local print = util.print local print = util.print
local println = util.println local println = util.println
local print_ts = util.print_ts local print_ts = util.print_ts
local println_ts = util.println_ts local println_ts = util.println_ts
-- create a new RTU -- create a new RTU unit
---@nodiscard
function rtu.init_unit() function rtu.init_unit()
local self = { local self = {
discrete_inputs = {}, discrete_inputs = {},
@@ -153,84 +154,104 @@ function rtu.init_unit()
-- public RTU device access -- public RTU device access
-- get the public interface to this RTU -- get the public interface to this RTU
function protected.interface() function protected.interface() return public end
return public
end
return protected return protected
end end
-- RTU Communications -- RTU Communications
---@param version string ---@nodiscard
---@param modem table ---@param version string RTU version
---@param local_port integer ---@param modem table modem device
---@param server_port integer ---@param local_port integer local listening port
---@param conn_watchdog watchdog ---@param server_port integer remote server port
function rtu.comms(version, modem, local_port, server_port, conn_watchdog) ---@param range integer trusted device connection range
---@param conn_watchdog watchdog watchdog reference
function rtu.comms(version, modem, local_port, server_port, range, conn_watchdog)
local self = { local self = {
version = version,
seq_num = 0, seq_num = 0,
r_seq_num = nil, r_seq_num = nil,
txn_id = 0, txn_id = 0,
modem = modem, last_est_ack = ESTABLISH_ACK.ALLOW
s_port = server_port,
l_port = local_port,
conn_watchdog = conn_watchdog
} }
---@class rtu_comms
local public = {}
local insert = table.insert local insert = table.insert
-- open modem comms.set_trusted_range(range)
if not self.modem.isOpen(self.l_port) then
self.modem.open(self.l_port)
end
-- PRIVATE FUNCTIONS -- -- PRIVATE FUNCTIONS --
-- configure modem channels
local function _conf_channels()
modem.closeAll()
modem.open(local_port)
end
_conf_channels()
-- send a scada management packet -- send a scada management packet
---@param msg_type SCADA_MGMT_TYPES ---@param msg_type SCADA_MGMT_TYPE
---@param msg table ---@param msg table
local function _send(msg_type, msg) local function _send(msg_type, msg)
local s_pkt = comms.scada_packet() local s_pkt = comms.scada_packet()
local m_pkt = comms.mgmt_packet() local m_pkt = comms.mgmt_packet()
m_pkt.make(msg_type, msg) m_pkt.make(msg_type, msg)
s_pkt.make(self.seq_num, PROTOCOLS.SCADA_MGMT, m_pkt.raw_sendable()) s_pkt.make(self.seq_num, PROTOCOL.SCADA_MGMT, m_pkt.raw_sendable())
self.modem.transmit(self.s_port, self.l_port, s_pkt.raw_sendable()) modem.transmit(server_port, local_port, s_pkt.raw_sendable())
self.seq_num = self.seq_num + 1 self.seq_num = self.seq_num + 1
end end
-- keep alive ack -- keep alive ack
---@param srv_time integer ---@param srv_time integer
local function _send_keep_alive_ack(srv_time) local function _send_keep_alive_ack(srv_time)
_send(SCADA_MGMT_TYPES.KEEP_ALIVE, { srv_time, util.time() }) _send(SCADA_MGMT_TYPE.KEEP_ALIVE, { srv_time, util.time() })
end
-- generate device advertisement table
---@nodiscard
---@param units table
---@return table advertisement
local function _generate_advertisement(units)
local advertisement = {}
for i = 1, #units do
local unit = units[i] ---@type rtu_unit_registry_entry
if unit.type ~= nil then
local advert = { unit.type, unit.index, unit.reactor }
if unit.type == RTU_UNIT_TYPE.REDSTONE then
insert(advert, unit.device)
end
insert(advertisement, advert)
end
end
return advertisement
end end
-- PUBLIC FUNCTIONS -- -- PUBLIC FUNCTIONS --
---@class rtu_comms
local public = {}
-- send a MODBUS TCP packet -- send a MODBUS TCP packet
---@param m_pkt modbus_packet ---@param m_pkt modbus_packet
function public.send_modbus(m_pkt) function public.send_modbus(m_pkt)
local s_pkt = comms.scada_packet() local s_pkt = comms.scada_packet()
s_pkt.make(self.seq_num, PROTOCOLS.MODBUS_TCP, m_pkt.raw_sendable()) s_pkt.make(self.seq_num, PROTOCOL.MODBUS_TCP, m_pkt.raw_sendable())
self.modem.transmit(self.s_port, self.l_port, s_pkt.raw_sendable()) modem.transmit(server_port, local_port, s_pkt.raw_sendable())
self.seq_num = self.seq_num + 1 self.seq_num = self.seq_num + 1
end end
-- reconnect a newly connected modem -- reconnect a newly connected modem
---@param modem table ---@param new_modem table
---@diagnostic disable-next-line: redefined-local function public.reconnect_modem(new_modem)
function public.reconnect_modem(modem) modem = new_modem
self.modem = modem _conf_channels()
-- open modem
if not self.modem.isOpen(self.l_port) then
self.modem.open(self.l_port)
end
end end
-- unlink from the server -- unlink from the server
@@ -243,39 +264,31 @@ function rtu.comms(version, modem, local_port, server_port, conn_watchdog)
-- close the connection to the server -- close the connection to the server
---@param rtu_state rtu_state ---@param rtu_state rtu_state
function public.close(rtu_state) function public.close(rtu_state)
self.conn_watchdog.cancel() conn_watchdog.cancel()
public.unlink(rtu_state) public.unlink(rtu_state)
_send(SCADA_MGMT_TYPES.CLOSE, {}) _send(SCADA_MGMT_TYPE.CLOSE, {})
end
-- send establish request (includes advertisement)
---@param units table
function public.send_establish(units)
_send(SCADA_MGMT_TYPE.ESTABLISH, { comms.version, version, DEVICE_TYPE.RTU, _generate_advertisement(units) })
end end
-- send capability advertisement -- send capability advertisement
---@param units table ---@param units table
function public.send_advertisement(units) function public.send_advertisement(units)
local advertisement = { self.version } _send(SCADA_MGMT_TYPE.RTU_ADVERT, _generate_advertisement(units))
end
for i = 1, #units do -- notify that a peripheral was remounted
local unit = units[i] --@type rtu_unit_registry_entry ---@param unit_index integer RTU unit ID
local type = comms.rtu_t_to_unit_type(unit.type) function public.send_remounted(unit_index)
_send(SCADA_MGMT_TYPE.RTU_DEV_REMOUNT, { unit_index })
if type ~= nil then
local advert = {
type,
unit.index,
unit.reactor
}
if type == RTU_UNIT_TYPES.REDSTONE then
insert(advert, unit.device)
end
insert(advertisement, advert)
end
end
_send(SCADA_MGMT_TYPES.RTU_ADVERT, advertisement)
end end
-- parse a MODBUS/SCADA packet -- parse a MODBUS/SCADA packet
---@nodiscard
---@param side string ---@param side string
---@param sender integer ---@param sender integer
---@param reply_to integer ---@param reply_to integer
@@ -291,13 +304,13 @@ function rtu.comms(version, modem, local_port, server_port, conn_watchdog)
if s_pkt.is_valid() then if s_pkt.is_valid() then
-- get as MODBUS TCP packet -- get as MODBUS TCP packet
if s_pkt.protocol() == PROTOCOLS.MODBUS_TCP then if s_pkt.protocol() == PROTOCOL.MODBUS_TCP then
local m_pkt = comms.modbus_packet() local m_pkt = comms.modbus_packet()
if m_pkt.decode(s_pkt) then if m_pkt.decode(s_pkt) then
pkt = m_pkt.get() pkt = m_pkt.get()
end end
-- get as SCADA management packet -- get as SCADA management packet
elseif s_pkt.protocol() == PROTOCOLS.SCADA_MGMT then elseif s_pkt.protocol() == PROTOCOL.SCADA_MGMT then
local mgmt_pkt = comms.mgmt_packet() local mgmt_pkt = comms.mgmt_packet()
if mgmt_pkt.decode(s_pkt) then if mgmt_pkt.decode(s_pkt) then
pkt = mgmt_pkt.get() pkt = mgmt_pkt.get()
@@ -312,10 +325,10 @@ function rtu.comms(version, modem, local_port, server_port, conn_watchdog)
-- handle a MODBUS/SCADA packet -- handle a MODBUS/SCADA packet
---@param packet modbus_frame|mgmt_frame ---@param packet modbus_frame|mgmt_frame
---@param units table ---@param units table RTU units
---@param rtu_state rtu_state ---@param rtu_state rtu_state
function public.handle_packet(packet, units, rtu_state) function public.handle_packet(packet, units, rtu_state)
if packet ~= nil then if packet.scada_frame.local_port() == local_port then
-- check sequence number -- check sequence number
if self.r_seq_num == nil then if self.r_seq_num == nil then
self.r_seq_num = packet.scada_frame.seq_num() self.r_seq_num = packet.scada_frame.seq_num()
@@ -327,84 +340,120 @@ function rtu.comms(version, modem, local_port, server_port, conn_watchdog)
end end
-- feed watchdog on valid sequence number -- feed watchdog on valid sequence number
self.conn_watchdog.feed() conn_watchdog.feed()
local protocol = packet.scada_frame.protocol() local protocol = packet.scada_frame.protocol()
if protocol == PROTOCOLS.MODBUS_TCP then if protocol == PROTOCOL.MODBUS_TCP then
local return_code = false ---@cast packet modbus_frame
local reply = modbus.reply__neg_ack(packet) if rtu_state.linked then
local return_code = false
local reply = modbus.reply__neg_ack(packet)
-- handle MODBUS instruction -- handle MODBUS instruction
if packet.unit_id <= #units then if packet.unit_id <= #units then
local unit = units[packet.unit_id] ---@type rtu_unit_registry_entry local unit = units[packet.unit_id] ---@type rtu_unit_registry_entry
local unit_dbg_tag = " (unit " .. packet.unit_id .. ")" local unit_dbg_tag = " (unit " .. packet.unit_id .. ")"
if unit.name == "redstone_io" then if unit.name == "redstone_io" then
-- immediately execute redstone RTU requests -- immediately execute redstone RTU requests
return_code, reply = unit.modbus_io.handle_packet(packet) return_code, reply = unit.modbus_io.handle_packet(packet)
if not return_code then if not return_code then
log.warning("requested MODBUS operation failed" .. unit_dbg_tag) log.warning("requested MODBUS operation failed" .. unit_dbg_tag)
end
else
-- check validity then pass off to unit comms thread
return_code, reply = unit.modbus_io.check_request(packet)
if return_code then
-- check if there are more than 3 active transactions
-- still queue the packet, but this may indicate a problem
if unit.pkt_queue.length() > 3 then
reply = modbus.reply__srv_device_busy(packet)
log.debug("queueing new request with " .. unit.pkt_queue.length() ..
" transactions already in the queue" .. unit_dbg_tag)
end
-- always queue the command even if busy
unit.pkt_queue.push_packet(packet)
else
log.warning("cannot perform requested MODBUS operation" .. unit_dbg_tag)
end
end end
else else
-- check validity then pass off to unit comms thread -- unit ID out of range?
return_code, reply = unit.modbus_io.check_request(packet) reply = modbus.reply__gw_unavailable(packet)
if return_code then log.error("received MODBUS packet for non-existent unit")
-- check if there are more than 3 active transactions end
-- still queue the packet, but this may indicate a problem
if unit.pkt_queue.length() > 3 then public.send_modbus(reply)
reply = modbus.reply__srv_device_busy(packet) else
log.debug("queueing new request with " .. unit.pkt_queue.length() .. log.debug("discarding MODBUS packet before linked")
" transactions already in the queue" .. unit_dbg_tag) end
elseif protocol == PROTOCOL.SCADA_MGMT then
---@cast packet mgmt_frame
-- SCADA management packet
if packet.type == SCADA_MGMT_TYPE.ESTABLISH then
if packet.length == 1 then
local est_ack = packet.data[1]
if est_ack == ESTABLISH_ACK.ALLOW then
-- establish allowed
rtu_state.linked = true
self.r_seq_num = nil
println_ts("supervisor connection established")
log.info("supervisor connection established")
else
-- establish denied
if est_ack ~= self.last_est_ack then
if est_ack == ESTABLISH_ACK.BAD_VERSION then
-- version mismatch
println_ts("supervisor comms version mismatch (try updating), retrying...")
log.warning("supervisor connection denied due to comms version mismatch, retrying")
else
println_ts("supervisor connection denied, retrying...")
log.warning("supervisor connection denied, retrying")
end
end end
-- always queue the command even if busy public.unlink(rtu_state)
unit.pkt_queue.push_packet(packet)
else
log.warning("cannot perform requested MODBUS operation" .. unit_dbg_tag)
end
end
else
-- unit ID out of range?
reply = modbus.reply__gw_unavailable(packet)
log.error("received MODBUS packet for non-existent unit")
end
public.send_modbus(reply)
elseif protocol == PROTOCOLS.SCADA_MGMT then
-- SCADA management packet
if packet.type == SCADA_MGMT_TYPES.KEEP_ALIVE then
-- keep alive request received, echo back
if packet.length == 1 then
local timestamp = packet.data[1]
local trip_time = util.time() - timestamp
if trip_time > 500 then
log.warning("RTU KEEP_ALIVE trip time > 500ms (" .. trip_time .. "ms)")
end end
-- log.debug("RTU RTT = " .. trip_time .. "ms") self.last_est_ack = est_ack
_send_keep_alive_ack(timestamp)
else else
log.debug("SCADA keep alive packet length mismatch") log.debug("SCADA_MGMT establish packet length mismatch")
end
elseif rtu_state.linked then
if packet.type == SCADA_MGMT_TYPE.KEEP_ALIVE then
-- keep alive request received, echo back
if packet.length == 1 and type(packet.data[1]) == "number" then
local timestamp = packet.data[1]
local trip_time = util.time() - timestamp
if trip_time > 750 then
log.warning("RTU KEEP_ALIVE trip time > 750ms (" .. trip_time .. "ms)")
end
-- log.debug("RTU RTT = " .. trip_time .. "ms")
_send_keep_alive_ack(timestamp)
else
log.debug("SCADA_MGMT keep alive packet length/type mismatch")
end
elseif packet.type == SCADA_MGMT_TYPE.CLOSE then
-- close connection
conn_watchdog.cancel()
public.unlink(rtu_state)
println_ts("server connection closed by remote host")
log.warning("server connection closed by remote host")
elseif packet.type == SCADA_MGMT_TYPE.RTU_ADVERT then
-- request for capabilities again
public.send_advertisement(units)
else
-- not supported
log.warning("received unsupported SCADA_MGMT message type " .. packet.type)
end end
elseif packet.type == SCADA_MGMT_TYPES.CLOSE then
-- close connection
self.conn_watchdog.cancel()
public.unlink(rtu_state)
println_ts("server connection closed by remote host")
log.warning("server connection closed by remote host")
elseif packet.type == SCADA_MGMT_TYPES.REMOTE_LINKED then
-- acknowledgement
rtu_state.linked = true
self.r_seq_num = nil
elseif packet.type == SCADA_MGMT_TYPES.RTU_ADVERT then
-- request for capabilities again
public.send_advertisement(units)
else else
-- not supported log.debug("discarding non-link SCADA_MGMT packet before linked")
log.warning("RTU got unexpected SCADA message type " .. packet.type)
end end
else else
-- should be unreachable assuming packet is from parse_packet() -- should be unreachable assuming packet is from parse_packet()

View File

@@ -4,30 +4,30 @@
require("/initenv").init_env() require("/initenv").init_env()
local log = require("scada-common.log") local crash = require("scada-common.crash")
local mqueue = require("scada-common.mqueue") local log = require("scada-common.log")
local ppm = require("scada-common.ppm") local mqueue = require("scada-common.mqueue")
local rsio = require("scada-common.rsio") local ppm = require("scada-common.ppm")
local types = require("scada-common.types") local rsio = require("scada-common.rsio")
local util = require("scada-common.util") local types = require("scada-common.types")
local util = require("scada-common.util")
local config = require("rtu.config") local config = require("rtu.config")
local modbus = require("rtu.modbus") local modbus = require("rtu.modbus")
local rtu = require("rtu.rtu") local rtu = require("rtu.rtu")
local threads = require("rtu.threads") local threads = require("rtu.threads")
local redstone_rtu = require("rtu.dev.redstone_rtu") local boilerv_rtu = require("rtu.dev.boilerv_rtu")
local boiler_rtu = require("rtu.dev.boiler_rtu") local envd_rtu = require("rtu.dev.envd_rtu")
local boilerv_rtu = require("rtu.dev.boilerv_rtu") local imatrix_rtu = require("rtu.dev.imatrix_rtu")
local energymachine_rtu = require("rtu.dev.energymachine_rtu") local redstone_rtu = require("rtu.dev.redstone_rtu")
local envd_rtu = require("rtu.dev.envd_rtu") local sna_rtu = require("rtu.dev.sna_rtu")
local imatrix_rtu = require("rtu.dev.imatrix_rtu") local sps_rtu = require("rtu.dev.sps_rtu")
local turbine_rtu = require("rtu.dev.turbine_rtu") local turbinev_rtu = require("rtu.dev.turbinev_rtu")
local turbinev_rtu = require("rtu.dev.turbinev_rtu")
local RTU_VERSION = "beta-v0.7.12" local RTU_VERSION = "v0.13.0"
local rtu_t = types.rtu_t local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE
local print = util.print local print = util.print
local println = util.println local println = util.println
@@ -42,6 +42,9 @@ local cfv = util.new_validator()
cfv.assert_port(config.SERVER_PORT) cfv.assert_port(config.SERVER_PORT)
cfv.assert_port(config.LISTEN_PORT) cfv.assert_port(config.LISTEN_PORT)
cfv.assert_type_int(config.TRUSTED_RANGE)
cfv.assert_type_num(config.COMMS_TIMEOUT)
cfv.assert_min(config.COMMS_TIMEOUT, 2)
cfv.assert_type_str(config.LOG_PATH) cfv.assert_type_str(config.LOG_PATH)
cfv.assert_type_int(config.LOG_MODE) cfv.assert_type_int(config.LOG_MODE)
cfv.assert_type_table(config.RTU_DEVICES) cfv.assert_type_table(config.RTU_DEVICES)
@@ -59,243 +62,308 @@ log.info("BOOTING rtu.startup " .. RTU_VERSION)
log.info("========================================") log.info("========================================")
println(">> RTU GATEWAY " .. RTU_VERSION .. " <<") println(">> RTU GATEWAY " .. RTU_VERSION .. " <<")
crash.set_env("rtu", RTU_VERSION)
---------------------------------------- ----------------------------------------
-- startup -- main application
---------------------------------------- ----------------------------------------
-- mount connected devices local function main()
ppm.mount_all() ----------------------------------------
-- startup
----------------------------------------
---@class rtu_shared_memory -- mount connected devices
local __shared_memory = { ppm.mount_all()
-- RTU system state flags
---@class rtu_state
rtu_state = {
linked = false,
shutdown = false
},
-- core RTU devices ---@class rtu_shared_memory
rtu_dev = { local __shared_memory = {
modem = ppm.get_wireless_modem() -- RTU system state flags
}, ---@class rtu_state
rtu_state = {
linked = false,
shutdown = false
},
-- system objects -- core RTU devices
rtu_sys = { rtu_dev = {
rtu_comms = nil, ---@type rtu_comms modem = ppm.get_wireless_modem()
conn_watchdog = nil, ---@type watchdog },
units = {} ---@type table
},
-- message queues -- system objects
q = { rtu_sys = {
mq_comms = mqueue.new() rtu_comms = nil, ---@type rtu_comms
conn_watchdog = nil, ---@type watchdog
units = {} ---@type table
},
-- message queues
q = {
mq_comms = mqueue.new()
}
} }
}
local smem_dev = __shared_memory.rtu_dev local smem_dev = __shared_memory.rtu_dev
local smem_sys = __shared_memory.rtu_sys local smem_sys = __shared_memory.rtu_sys
-- get modem -- get modem
if smem_dev.modem == nil then if smem_dev.modem == nil then
println("boot> wireless modem not found") println("boot> wireless modem not found")
log.fatal("no wireless modem on startup") log.fatal("no wireless modem on startup")
return return
end
----------------------------------------
-- interpret config and init units
----------------------------------------
local units = __shared_memory.rtu_sys.units
local rtu_redstone = config.RTU_REDSTONE
local rtu_devices = config.RTU_DEVICES
-- configure RTU gateway based on config file definitions
local function configure()
-- redstone interfaces
for entry_idx = 1, #rtu_redstone do
local rs_rtu = redstone_rtu.new()
local io_table = rtu_redstone[entry_idx].io
local io_reactor = rtu_redstone[entry_idx].for_reactor
-- CHECK: reactor ID must be >= to 1
if (not util.is_int(io_reactor)) or (io_reactor <= 0) then
println(util.c("configure> redstone entry #", entry_idx, " : ", io_reactor, " isn't an integer >= 1"))
return false
end
-- CHECK: io table exists
if type(io_table) ~= "table" then
println(util.c("configure> redstone entry #", entry_idx, " no IO table found"))
return false
end
local capabilities = {}
log.debug(util.c("configure> starting redstone RTU I/O linking for reactor ", io_reactor, "..."))
local continue = true
-- check for duplicate entries
for i = 1, #units do
local unit = units[i] ---@type rtu_unit_registry_entry
if unit.reactor == io_reactor and unit.type == rtu_t.redstone then
-- duplicate entry
local message = util.c("configure> skipping definition block #", entry_idx, " for reactor ", io_reactor,
" with already defined redstone I/O")
println(message)
log.warning(message)
continue = false
break
end
end
-- not a duplicate
if continue then
for i = 1, #io_table do
local valid = false
local conf = io_table[i]
-- verify configuration
if rsio.is_valid_channel(conf.channel) and rsio.is_valid_side(conf.side) then
if conf.bundled_color then
valid = rsio.is_color(conf.bundled_color)
else
valid = true
end
end
if not valid then
local message = util.c("configure> invalid redstone definition at index ", i, " in definition block #", entry_idx,
" (for reactor ", io_reactor, ")")
println(message)
log.error(message)
return false
else
-- link redstone in RTU
local mode = rsio.get_io_mode(conf.channel)
if mode == rsio.IO_MODE.DIGITAL_IN then
-- can't have duplicate inputs
if util.table_contains(capabilities, conf.channel) then
local message = util.c("configure> skipping duplicate input for channel ", rsio.to_string(conf.channel), " on side ", conf.side)
println(message)
log.warning(message)
else
rs_rtu.link_di(conf.side, conf.bundled_color)
end
elseif mode == rsio.IO_MODE.DIGITAL_OUT then
rs_rtu.link_do(conf.channel, conf.side, conf.bundled_color)
elseif mode == rsio.IO_MODE.ANALOG_IN then
-- can't have duplicate inputs
if util.table_contains(capabilities, conf.channel) then
local message = util.c("configure> skipping duplicate input for channel ", rsio.to_string(conf.channel), " on side ", conf.side)
println(message)
log.warning(message)
else
rs_rtu.link_ai(conf.side)
end
elseif mode == rsio.IO_MODE.ANALOG_OUT then
rs_rtu.link_ao(conf.side)
else
-- should be unreachable code, we already validated channels
log.error("configure> fell through if chain attempting to identify IO mode", true)
println("configure> encountered a software error, check logs")
return false
end
table.insert(capabilities, conf.channel)
log.debug(util.c("configure> linked redstone ", #capabilities, ": ", rsio.to_string(conf.channel),
" (", conf.side, ") for reactor ", io_reactor))
end
end
---@class rtu_unit_registry_entry
local unit = {
name = "redstone_io",
type = rtu_t.redstone,
index = entry_idx,
reactor = io_reactor,
device = capabilities, -- use device field for redstone channels
rtu = rs_rtu,
modbus_io = modbus.new(rs_rtu, false),
pkt_queue = nil,
thread = nil
}
table.insert(units, unit)
log.debug(util.c("init> initialized RTU unit #", #units, ": redstone_io (redstone) [1] for reactor ", io_reactor))
end
end end
-- mounted peripherals ----------------------------------------
for i = 1, #rtu_devices do -- interpret config and init units
local name = rtu_devices[i].name ----------------------------------------
local index = rtu_devices[i].index
local for_reactor = rtu_devices[i].for_reactor
-- CHECK: name is a string local units = __shared_memory.rtu_sys.units
if type(name) ~= "string" then
println(util.c("configure> device entry #", i, ": device ", name, " isn't a string")) local rtu_redstone = config.RTU_REDSTONE
return false local rtu_devices = config.RTU_DEVICES
-- configure RTU gateway based on config file definitions
local function configure()
-- redstone interfaces
for entry_idx = 1, #rtu_redstone do
local rs_rtu = redstone_rtu.new()
local io_table = rtu_redstone[entry_idx].io ---@type table
local io_reactor = rtu_redstone[entry_idx].for_reactor ---@type integer
-- CHECK: reactor ID must be >= to 1
if (not util.is_int(io_reactor)) or (io_reactor < 0) then
local message = util.c("configure> redstone entry #", entry_idx, " : ", io_reactor, " isn't an integer >= 0")
println(message)
log.fatal(message)
return false
end
-- CHECK: io table exists
if type(io_table) ~= "table" then
local message = util.c("configure> redstone entry #", entry_idx, " no IO table found")
println(message)
log.fatal(message)
return false
end
local capabilities = {}
log.debug(util.c("configure> starting redstone RTU I/O linking for reactor ", io_reactor, "..."))
local continue = true
-- CHECK: no duplicate entries
for i = 1, #units do
local unit = units[i] ---@type rtu_unit_registry_entry
if unit.reactor == io_reactor and unit.type == RTU_UNIT_TYPE.REDSTONE then
-- duplicate entry
local message = util.c("configure> skipping definition block #", entry_idx, " for reactor ", io_reactor,
" with already defined redstone I/O")
println(message)
log.warning(message)
continue = false
break
end
end
-- not a duplicate
if continue then
for i = 1, #io_table do
local valid = false
local conf = io_table[i]
-- verify configuration
if rsio.is_valid_port(conf.port) and rsio.is_valid_side(conf.side) then
if conf.bundled_color then
valid = rsio.is_color(conf.bundled_color)
else
valid = true
end
end
if not valid then
local message = util.c("configure> invalid redstone definition at index ", i, " in definition block #", entry_idx,
" (for reactor ", io_reactor, ")")
println(message)
log.fatal(message)
return false
else
-- link redstone in RTU
local mode = rsio.get_io_mode(conf.port)
if mode == rsio.IO_MODE.DIGITAL_IN then
-- can't have duplicate inputs
if util.table_contains(capabilities, conf.port) then
local message = util.c("configure> skipping duplicate input for port ", rsio.to_string(conf.port), " on side ", conf.side)
println(message)
log.warning(message)
else
rs_rtu.link_di(conf.side, conf.bundled_color)
end
elseif mode == rsio.IO_MODE.DIGITAL_OUT then
rs_rtu.link_do(conf.side, conf.bundled_color)
elseif mode == rsio.IO_MODE.ANALOG_IN then
-- can't have duplicate inputs
if util.table_contains(capabilities, conf.port) then
local message = util.c("configure> skipping duplicate input for port ", rsio.to_string(conf.port), " on side ", conf.side)
println(message)
log.warning(message)
else
rs_rtu.link_ai(conf.side)
end
elseif mode == rsio.IO_MODE.ANALOG_OUT then
rs_rtu.link_ao(conf.side)
else
-- should be unreachable code, we already validated ports
log.error("configure> fell through if chain attempting to identify IO mode", true)
println("configure> encountered a software error, check logs")
return false
end
table.insert(capabilities, conf.port)
log.debug(util.c("configure> linked redstone ", #capabilities, ": ", rsio.to_string(conf.port),
" (", conf.side, ") for reactor ", io_reactor))
end
end
---@class rtu_unit_registry_entry
local unit = {
uid = 0, ---@type integer
name = "redstone_io", ---@type string
type = RTU_UNIT_TYPE.REDSTONE, ---@type RTU_UNIT_TYPE
index = entry_idx, ---@type integer
reactor = io_reactor, ---@type integer
device = capabilities, ---@type table use device field for redstone ports
is_multiblock = false, ---@type boolean
formed = nil, ---@type boolean|nil
rtu = rs_rtu, ---@type rtu_device|rtu_rs_device
modbus_io = modbus.new(rs_rtu, false),
pkt_queue = nil, ---@type mqueue|nil
thread = nil ---@type parallel_thread|nil
}
table.insert(units, unit)
local for_message = "facility"
if io_reactor > 0 then
for_message = util.c("reactor ", io_reactor)
end
log.info(util.c("configure> initialized RTU unit #", #units, ": redstone_io (redstone) [1] for ", for_message))
unit.uid = #units
end
end end
-- CHECK: index is an integer >= 1 -- mounted peripherals
if (not util.is_int(index)) or (index <= 0) then for i = 1, #rtu_devices do
println(util.c("configure> device entry #", i, ": index ", index, " isn't an integer >= 1")) local name = rtu_devices[i].name
return false local index = rtu_devices[i].index
end local for_reactor = rtu_devices[i].for_reactor
-- CHECK: reactor is an integer >= 1 -- CHECK: name is a string
if (not util.is_int(for_reactor)) or (for_reactor <= 0) then if type(name) ~= "string" then
println(util.c("configure> device entry #", i, ": reactor ", for_reactor, " isn't an integer >= 1")) local message = util.c("configure> device entry #", i, ": device ", name, " isn't a string")
return false println(message)
end log.fatal(message)
return false
end
local device = ppm.get_periph(name) -- CHECK: index is an integer >= 1
if (not util.is_int(index)) or (index <= 0) then
local message = util.c("configure> device entry #", i, ": index ", index, " isn't an integer >= 1")
println(message)
log.fatal(message)
return false
end
if device == nil then -- CHECK: reactor is an integer >= 0
local message = util.c("configure> '", name, "' not found") if (not util.is_int(for_reactor)) or (for_reactor < 0) then
println(message) local message = util.c("configure> device entry #", i, ": reactor ", for_reactor, " isn't an integer >= 0")
log.fatal(message) println(message)
return false log.fatal(message)
else return false
local type = ppm.get_type(name) end
local rtu_iface = nil ---@type rtu_device
local rtu_type = ""
if type == "boiler" then local device = ppm.get_periph(name)
local type = nil ---@type string|nil
local rtu_iface = nil ---@type rtu_device
local rtu_type = nil ---@type RTU_UNIT_TYPE
local is_multiblock = false
local formed = nil ---@type boolean|nil
if device == nil then
local message = util.c("configure> '", name, "' not found, using placeholder")
println(message)
log.warning(message)
-- mount a virtual (placeholder) device
type, device = ppm.mount_virtual()
else
type = ppm.get_type(name)
end
if type == "boilerValve" then
-- boiler multiblock -- boiler multiblock
rtu_type = rtu_t.boiler rtu_type = RTU_UNIT_TYPE.BOILER_VALVE
rtu_iface = boiler_rtu.new(device)
elseif type == "boilerValve" then
-- boiler multiblock (10.1+)
rtu_type = rtu_t.boiler_valve
rtu_iface = boilerv_rtu.new(device) rtu_iface = boilerv_rtu.new(device)
elseif type == "turbine" then is_multiblock = true
-- turbine multiblock formed = device.isFormed()
rtu_type = rtu_t.turbine
rtu_iface = turbine_rtu.new(device) if formed == ppm.UNDEFINED_FIELD or formed == ppm.ACCESS_FAULT then
println_ts(util.c("configure> failed to check if '", name, "' is formed"))
log.fatal(util.c("configure> failed to check if '", name, "' is a formed boiler multiblock"))
return false
end
elseif type == "turbineValve" then elseif type == "turbineValve" then
-- turbine multiblock (10.1+) -- turbine multiblock
rtu_type = rtu_t.turbine_valve rtu_type = RTU_UNIT_TYPE.TURBINE_VALVE
rtu_iface = turbinev_rtu.new(device) rtu_iface = turbinev_rtu.new(device)
elseif type == "mekanismMachine" then is_multiblock = true
-- assumed to be an induction matrix multiblock, pre Mekanism 10.1 formed = device.isFormed()
-- also works with energy cubes
rtu_type = rtu_t.energy_machine if formed == ppm.UNDEFINED_FIELD or formed == ppm.ACCESS_FAULT then
rtu_iface = energymachine_rtu.new(device) println_ts(util.c("configure> failed to check if '", name, "' is formed"))
log.fatal(util.c("configure> failed to check if '", name, "' is a formed turbine multiblock"))
return false
end
elseif type == "inductionPort" then elseif type == "inductionPort" then
-- induction matrix multiblock (10.1+) -- induction matrix multiblock
rtu_type = rtu_t.induction_matrix rtu_type = RTU_UNIT_TYPE.IMATRIX
rtu_iface = imatrix_rtu.new(device) rtu_iface = imatrix_rtu.new(device)
is_multiblock = true
formed = device.isFormed()
if formed == ppm.UNDEFINED_FIELD or formed == ppm.ACCESS_FAULT then
println_ts(util.c("configure> failed to check if '", name, "' is formed"))
log.fatal(util.c("configure> failed to check if '", name, "' is a formed induction matrix multiblock"))
return false
end
elseif type == "spsPort" then
-- SPS multiblock
rtu_type = RTU_UNIT_TYPE.SPS
rtu_iface = sps_rtu.new(device)
is_multiblock = true
formed = device.isFormed()
if formed == ppm.UNDEFINED_FIELD or formed == ppm.ACCESS_FAULT then
println_ts(util.c("configure> failed to check if '", name, "' is formed"))
log.fatal(util.c("configure> failed to check if '", name, "' is a formed SPS multiblock"))
return false
end
elseif type == "solarNeutronActivator" then
-- SNA
rtu_type = RTU_UNIT_TYPE.SNA
rtu_iface = sna_rtu.new(device)
elseif type == "environmentDetector" then elseif type == "environmentDetector" then
-- advanced peripherals environment detector -- advanced peripherals environment detector
rtu_type = rtu_t.env_detector rtu_type = RTU_UNIT_TYPE.ENV_DETECTOR
rtu_iface = envd_rtu.new(device) rtu_iface = envd_rtu.new(device)
elseif type == ppm.VIRTUAL_DEVICE_TYPE then
-- placeholder device
rtu_type = RTU_UNIT_TYPE.VIRTUAL
rtu_iface = rtu.init_unit().interface()
else else
local message = util.c("configure> device '", name, "' is not a known type (", type, ")") local message = util.c("configure> device '", name, "' is not a known type (", type, ")")
println_ts(message) println_ts(message)
@@ -303,65 +371,82 @@ local function configure()
return false return false
end end
if rtu_iface ~= nil then ---@class rtu_unit_registry_entry
---@class rtu_unit_registry_entry local rtu_unit = {
local rtu_unit = { uid = 0, ---@type integer
name = name, name = name, ---@type string
type = rtu_type, type = rtu_type, ---@type RTU_UNIT_TYPE
index = index, index = index, ---@type integer
reactor = for_reactor, reactor = for_reactor, ---@type integer
device = device, device = device, ---@type table
rtu = rtu_iface, is_multiblock = is_multiblock, ---@type boolean
modbus_io = modbus.new(rtu_iface, true), formed = formed, ---@type boolean|nil
pkt_queue = mqueue.new(), rtu = rtu_iface, ---@type rtu_device|rtu_rs_device
thread = nil modbus_io = modbus.new(rtu_iface, true),
} pkt_queue = mqueue.new(), ---@type mqueue|nil
thread = nil ---@type parallel_thread|nil
}
rtu_unit.thread = threads.thread__unit_comms(__shared_memory, rtu_unit) rtu_unit.thread = threads.thread__unit_comms(__shared_memory, rtu_unit)
table.insert(units, rtu_unit) table.insert(units, rtu_unit)
log.debug(util.c("configure> initialized RTU unit #", #units, ": ", name, " (", rtu_type, ") [", index, "] for reactor ", for_reactor)) if is_multiblock and not formed then
log.info(util.c("configure> device '", name, "' is not formed"))
end
local for_message = "facility"
if for_reactor > 0 then
for_message = util.c("reactor ", for_reactor)
end
log.info(util.c("configure> initialized RTU unit #", #units, ": ", name, " (", types.rtu_type_to_string(rtu_type), ") [", index, "] for ", for_message))
rtu_unit.uid = #units
end
-- we made it through all that trusting-user-to-write-a-config-file chaos
return true
end
----------------------------------------
-- start system
----------------------------------------
log.debug("boot> running configure()")
if configure() then
-- start connection watchdog
smem_sys.conn_watchdog = util.new_watchdog(config.COMMS_TIMEOUT)
log.debug("startup> conn watchdog started")
-- setup comms
smem_sys.rtu_comms = rtu.comms(RTU_VERSION, smem_dev.modem, config.LISTEN_PORT, config.SERVER_PORT,
config.TRUSTED_RANGE, smem_sys.conn_watchdog)
log.debug("startup> comms init")
-- init threads
local main_thread = threads.thread__main(__shared_memory)
local comms_thread = threads.thread__comms(__shared_memory)
-- assemble thread list
local _threads = { main_thread.p_exec, comms_thread.p_exec }
for i = 1, #units do
if units[i].thread ~= nil then
table.insert(_threads, units[i].thread.p_exec)
end end
end end
log.info("startup> completed")
-- run threads
parallel.waitForAll(table.unpack(_threads))
else
println("configuration failed, exiting...")
end end
-- we made it through all that trusting-user-to-write-a-config-file chaos println_ts("exited")
return true log.info("exited")
end end
---------------------------------------- if not xpcall(main, crash.handler) then crash.exit() end
-- start system
----------------------------------------
log.debug("boot> running configure()")
if configure() then
-- start connection watchdog
smem_sys.conn_watchdog = util.new_watchdog(5)
log.debug("boot> conn watchdog started")
-- setup comms
smem_sys.rtu_comms = rtu.comms(RTU_VERSION, smem_dev.modem, config.LISTEN_PORT, config.SERVER_PORT, smem_sys.conn_watchdog)
log.debug("boot> comms init")
-- init threads
local main_thread = threads.thread__main(__shared_memory)
local comms_thread = threads.thread__comms(__shared_memory)
-- assemble thread list
local _threads = { main_thread.p_exec, comms_thread.p_exec }
for i = 1, #units do
if units[i].thread ~= nil then
table.insert(_threads, units[i].thread.p_exec)
end
end
-- run threads
parallel.waitForAll(table.unpack(_threads))
else
println("configuration failed, exiting...")
end
println_ts("exited")
log.info("exited")

View File

@@ -1,21 +1,21 @@
local log = require("scada-common.log") local log = require("scada-common.log")
local mqueue = require("scada-common.mqueue") local mqueue = require("scada-common.mqueue")
local ppm = require("scada-common.ppm") local ppm = require("scada-common.ppm")
local types = require("scada-common.types") local types = require("scada-common.types")
local util = require("scada-common.util") local util = require("scada-common.util")
local boiler_rtu = require("rtu.dev.boiler_rtu") local boilerv_rtu = require("rtu.dev.boilerv_rtu")
local boilerv_rtu = require("rtu.dev.boilerv_rtu") local envd_rtu = require("rtu.dev.envd_rtu")
local energymachine_rtu = require("rtu.dev.energymachine_rtu") local imatrix_rtu = require("rtu.dev.imatrix_rtu")
local imatrix_rtu = require("rtu.dev.imatrix_rtu") local sna_rtu = require("rtu.dev.sna_rtu")
local turbine_rtu = require("rtu.dev.turbine_rtu") local sps_rtu = require("rtu.dev.sps_rtu")
local turbinev_rtu = require("rtu.dev.turbinev_rtu") local turbinev_rtu = require("rtu.dev.turbinev_rtu")
local modbus = require("rtu.modbus") local modbus = require("rtu.modbus")
local threads = {} local threads = {}
local rtu_t = types.rtu_t local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE
local print = util.print local print = util.print
local println = util.println local println = util.println
@@ -26,9 +26,11 @@ local MAIN_CLOCK = 2 -- (2Hz, 40 ticks)
local COMMS_SLEEP = 100 -- (100ms, 2 ticks) local COMMS_SLEEP = 100 -- (100ms, 2 ticks)
-- main thread -- main thread
---@nodiscard
---@param smem rtu_shared_memory ---@param smem rtu_shared_memory
function threads.thread__main(smem) function threads.thread__main(smem)
local public = {} ---@class thread ---@class parallel_thread
local public = {}
-- execute thread -- execute thread
function public.exec() function public.exec()
@@ -58,10 +60,10 @@ function threads.thread__main(smem)
-- start next clock timer -- start next clock timer
loop_clock.start() loop_clock.start()
-- period tick, if we are not linked send advertisement -- period tick, if we are not linked send establish request
if not rtu_state.linked then if not rtu_state.linked then
-- advertise units -- advertise units
rtu_comms.send_advertisement(units) rtu_comms.send_establish(units)
end end
elseif event == "modem_message" then elseif event == "modem_message" then
-- got a packet -- got a packet
@@ -93,7 +95,10 @@ function threads.thread__main(smem)
-- we are going to let the PPM prevent crashes -- we are going to let the PPM prevent crashes
-- return fault flags/codes to MODBUS queries -- return fault flags/codes to MODBUS queries
local unit = units[i] local unit = units[i]
println_ts("lost the " .. unit.type .. " on interface " .. unit.name) local type_name = types.rtu_type_to_string(unit.type)
println_ts(util.c("lost the ", type_name, " on interface ", unit.name))
log.warning(util.c("lost the ", type_name, " unit peripheral on interface ", unit.name))
break
end end
end end
end end
@@ -110,9 +115,9 @@ function threads.thread__main(smem)
rtu_comms.reconnect_modem(rtu_dev.modem) rtu_comms.reconnect_modem(rtu_dev.modem)
println_ts("wireless modem reconnected.") println_ts("wireless modem reconnected.")
log.info("comms modem reconnected.") log.info("comms modem reconnected")
else else
log.info("wired modem reconnected.") log.info("wired modem reconnected")
end end
else else
-- relink lost peripheral to correct unit entry -- relink lost peripheral to correct unit entry
@@ -120,27 +125,79 @@ function threads.thread__main(smem)
local unit = units[i] ---@type rtu_unit_registry_entry local unit = units[i] ---@type rtu_unit_registry_entry
-- find disconnected device to reconnect -- find disconnected device to reconnect
-- note: cannot check isFormed as that would yield this coroutine and consume events
if unit.name == param1 then if unit.name == param1 then
local resend_advert = false
-- found, re-link -- found, re-link
unit.device = device unit.device = device
if unit.type == rtu_t.boiler then if unit.type == RTU_UNIT_TYPE.VIRTUAL then
unit.rtu = boiler_rtu.new(device) resend_advert = true
elseif unit.type == rtu_t.boiler_valve then if type == "boilerValve" then
-- boiler multiblock
unit.type = RTU_UNIT_TYPE.BOILER_VALVE
elseif type == "turbineValve" then
-- turbine multiblock
unit.type = RTU_UNIT_TYPE.TURBINE_VALVE
elseif type == "inductionPort" then
-- induction matrix multiblock
unit.type = RTU_UNIT_TYPE.IMATRIX
elseif type == "spsPort" then
-- SPS multiblock
unit.type = RTU_UNIT_TYPE.SPS
elseif type == "solarNeutronActivator" then
-- SNA
unit.type = RTU_UNIT_TYPE.SNA
elseif type == "environmentDetector" then
-- advanced peripherals environment detector
unit.type = RTU_UNIT_TYPE.ENV_DETECTOR
else
resend_advert = false
log.error(util.c("virtual device '", unit.name, "' cannot init to an unknown type (", type, ")"))
end
end
if unit.type == RTU_UNIT_TYPE.BOILER_VALVE then
unit.rtu = boilerv_rtu.new(device) unit.rtu = boilerv_rtu.new(device)
elseif unit.type == rtu_t.turbine then -- if not formed, indexing the multiblock functions would have resulted in a PPM fault
unit.rtu = turbine_rtu.new(device) unit.formed = util.trinary(device.__p_is_faulted(), false, nil)
elseif unit.type == rtu_t.turbine_valve then elseif unit.type == RTU_UNIT_TYPE.TURBINE_VALVE then
unit.rtu = turbinev_rtu.new(device) unit.rtu = turbinev_rtu.new(device)
elseif unit.type == rtu_t.energy_machine then -- if not formed, indexing the multiblock functions would have resulted in a PPM fault
unit.rtu = energymachine_rtu.new(device) unit.formed = util.trinary(device.__p_is_faulted(), false, nil)
elseif unit.type == rtu_t.induction_matrix then elseif unit.type == RTU_UNIT_TYPE.IMATRIX then
unit.rtu = imatrix_rtu.new(device) unit.rtu = imatrix_rtu.new(device)
-- if not formed, indexing the multiblock functions would have resulted in a PPM fault
unit.formed = util.trinary(device.__p_is_faulted(), false, nil)
elseif unit.type == RTU_UNIT_TYPE.SPS then
unit.rtu = sps_rtu.new(device)
-- if not formed, indexing the multiblock functions would have resulted in a PPM fault
unit.formed = util.trinary(device.__p_is_faulted(), false, nil)
elseif unit.type == RTU_UNIT_TYPE.SNA then
unit.rtu = sna_rtu.new(device)
elseif unit.type == RTU_UNIT_TYPE.ENV_DETECTOR then
unit.rtu = envd_rtu.new(device)
else
log.error(util.c("failed to identify reconnected RTU unit type (", unit.name, ")"), true)
end
if unit.is_multiblock and (unit.formed == false) then
log.info(util.c("assuming ", unit.name, " is not formed due to PPM faults while initializing"))
end end
unit.modbus_io = modbus.new(unit.rtu, true) unit.modbus_io = modbus.new(unit.rtu, true)
println_ts("reconnected the " .. unit.type .. " on interface " .. unit.name) local type_name = types.rtu_type_to_string(unit.type)
local message = util.c("reconnected the ", type_name, " on interface ", unit.name)
println_ts(message)
log.info(message)
if resend_advert then
rtu_comms.send_advertisement(units)
else
rtu_comms.send_remounted(unit.uid)
end
end end
end end
end end
@@ -163,7 +220,7 @@ function threads.thread__main(smem)
while not rtu_state.shutdown do while not rtu_state.shutdown do
local status, result = pcall(public.exec) local status, result = pcall(public.exec)
if status == false then if status == false then
log.fatal(result) log.fatal(util.strval(result))
end end
if not rtu_state.shutdown then if not rtu_state.shutdown then
@@ -177,22 +234,24 @@ function threads.thread__main(smem)
end end
-- communications handler thread -- communications handler thread
---@nodiscard
---@param smem rtu_shared_memory ---@param smem rtu_shared_memory
function threads.thread__comms(smem) function threads.thread__comms(smem)
local public = {} ---@class thread ---@class parallel_thread
local public = {}
-- execute thread -- execute thread
function public.exec() function public.exec()
log.debug("comms thread start") log.debug("comms thread start")
-- load in from shared memory -- load in from shared memory
local rtu_state = smem.rtu_state local rtu_state = smem.rtu_state
local rtu_comms = smem.rtu_sys.rtu_comms local rtu_comms = smem.rtu_sys.rtu_comms
local units = smem.rtu_sys.units local units = smem.rtu_sys.units
local comms_queue = smem.q.mq_comms local comms_queue = smem.q.mq_comms
local last_update = util.time() local last_update = util.time()
-- thread loop -- thread loop
while true do while true do
@@ -235,7 +294,7 @@ function threads.thread__comms(smem)
while not rtu_state.shutdown do while not rtu_state.shutdown do
local status, result = pcall(public.exec) local status, result = pcall(public.exec)
if status == false then if status == false then
log.fatal(result) log.fatal(util.strval(result))
end end
if not rtu_state.shutdown then if not rtu_state.shutdown then
@@ -249,14 +308,16 @@ function threads.thread__comms(smem)
end end
-- per-unit communications handler thread -- per-unit communications handler thread
---@nodiscard
---@param smem rtu_shared_memory ---@param smem rtu_shared_memory
---@param unit rtu_unit_registry_entry ---@param unit rtu_unit_registry_entry
function threads.thread__unit_comms(smem, unit) function threads.thread__unit_comms(smem, unit)
local public = {} ---@class thread ---@class parallel_thread
local public = {}
-- execute thread -- execute thread
function public.exec() function public.exec()
log.debug("rtu unit thread start -> " .. unit.type .. "(" .. unit.name .. ")") log.debug(util.c("rtu unit thread start -> ", types.rtu_type_to_string(unit.type), "(", unit.name, ")"))
-- load in from shared memory -- load in from shared memory
local rtu_state = smem.rtu_state local rtu_state = smem.rtu_state
@@ -265,6 +326,16 @@ function threads.thread__unit_comms(smem, unit)
local last_update = util.time() local last_update = util.time()
local last_f_check = 0
local detail_name = util.c(types.rtu_type_to_string(unit.type), " (", unit.name, ") [", unit.index, "] for reactor ", unit.reactor)
local short_name = util.c(types.rtu_type_to_string(unit.type), " (", unit.name, ")")
if packet_queue == nil then
log.error("rtu unit thread created without a message queue, exiting...", true)
return
end
-- thread loop -- thread loop
while true do while true do
-- check for messages in the message queue -- check for messages in the message queue
@@ -287,9 +358,71 @@ function threads.thread__unit_comms(smem, unit)
util.nop() util.nop()
end end
-- check if multiblock is still formed if this is a multiblock
if unit.is_multiblock and (util.time_ms() - last_f_check > 250) then
local is_formed = unit.device.isFormed()
last_f_check = util.time_ms()
if unit.formed == nil then unit.formed = is_formed end
if (not unit.formed) and is_formed then
-- newly re-formed
local iface = ppm.get_iface(unit.device)
if iface then
log.info(util.c("unmounting and remounting reformed RTU unit ", detail_name))
ppm.unmount(unit.device)
local type, device = ppm.mount(iface)
if device ~= nil then
if type == "boilerValve" and unit.type == RTU_UNIT_TYPE.BOILER_VALVE then
-- boiler multiblock
unit.device = device
unit.rtu = boilerv_rtu.new(device)
unit.formed = device.isFormed()
unit.modbus_io = modbus.new(unit.rtu, true)
elseif type == "turbineValve" and unit.type == RTU_UNIT_TYPE.TURBINE_VALVE then
-- turbine multiblock
unit.device = device
unit.rtu = turbinev_rtu.new(device)
unit.formed = device.isFormed()
unit.modbus_io = modbus.new(unit.rtu, true)
elseif type == "inductionPort" and unit.type == RTU_UNIT_TYPE.IMATRIX then
-- induction matrix multiblock
unit.device = device
unit.rtu = imatrix_rtu.new(device)
unit.formed = device.isFormed()
unit.modbus_io = modbus.new(unit.rtu, true)
elseif type == "spsPort" and unit.type == RTU_UNIT_TYPE.SPS then
-- SPS multiblock
unit.device = device
unit.rtu = sps_rtu.new(device)
unit.formed = device.isFormed()
unit.modbus_io = modbus.new(unit.rtu, true)
else
log.error("illegal remount of non-multiblock RTU attempted for " .. short_name, true)
end
rtu_comms.send_remounted(unit.uid)
else
-- fully lost the peripheral now :(
log.error(util.c(unit.name, " lost (failed reconnect)"))
end
log.info(util.c("reconnected the ", unit.type, " on interface ", unit.name))
else
log.error("failed to get interface of previously connected RTU unit " .. detail_name, true)
end
end
unit.formed = is_formed
end
-- check for termination request -- check for termination request
if rtu_state.shutdown then if rtu_state.shutdown then
log.info("rtu unit thread exiting -> " .. unit.type .. "(" .. unit.name .. ")") log.info("rtu unit thread exiting -> " .. short_name)
break break
end end
@@ -305,11 +438,11 @@ function threads.thread__unit_comms(smem, unit)
while not rtu_state.shutdown do while not rtu_state.shutdown do
local status, result = pcall(public.exec) local status, result = pcall(public.exec)
if status == false then if status == false then
log.fatal(result) log.fatal(util.strval(result))
end end
if not rtu_state.shutdown then if not rtu_state.shutdown then
log.info(util.c("rtu unit thread ", unit.type, "(", unit.name, ") restarting in 5 seconds...")) log.info(util.c("rtu unit thread ", types.rtu_type_to_string(unit.type), "(", unit.name, " restarting in 5 seconds..."))
util.psleep(5) util.psleep(5)
end end
end end

View File

@@ -1,73 +0,0 @@
local util = require("scada-common.util")
---@class alarm
local alarm = {}
---@alias SEVERITY integer
SEVERITY = {
INFO = 0, -- basic info message
WARNING = 1, -- warning about some abnormal state
ALERT = 2, -- important device state changes
FACILITY = 3, -- facility-wide alert
SAFETY = 4, -- safety alerts
EMERGENCY = 5 -- critical safety alarm
}
alarm.SEVERITY = SEVERITY
-- severity integer to string
---@param severity SEVERITY
function alarm.severity_to_string(severity)
if severity == SEVERITY.INFO then
return "INFO"
elseif severity == SEVERITY.WARNING then
return "WARNING"
elseif severity == SEVERITY.ALERT then
return "ALERT"
elseif severity == SEVERITY.FACILITY then
return "FACILITY"
elseif severity == SEVERITY.SAFETY then
return "SAFETY"
elseif severity == SEVERITY.EMERGENCY then
return "EMERGENCY"
else
return "UNKNOWN"
end
end
-- create a new scada alarm entry
---@param severity SEVERITY
---@param device string
---@param message string
function alarm.scada_alarm(severity, device, message)
local self = {
time = util.time(),
ts_string = os.date("[%H:%M:%S]"),
severity = severity,
device = device,
message = message
}
---@class scada_alarm
local public = {}
-- format the alarm as a string
---@return string message
function public.format()
return self.ts_string .. " [" .. alarm.severity_to_string(self.severity) .. "] (" .. self.device .. ") >> " .. self.message
end
-- get alarm properties
function public.properties()
return {
time = self.time,
severity = self.severity,
device = self.device,
message = self.message
}
end
return public
end
return alarm

View File

@@ -3,16 +3,18 @@
-- --
local log = require("scada-common.log") local log = require("scada-common.log")
local types = require("scada-common.types")
---@class comms ---@class comms
local comms = {} local comms = {}
local rtu_t = types.rtu_t
local insert = table.insert local insert = table.insert
---@alias PROTOCOLS integer local max_distance = nil
local PROTOCOLS = {
comms.version = "1.4.0"
---@enum PROTOCOL
local PROTOCOL = {
MODBUS_TCP = 0, -- our "MODBUS TCP"-esque protocol MODBUS_TCP = 0, -- our "MODBUS TCP"-esque protocol
RPLC = 1, -- reactor PLC protocol RPLC = 1, -- reactor PLC protocol
SCADA_MGMT = 2, -- SCADA supervisor management, device advertisements, etc SCADA_MGMT = 2, -- SCADA supervisor management, device advertisements, etc
@@ -20,79 +22,130 @@ local PROTOCOLS = {
COORD_API = 4 -- data/control packets for pocket computers to/from coordinators COORD_API = 4 -- data/control packets for pocket computers to/from coordinators
} }
---@alias RPLC_TYPES integer ---@enum RPLC_TYPE
local RPLC_TYPES = { local RPLC_TYPE = {
LINK_REQ = 0, -- linking requests STATUS = 0, -- reactor/system status
STATUS = 1, -- reactor/system status MEK_STRUCT = 1, -- mekanism build structure
MEK_STRUCT = 2, -- mekanism build structure MEK_BURN_RATE = 2, -- set burn rate
MEK_BURN_RATE = 3, -- set burn rate RPS_ENABLE = 3, -- enable reactor
RPS_ENABLE = 4, -- enable reactor RPS_SCRAM = 4, -- SCRAM reactor (manual request)
RPS_SCRAM = 5, -- SCRAM reactor RPS_ASCRAM = 5, -- SCRAM reactor (automatic request)
RPS_STATUS = 6, -- RPS status RPS_STATUS = 6, -- RPS status
RPS_ALARM = 7, -- RPS alarm broadcast RPS_ALARM = 7, -- RPS alarm broadcast
RPS_RESET = 8 -- clear RPS trip (if in bad state, will trip immediately) RPS_RESET = 8, -- clear RPS trip (if in bad state, will trip immediately)
RPS_AUTO_RESET = 9, -- clear RPS trip if it is just a timeout or auto scram
AUTO_BURN_RATE = 10 -- set an automatic burn rate, PLC will respond with status, enable toggle speed limited
} }
---@alias RPLC_LINKING integer ---@enum SCADA_MGMT_TYPE
local RPLC_LINKING = { local SCADA_MGMT_TYPE = {
ESTABLISH = 0, -- establish new connection
KEEP_ALIVE = 1, -- keep alive packet w/ RTT
CLOSE = 2, -- close a connection
RTU_ADVERT = 3, -- RTU capability advertisement
RTU_DEV_REMOUNT = 4 -- RTU multiblock possbily changed (formed, unformed) due to PPM remount
}
---@enum SCADA_CRDN_TYPE
local SCADA_CRDN_TYPE = {
INITIAL_BUILDS = 0, -- initial, complete builds packet to the coordinator
FAC_BUILDS = 1, -- facility RTU builds
FAC_STATUS = 2, -- state of facility and facility devices
FAC_CMD = 3, -- faility command
UNIT_BUILDS = 4, -- build of each reactor unit (reactor + RTUs)
UNIT_STATUSES = 5, -- state of each of the reactor units
UNIT_CMD = 6 -- command a reactor unit
}
---@enum CAPI_TYPE
local CAPI_TYPE = {
}
---@enum ESTABLISH_ACK
local ESTABLISH_ACK = {
ALLOW = 0, -- link approved ALLOW = 0, -- link approved
DENY = 1, -- link denied DENY = 1, -- link denied
COLLISION = 2 -- link denied due to existing active link COLLISION = 2, -- link denied due to existing active link
BAD_VERSION = 3 -- link denied due to comms version mismatch
} }
---@alias SCADA_MGMT_TYPES integer ---@enum DEVICE_TYPE
local SCADA_MGMT_TYPES = { local DEVICE_TYPE = {
KEEP_ALIVE = 0, -- keep alive packet w/ RTT PLC = 0, -- PLC device type for establish
CLOSE = 1, -- close a connection RTU = 1, -- RTU device type for establish
RTU_ADVERT = 2, -- RTU capability advertisement SV = 2, -- supervisor device type for establish
REMOTE_LINKED = 3 -- remote device linked CRDN = 3 -- coordinator device type for establish
} }
---@alias SCADA_CRDN_TYPES integer ---@enum PLC_AUTO_ACK
local SCADA_CRDN_TYPES = { local PLC_AUTO_ACK = {
ESTABLISH = 0, -- initial greeting FAIL = 0, -- failed to set burn rate/burn rate invalid
STRUCT_BUILDS = 1, -- mekanism structure builds DIRECT_SET_OK = 1, -- successfully set burn rate
UNIT_STATUSES = 2, -- state of reactor units RAMP_SET_OK = 2, -- successfully started burn rate ramping
COMMAND_UNIT = 3, -- command a reactor unit ZERO_DIS_OK = 3 -- successfully disabled reactor with < 0.01 burn rate
ALARM = 4 -- alarm signaling
} }
---@alias CAPI_TYPES integer ---@enum FAC_COMMAND
local CAPI_TYPES = { local FAC_COMMAND = {
ESTABLISH = 0 -- initial greeting SCRAM_ALL = 0, -- SCRAM all reactors
STOP = 1, -- stop automatic control
START = 2, -- start automatic control
ACK_ALL_ALARMS = 3 -- acknowledge all alarms on all units
} }
---@alias RTU_UNIT_TYPES integer ---@enum UNIT_COMMAND
local RTU_UNIT_TYPES = { local UNIT_COMMAND = {
REDSTONE = 0, -- redstone I/O SCRAM = 0, -- SCRAM the reactor
BOILER = 1, -- boiler START = 1, -- start the reactor
BOILER_VALVE = 2, -- boiler mekanism 10.1+ RESET_RPS = 2, -- reset the RPS
TURBINE = 3, -- turbine SET_BURN = 3, -- set the burn rate
TURBINE_VALVE = 4, -- turbine, mekanism 10.1+ SET_WASTE = 4, -- set the waste processing mode
EMACHINE = 5, -- energy machine ACK_ALL_ALARMS = 5, -- ack all active alarms
IMATRIX = 6, -- induction matrix ACK_ALARM = 6, -- ack a particular alarm
SPS = 7, -- SPS RESET_ALARM = 7, -- reset a particular alarm
SNA = 8, -- SNA SET_GROUP = 8 -- assign this unit to a group
ENV_DETECTOR = 9 -- environment detector
} }
comms.PROTOCOLS = PROTOCOLS comms.PROTOCOL = PROTOCOL
comms.RPLC_TYPES = RPLC_TYPES
comms.RPLC_LINKING = RPLC_LINKING comms.RPLC_TYPE = RPLC_TYPE
comms.SCADA_MGMT_TYPES = SCADA_MGMT_TYPES comms.SCADA_MGMT_TYPE = SCADA_MGMT_TYPE
comms.SCADA_CRDN_TYPES = SCADA_CRDN_TYPES comms.SCADA_CRDN_TYPE = SCADA_CRDN_TYPE
comms.RTU_UNIT_TYPES = RTU_UNIT_TYPES comms.CAPI_TYPE = CAPI_TYPE
comms.ESTABLISH_ACK = ESTABLISH_ACK
comms.DEVICE_TYPE = DEVICE_TYPE
comms.PLC_AUTO_ACK = PLC_AUTO_ACK
comms.UNIT_COMMAND = UNIT_COMMAND
comms.FAC_COMMAND = FAC_COMMAND
---@alias packet scada_packet|modbus_packet|rplc_packet|mgmt_packet|crdn_packet|capi_packet
---@alias frame modbus_frame|rplc_frame|mgmt_frame|crdn_frame|capi_frame
-- configure the maximum allowable message receive distance<br>
-- packets received with distances greater than this will be silently discarded
---@param distance integer max modem message distance (less than 1 disables the limit)
function comms.set_trusted_range(distance)
if distance < 1 then
max_distance = nil
else
max_distance = distance
end
end
-- generic SCADA packet object -- generic SCADA packet object
---@nodiscard
function comms.scada_packet() function comms.scada_packet()
local self = { local self = {
modem_msg_in = nil, modem_msg_in = nil,
valid = false, valid = false,
raw = nil, raw = { -1, PROTOCOL.SCADA_MGMT, {} },
seq_num = nil, seq_num = -1,
protocol = nil, protocol = PROTOCOL.SCADA_MGMT,
length = nil, length = 0,
payload = nil payload = {}
} }
---@class scada_packet ---@class scada_packet
@@ -100,7 +153,7 @@ function comms.scada_packet()
-- make a SCADA packet -- make a SCADA packet
---@param seq_num integer ---@param seq_num integer
---@param protocol PROTOCOLS ---@param protocol PROTOCOL
---@param payload table ---@param payload table
function public.make(seq_num, protocol, payload) function public.make(seq_num, protocol, payload)
self.valid = true self.valid = true
@@ -112,11 +165,12 @@ function comms.scada_packet()
end end
-- parse in a modem message as a SCADA packet -- parse in a modem message as a SCADA packet
---@param side string ---@param side string modem side
---@param sender integer ---@param sender integer sender port
---@param reply_to integer ---@param reply_to integer reply port
---@param message any ---@param message any message body
---@param distance integer ---@param distance integer transmission distance
---@return boolean valid valid message received
function public.receive(side, sender, reply_to, message, distance) function public.receive(side, sender, reply_to, message, distance)
self.modem_msg_in = { self.modem_msg_in = {
iface = side, iface = side,
@@ -128,17 +182,26 @@ function comms.scada_packet()
self.raw = self.modem_msg_in.msg self.raw = self.modem_msg_in.msg
if type(self.raw) == "table" then if (type(max_distance) == "number") and (distance > max_distance) then
if #self.raw >= 3 then -- outside of maximum allowable transmission distance
self.seq_num = self.raw[1] -- log.debug("comms.scada_packet.receive(): discarding packet with distance " .. distance .. " outside of trusted range")
self.protocol = self.raw[2] else
self.length = #self.raw[3] if type(self.raw) == "table" then
self.payload = self.raw[3] if #self.raw >= 3 then
end self.seq_num = self.raw[1]
self.protocol = self.raw[2]
self.valid = type(self.seq_num) == "number" and -- element 3 must be a table
type(self.protocol) == "number" and if type(self.raw[3]) == "table" then
type(self.payload) == "table" self.length = #self.raw[3]
self.payload = self.raw[3]
end
end
self.valid = type(self.seq_num) == "number" and
type(self.protocol) == "number" and
type(self.payload) == "table"
end
end end
return self.valid return self.valid
@@ -146,33 +209,43 @@ function comms.scada_packet()
-- public accessors -- -- public accessors --
---@nodiscard
function public.modem_event() return self.modem_msg_in end function public.modem_event() return self.modem_msg_in end
---@nodiscard
function public.raw_sendable() return self.raw end function public.raw_sendable() return self.raw end
---@nodiscard
function public.local_port() return self.modem_msg_in.s_port end function public.local_port() return self.modem_msg_in.s_port end
---@nodiscard
function public.remote_port() return self.modem_msg_in.r_port end function public.remote_port() return self.modem_msg_in.r_port end
---@nodiscard
function public.is_valid() return self.valid end function public.is_valid() return self.valid end
---@nodiscard
function public.seq_num() return self.seq_num end function public.seq_num() return self.seq_num end
---@nodiscard
function public.protocol() return self.protocol end function public.protocol() return self.protocol end
---@nodiscard
function public.length() return self.length end function public.length() return self.length end
---@nodiscard
function public.data() return self.payload end function public.data() return self.payload end
return public return public
end end
-- MODBUS packet -- MODBUS packet<br>
-- modeled after MODBUS TCP packet -- modeled after MODBUS TCP packet
---@nodiscard
function comms.modbus_packet() function comms.modbus_packet()
local self = { local self = {
frame = nil, frame = nil,
raw = nil, raw = {},
txn_id = nil, txn_id = -1,
length = nil, length = 0,
unit_id = nil, unit_id = -1,
func_code = nil, func_code = 0x80,
data = nil data = {}
} }
---@class modbus_packet ---@class modbus_packet
@@ -208,7 +281,7 @@ function comms.modbus_packet()
if frame then if frame then
self.frame = frame self.frame = frame
if frame.protocol() == PROTOCOLS.MODBUS_TCP then if frame.protocol() == PROTOCOL.MODBUS_TCP then
local size_ok = frame.length() >= 3 local size_ok = frame.length() >= 3
if size_ok then if size_ok then
@@ -232,9 +305,11 @@ function comms.modbus_packet()
end end
-- get raw to send -- get raw to send
---@nodiscard
function public.raw_sendable() return self.raw end function public.raw_sendable() return self.raw end
-- get this packet as a frame with an immutable relation to this object -- get this packet as a frame with an immutable relation to this object
---@nodiscard
function public.get() function public.get()
---@class modbus_frame ---@class modbus_frame
local frame = { local frame = {
@@ -253,14 +328,15 @@ function comms.modbus_packet()
end end
-- reactor PLC packet -- reactor PLC packet
---@nodiscard
function comms.rplc_packet() function comms.rplc_packet()
local self = { local self = {
frame = nil, frame = nil,
raw = nil, raw = {},
id = nil, id = 0,
type = nil, type = 0, ---@type RPLC_TYPE
length = nil, length = 0,
body = nil data = {}
} }
---@class rplc_packet ---@class rplc_packet
@@ -268,20 +344,22 @@ function comms.rplc_packet()
-- check that type is known -- check that type is known
local function _rplc_type_valid() local function _rplc_type_valid()
return self.type == RPLC_TYPES.LINK_REQ or return self.type == RPLC_TYPE.STATUS or
self.type == RPLC_TYPES.STATUS or self.type == RPLC_TYPE.MEK_STRUCT or
self.type == RPLC_TYPES.MEK_STRUCT or self.type == RPLC_TYPE.MEK_BURN_RATE or
self.type == RPLC_TYPES.MEK_BURN_RATE or self.type == RPLC_TYPE.RPS_ENABLE or
self.type == RPLC_TYPES.RPS_ENABLE or self.type == RPLC_TYPE.RPS_SCRAM or
self.type == RPLC_TYPES.RPS_SCRAM or self.type == RPLC_TYPE.RPS_ASCRAM or
self.type == RPLC_TYPES.RPS_ALARM or self.type == RPLC_TYPE.RPS_STATUS or
self.type == RPLC_TYPES.RPS_STATUS or self.type == RPLC_TYPE.RPS_ALARM or
self.type == RPLC_TYPES.RPS_RESET self.type == RPLC_TYPE.RPS_RESET or
self.type == RPLC_TYPE.RPS_AUTO_RESET or
self.type == RPLC_TYPE.AUTO_BURN_RATE
end end
-- make an RPLC packet -- make an RPLC packet
---@param id integer ---@param id integer
---@param packet_type RPLC_TYPES ---@param packet_type RPLC_TYPE
---@param data table ---@param data table
function public.make(id, packet_type, data) function public.make(id, packet_type, data)
if type(data) == "table" then if type(data) == "table" then
@@ -308,7 +386,7 @@ function comms.rplc_packet()
if frame then if frame then
self.frame = frame self.frame = frame
if frame.protocol() == PROTOCOLS.RPLC then if frame.protocol() == PROTOCOL.RPLC then
local ok = frame.length() >= 2 local ok = frame.length() >= 2
if ok then if ok then
@@ -331,9 +409,11 @@ function comms.rplc_packet()
end end
-- get raw to send -- get raw to send
---@nodiscard
function public.raw_sendable() return self.raw end function public.raw_sendable() return self.raw end
-- get this packet as a frame with an immutable relation to this object -- get this packet as a frame with an immutable relation to this object
---@nodiscard
function public.get() function public.get()
---@class rplc_frame ---@class rplc_frame
local frame = { local frame = {
@@ -351,13 +431,14 @@ function comms.rplc_packet()
end end
-- SCADA management packet -- SCADA management packet
---@nodiscard
function comms.mgmt_packet() function comms.mgmt_packet()
local self = { local self = {
frame = nil, frame = nil,
raw = nil, raw = {},
type = nil, type = 0, ---@type SCADA_MGMT_TYPE
length = nil, length = 0,
data = nil data = {}
} }
---@class mgmt_packet ---@class mgmt_packet
@@ -365,14 +446,16 @@ function comms.mgmt_packet()
-- check that type is known -- check that type is known
local function _scada_type_valid() local function _scada_type_valid()
return self.type == SCADA_MGMT_TYPES.KEEP_ALIVE or return self.type == SCADA_MGMT_TYPE.ESTABLISH or
self.type == SCADA_MGMT_TYPES.CLOSE or self.type == SCADA_MGMT_TYPE.KEEP_ALIVE or
self.type == SCADA_MGMT_TYPES.REMOTE_LINKED or self.type == SCADA_MGMT_TYPE.CLOSE or
self.type == SCADA_MGMT_TYPES.RTU_ADVERT self.type == SCADA_MGMT_TYPE.REMOTE_LINKED or
self.type == SCADA_MGMT_TYPE.RTU_ADVERT or
self.type == SCADA_MGMT_TYPE.RTU_DEV_REMOUNT
end end
-- make a SCADA management packet -- make a SCADA management packet
---@param packet_type SCADA_MGMT_TYPES ---@param packet_type SCADA_MGMT_TYPE
---@param data table ---@param data table
function public.make(packet_type, data) function public.make(packet_type, data)
if type(data) == "table" then if type(data) == "table" then
@@ -398,7 +481,7 @@ function comms.mgmt_packet()
if frame then if frame then
self.frame = frame self.frame = frame
if frame.protocol() == PROTOCOLS.SCADA_MGMT then if frame.protocol() == PROTOCOL.SCADA_MGMT then
local ok = frame.length() >= 1 local ok = frame.length() >= 1
if ok then if ok then
@@ -419,9 +502,11 @@ function comms.mgmt_packet()
end end
-- get raw to send -- get raw to send
---@nodiscard
function public.raw_sendable() return self.raw end function public.raw_sendable() return self.raw end
-- get this packet as a frame with an immutable relation to this object -- get this packet as a frame with an immutable relation to this object
---@nodiscard
function public.get() function public.get()
---@class mgmt_frame ---@class mgmt_frame
local frame = { local frame = {
@@ -438,29 +523,33 @@ function comms.mgmt_packet()
end end
-- SCADA coordinator packet -- SCADA coordinator packet
---@nodiscard
function comms.crdn_packet() function comms.crdn_packet()
local self = { local self = {
frame = nil, frame = nil,
raw = nil, raw = {},
type = nil, type = 0, ---@type SCADA_CRDN_TYPE
length = nil, length = 0,
data = nil data = {}
} }
---@class crdn_packet ---@class crdn_packet
local public = {} local public = {}
-- check that type is known -- check that type is known
---@nodiscard
local function _crdn_type_valid() local function _crdn_type_valid()
return self.type == SCADA_CRDN_TYPES.ESTABLISH or return self.type == SCADA_CRDN_TYPE.INITIAL_BUILDS or
self.type == SCADA_CRDN_TYPES.STRUCT_BUILDS or self.type == SCADA_CRDN_TYPE.FAC_BUILDS or
self.type == SCADA_CRDN_TYPES.UNIT_STATUSES or self.type == SCADA_CRDN_TYPE.FAC_STATUS or
self.type == SCADA_CRDN_TYPES.COMMAND_UNIT or self.type == SCADA_CRDN_TYPE.FAC_CMD or
self.type == SCADA_CRDN_TYPES.ALARM self.type == SCADA_CRDN_TYPE.UNIT_BUILDS or
self.type == SCADA_CRDN_TYPE.UNIT_STATUSES or
self.type == SCADA_CRDN_TYPE.UNIT_CMD
end end
-- make a coordinator packet -- make a coordinator packet
---@param packet_type SCADA_CRDN_TYPES ---@param packet_type SCADA_CRDN_TYPE
---@param data table ---@param data table
function public.make(packet_type, data) function public.make(packet_type, data)
if type(data) == "table" then if type(data) == "table" then
@@ -486,7 +575,7 @@ function comms.crdn_packet()
if frame then if frame then
self.frame = frame self.frame = frame
if frame.protocol() == PROTOCOLS.SCADA_CRDN then if frame.protocol() == PROTOCOL.SCADA_CRDN then
local ok = frame.length() >= 1 local ok = frame.length() >= 1
if ok then if ok then
@@ -507,9 +596,11 @@ function comms.crdn_packet()
end end
-- get raw to send -- get raw to send
---@nodiscard
function public.raw_sendable() return self.raw end function public.raw_sendable() return self.raw end
-- get this packet as a frame with an immutable relation to this object -- get this packet as a frame with an immutable relation to this object
---@nodiscard
function public.get() function public.get()
---@class crdn_frame ---@class crdn_frame
local frame = { local frame = {
@@ -526,26 +617,27 @@ function comms.crdn_packet()
end end
-- coordinator API (CAPI) packet -- coordinator API (CAPI) packet
-- @todo ---@todo implement for pocket access, set enum type for self.type
---@nodiscard
function comms.capi_packet() function comms.capi_packet()
local self = { local self = {
frame = nil, frame = nil,
raw = nil, raw = {},
type = nil, type = 0,
length = nil, length = 0,
data = nil data = {}
} }
---@class capi_packet ---@class capi_packet
local public = {} local public = {}
local function _capi_type_valid() local function _capi_type_valid()
-- @todo ---@todo
return false return false
end end
-- make a coordinator API packet -- make a coordinator API packet
---@param packet_type CAPI_TYPES ---@param packet_type CAPI_TYPE
---@param data table ---@param data table
function public.make(packet_type, data) function public.make(packet_type, data)
if type(data) == "table" then if type(data) == "table" then
@@ -571,7 +663,7 @@ function comms.capi_packet()
if frame then if frame then
self.frame = frame self.frame = frame
if frame.protocol() == PROTOCOLS.COORD_API then if frame.protocol() == PROTOCOL.COORD_API then
local ok = frame.length() >= 1 local ok = frame.length() >= 1
if ok then if ok then
@@ -592,9 +684,11 @@ function comms.capi_packet()
end end
-- get raw to send -- get raw to send
---@nodiscard
function public.raw_sendable() return self.raw end function public.raw_sendable() return self.raw end
-- get this packet as a frame with an immutable relation to this object -- get this packet as a frame with an immutable relation to this object
---@nodiscard
function public.get() function public.get()
---@class capi_frame ---@class capi_frame
local frame = { local frame = {
@@ -610,50 +704,4 @@ function comms.capi_packet()
return public return public
end end
-- convert rtu_t to RTU unit type
---@param type rtu_t
---@return RTU_UNIT_TYPES|nil
function comms.rtu_t_to_unit_type(type)
if type == rtu_t.redstone then
return RTU_UNIT_TYPES.REDSTONE
elseif type == rtu_t.boiler then
return RTU_UNIT_TYPES.BOILER
elseif type == rtu_t.boiler_valve then
return RTU_UNIT_TYPES.BOILER_VALVE
elseif type == rtu_t.turbine then
return RTU_UNIT_TYPES.TURBINE
elseif type == rtu_t.turbine_valve then
return RTU_UNIT_TYPES.TURBINE_VALVE
elseif type == rtu_t.energy_machine then
return RTU_UNIT_TYPES.EMACHINE
elseif type == rtu_t.induction_matrix then
return RTU_UNIT_TYPES.IMATRIX
end
return nil
end
-- convert RTU unit type to rtu_t
---@param utype RTU_UNIT_TYPES
---@return rtu_t|nil
function comms.advert_type_to_rtu_t(utype)
if utype == RTU_UNIT_TYPES.REDSTONE then
return rtu_t.redstone
elseif utype == RTU_UNIT_TYPES.BOILER then
return rtu_t.boiler
elseif utype == RTU_UNIT_TYPES.BOILER_VALVE then
return rtu_t.boiler_valve
elseif utype == RTU_UNIT_TYPES.TURBINE then
return rtu_t.turbine
elseif utype == RTU_UNIT_TYPES.TURBINE_VALVE then
return rtu_t.turbine_valve
elseif utype == RTU_UNIT_TYPES.EMACHINE then
return rtu_t.energy_machine
elseif utype == RTU_UNIT_TYPES.IMATRIX then
return rtu_t.induction_matrix
end
return nil
end
return comms return comms

View File

@@ -0,0 +1,83 @@
--
-- System and Safety Constants
--
local constants = {}
--#region Reactor Protection System (on the PLC) Limits
local rps = {}
rps.MAX_DAMAGE_PERCENT = 90 -- damage >= 90%
rps.MAX_DAMAGE_TEMPERATURE = 1200 -- temp >= 1200K
rps.MIN_COOLANT_FILL = 0.10 -- fill < 10%
rps.MAX_WASTE_FILL = 0.95 -- fill > 95%
rps.MAX_HEATED_COLLANT_FILL = 0.95 -- fill > 95%
rps.NO_FUEL_FILL = 0.0 -- fill <= 0%
constants.RPS_LIMITS = rps
--#endregion
--#region Annunciator Limits
local annunc = {}
annunc.RCSFlowLow_H2O = -3.2 -- flow < -3.2 mB/s
annunc.RCSFlowLow_NA = -2.0 -- flow < -2.0 mB/s
annunc.CoolantLevelLow = 0.4 -- fill < 40%
annunc.ReactorTempHigh = 1000 -- temp > 1000K
annunc.ReactorHighDeltaT = 50 -- rate > 50K/s
annunc.FuelLevelLow = 0.05 -- fill <= 5%
annunc.WasteLevelHigh = 0.80 -- fill >= 80%
annunc.WaterLevelLow = 0.4 -- fill < 40%
annunc.SteamFeedMismatch = 10 -- ±10mB difference between total coolant flow and total steam input rate
annunc.SFM_MaxSteamDT_H20 = 2.0 -- flow > 2.0 mB/s
annunc.SFM_MinWaterDT_H20 = -3.0 -- flow < -3.0 mB/s
annunc.SFM_MaxSteamDT_NA = 2.0 -- flow > 2.0 mB/s
annunc.SFM_MinWaterDT_NA = -2.0 -- flow < -2.0 mB/s
annunc.RadiationWarning = 0.00001 -- 10 uSv/h
constants.ANNUNCIATOR_LIMITS = annunc
--#endregion
--#region Supervisor Alarm Limits
local alarms = {}
-- unit alarms
alarms.HIGH_TEMP = 1150 -- temp >= 1150K
alarms.HIGH_WASTE = 0.85 -- fill > 85%
alarms.HIGH_RADIATION = 0.00005 -- 50 uSv/h, not yet damaging but this isn't good
-- facility alarms
alarms.CHARGE_HIGH = 1.0 -- once at or above 100% charge
alarms.CHARGE_RE_ENABLE = 0.95 -- once below 95% charge
alarms.FAC_HIGH_RAD = 0.00001 -- 10 uSv/h
constants.ALARM_LIMITS = alarms
--#endregion
--#region Supervisor Constants
-- milliseconds until turbine flow is assumed to be stable enough to enable coolant checks
constants.FLOW_STABILITY_DELAY_MS = 15000
-- Notes on Radiation
-- - background radiation 0.0000001 Sv/h (99.99 nSv/h)
-- - "green tint" radiation 0.00001 Sv/h (10 uSv/h)
-- - damaging radiation 0.00006 Sv/h (60 uSv/h)
constants.LOW_RADIATION = 0.00001
constants.HAZARD_RADIATION = 0.00006
constants.HIGH_RADIATION = 0.001
constants.VERY_HIGH_RADIATION = 0.1
constants.SEVERE_RADIATION = 8.0
constants.EXTREME_RADIATION = 100.0
--#endregion
return constants

46
scada-common/crash.lua Normal file
View File

@@ -0,0 +1,46 @@
--
-- Crash Handler
--
local comms = require("scada-common.comms")
local log = require("scada-common.log")
local util = require("scada-common.util")
local crash = {}
local app = "unknown"
local ver = "v0.0.0"
local err = ""
-- set crash environment
---@param application string app name
---@param version string version
function crash.set_env(application, version)
app = application
ver = version
end
-- handle a crash error
---@param error string error message
function crash.handler(error)
err = error
log.info("=====> FATAL SOFTWARE FAULT <=====")
log.fatal(error)
log.info("----------------------------------")
log.info(util.c("RUNTIME: ", _HOST))
log.info(util.c("LUA VERSION: ", _VERSION))
log.info(util.c("APPLICATION: ", app))
log.info(util.c("FIRMWARE VERSION: ", ver))
log.info(util.c("COMMS VERSION: ", comms.version))
log.info("----------------------------------")
log.info(debug.traceback("--- begin debug trace ---", 1))
log.info("--- end debug trace ---")
end
-- final error print on failed xpcall, app exits here
function crash.exit()
util.println("fatal error occured in main application:")
error(err, 0)
end
return crash

View File

@@ -3,18 +3,18 @@
-- --
local aes128 = require("lockbox.cipher.aes128") local aes128 = require("lockbox.cipher.aes128")
local ctr_mode = require("lockbox.cipher.mode.ctr"); local ctr_mode = require("lockbox.cipher.mode.ctr")
local sha1 = require("lockbox.digest.sha1"); local sha1 = require("lockbox.digest.sha1")
local sha2_224 = require("lockbox.digest.sha2_224"); local sha2_224 = require("lockbox.digest.sha2_224")
local sha2_256 = require("lockbox.digest.sha2_256"); local sha2_256 = require("lockbox.digest.sha2_256")
local pbkdf2 = require("lockbox.kdf.pbkdf2") local pbkdf2 = require("lockbox.kdf.pbkdf2")
local hmac = require("lockbox.mac.hmac") local hmac = require("lockbox.mac.hmac")
local zero_pad = require("lockbox.padding.zero"); local zero_pad = require("lockbox.padding.zero")
local stream = require("lockbox.util.stream") local stream = require("lockbox.util.stream")
local array = require("lockbox.util.array") local array = require("lockbox.util.array")
local log = require("scada-common.log") local log = require("scada-common.log")
local util = require("scada-common.util") local util = require("scada-common.util")
local crypto = {} local crypto = {}
@@ -52,13 +52,13 @@ function crypto.init(password, server_port)
c_eng.cipher = ctr_mode.Cipher() c_eng.cipher = ctr_mode.Cipher()
c_eng.cipher.setKey(c_eng.key) c_eng.cipher.setKey(c_eng.key)
c_eng.cipher.setBlockCipher(aes128) c_eng.cipher.setBlockCipher(aes128)
c_eng.cipher.setPadding(zero_pad); c_eng.cipher.setPadding(zero_pad)
-- initialize decipher -- initialize decipher
c_eng.decipher = ctr_mode.Decipher() c_eng.decipher = ctr_mode.Decipher()
c_eng.decipher.setKey(c_eng.key) c_eng.decipher.setKey(c_eng.key)
c_eng.decipher.setBlockCipher(aes128) c_eng.decipher.setBlockCipher(aes128)
c_eng.decipher.setPadding(zero_pad); c_eng.decipher.setPadding(zero_pad)
-- initialize HMAC -- initialize HMAC
c_eng.hmac = hmac() c_eng.hmac = hmac()
@@ -70,8 +70,9 @@ function crypto.init(password, server_port)
end end
-- encrypt plaintext -- encrypt plaintext
---@nodiscard
---@param plaintext string ---@param plaintext string
---@return string initial_value, string ciphertext ---@return table initial_value, string ciphertext
function crypto.encrypt(plaintext) function crypto.encrypt(plaintext)
local start = util.time() local start = util.time()
@@ -113,6 +114,7 @@ function crypto.encrypt(plaintext)
end end
-- decrypt ciphertext -- decrypt ciphertext
---@nodiscard
---@param iv string CTR initial value ---@param iv string CTR initial value
---@param ciphertext string ciphertext hex ---@param ciphertext string ciphertext hex
---@return string plaintext ---@return string plaintext
@@ -135,6 +137,7 @@ function crypto.decrypt(iv, ciphertext)
end end
-- generate HMAC of message -- generate HMAC of message
---@nodiscard
---@param message_hex string initial value concatenated with ciphertext ---@param message_hex string initial value concatenated with ciphertext
function crypto.hmac(message_hex) function crypto.hmac(message_hex)
local start = util.time() local start = util.time()
@@ -201,11 +204,12 @@ function crypto.secure_modem(modem)
end end
-- parse in a modem message as a network packet -- parse in a modem message as a network packet
---@param side string ---@nodiscard
---@param sender integer ---@param side string modem side
---@param reply_to integer ---@param sender integer sender port
---@param reply_to integer reply port
---@param message any encrypted packet sent with secure_modem.transmit ---@param message any encrypted packet sent with secure_modem.transmit
---@param distance integer ---@param distance integer transmission distance
---@return string side, integer sender, integer reply_to, any plaintext_message, integer distance ---@return string side, integer sender, integer reply_to, any plaintext_message, integer distance
function public.receive(side, sender, reply_to, message, distance) function public.receive(side, sender, reply_to, message, distance)
local body = "" local body = ""
@@ -222,7 +226,7 @@ function crypto.secure_modem(modem)
if hmac == computed_hmac then if hmac == computed_hmac then
-- message intact -- message intact
local plaintext = crypto.decrypt(iv, ciphertext) local plaintext = crypto.decrypt(iv, ciphertext)
body = textutils.deserialize(plaintext) body = textutils.unserialize(plaintext)
if body == nil then if body == nil then
-- failed decryption -- failed decryption

View File

@@ -16,9 +16,9 @@ local MODE = {
log.MODE = MODE log.MODE = MODE
-- whether to log debug messages or not -- whether to log debug messages or not
local LOG_DEBUG = true local LOG_DEBUG = false
local _log_sys = { local log_sys = {
path = "/log.txt", path = "/log.txt",
mode = MODE.APPEND, mode = MODE.APPEND,
file = nil, file = nil,
@@ -33,60 +33,63 @@ local free_space = fs.getFreeSpace
---@param write_mode MODE ---@param write_mode MODE
---@param dmesg_redirect? table terminal/window to direct dmesg to ---@param dmesg_redirect? table terminal/window to direct dmesg to
function log.init(path, write_mode, dmesg_redirect) function log.init(path, write_mode, dmesg_redirect)
_log_sys.path = path log_sys.path = path
_log_sys.mode = write_mode log_sys.mode = write_mode
if _log_sys.mode == MODE.APPEND then if log_sys.mode == MODE.APPEND then
_log_sys.file = fs.open(path, "a") log_sys.file = fs.open(path, "a")
else else
_log_sys.file = fs.open(path, "w") log_sys.file = fs.open(path, "w")
end end
if dmesg_redirect then if dmesg_redirect then
_log_sys.dmesg_out = dmesg_redirect log_sys.dmesg_out = dmesg_redirect
else else
_log_sys.dmesg_out = term.current() log_sys.dmesg_out = term.current()
end end
end end
-- direct dmesg output to a monitor/window -- direct dmesg output to a monitor/window
---@param window table window or terminal reference ---@param window table window or terminal reference
function log.direct_dmesg(window) function log.direct_dmesg(window) log_sys.dmesg_out = window end
_log_sys.dmesg_out = window
end
-- private log write function -- private log write function
---@param msg string ---@param msg string
local function _log(msg) local function _log(msg)
local out_of_space = false
local time_stamp = os.date("[%c] ") local time_stamp = os.date("[%c] ")
local stamped = time_stamp .. util.strval(msg) local stamped = time_stamp .. util.strval(msg)
-- attempt to write log -- attempt to write log
local status, result = pcall(function () local status, result = pcall(function ()
_log_sys.file.writeLine(stamped) log_sys.file.writeLine(stamped)
_log_sys.file.flush() log_sys.file.flush()
end) end)
-- if we don't have space, we need to create a new log file -- if we don't have space, we need to create a new log file
if not status then if (not status) and (result ~= nil) then
if result == "Out of space" then out_of_space = string.find(result, "Out of space") ~= nil
if out_of_space then
-- will delete log file -- will delete log file
elseif result ~= nil then else
util.println("unknown error writing to logfile: " .. result) util.println("unknown error writing to logfile: " .. result)
end end
end end
if (result == "Out of space") or (free_space(_log_sys.path) < 100) then if out_of_space or (free_space(log_sys.path) < 100) then
-- delete the old log file and open a new one -- delete the old log file before opening a new one
_log_sys.file.close() log_sys.file.close()
fs.delete(_log_sys.path) fs.delete(log_sys.path)
log.init(_log_sys.path, _log_sys.mode)
-- re-init logger and pass dmesg_out so that it doesn't change
log.init(log_sys.path, log_sys.mode, log_sys.dmesg_out)
-- leave a message -- leave a message
_log_sys.file.writeLine(time_stamp .. "recycled log file") log_sys.file.writeLine(time_stamp .. "recycled log file")
_log_sys.file.writeLine(stamped) log_sys.file.writeLine(stamped)
_log_sys.file.flush() log_sys.file.flush()
end end
end end
@@ -104,74 +107,41 @@ function log.dmesg(msg, tag, tag_color)
tag = util.strval(tag) tag = util.strval(tag)
local t_stamp = string.format("%12.2f", os.clock()) local t_stamp = string.format("%12.2f", os.clock())
local out = _log_sys.dmesg_out local out = log_sys.dmesg_out
local out_w, out_h = out.getSize()
local lines = { msg } if out ~= nil then
local out_w, out_h = out.getSize()
-- wrap if needed local lines = { msg }
if string.len(msg) > out_w then
local remaining = true
local s_start = 1
local s_end = out_w
local i = 1
lines = {} -- wrap if needed
if string.len(msg) > out_w then
local remaining = true
local s_start = 1
local s_end = out_w
local i = 1
while remaining do lines = {}
local line = string.sub(msg, s_start, s_end)
if line == "" then while remaining do
remaining = false local line = string.sub(msg, s_start, s_end)
else
lines[i] = line
s_start = s_end + 1 if line == "" then
s_end = s_end + out_w remaining = false
i = i + 1 else
lines[i] = line
s_start = s_end + 1
s_end = s_end + out_w
i = i + 1
end
end end
end end
end
-- start output with tag and time, assuming we have enough width for this to be on one line -- start output with tag and time, assuming we have enough width for this to be on one line
local cur_x, cur_y = out.getCursorPos() local cur_x, cur_y = out.getCursorPos()
if cur_x > 1 then if cur_x > 1 then
if cur_y == out_h then
out.scroll(1)
out.setCursorPos(1, cur_y)
else
out.setCursorPos(1, cur_y + 1)
end
end
-- colored time
local initial_color = out.getTextColor()
out.setTextColor(colors.white)
out.write("[")
out.setTextColor(colors.lightGray)
out.write(t_stamp)
ts_coord.x2, ts_coord.y = out.getCursorPos()
ts_coord.x2 = ts_coord.x2 - 1
out.setTextColor(colors.white)
out.write("] ")
-- print optionally colored tag
if tag ~= "" then
out.write("[")
if tag_color then out.setTextColor(tag_color) end
out.write(tag)
out.setTextColor(colors.white)
out.write("] ")
end
out.setTextColor(initial_color)
-- output message
for i = 1, #lines do
cur_x, cur_y = out.getCursorPos()
if i > 1 and cur_x > 1 then
if cur_y == out_h then if cur_y == out_h then
out.scroll(1) out.scroll(1)
out.setCursorPos(1, cur_y) out.setCursorPos(1, cur_y)
@@ -180,15 +150,52 @@ function log.dmesg(msg, tag, tag_color)
end end
end end
out.write(lines[i]) -- colored time
end local initial_color = out.getTextColor()
out.setTextColor(colors.white)
out.write("[")
out.setTextColor(colors.lightGray)
out.write(t_stamp)
ts_coord.x2, ts_coord.y = out.getCursorPos()
ts_coord.x2 = ts_coord.x2 - 1
out.setTextColor(colors.white)
out.write("] ")
_log(util.c("[", t_stamp, "] [", tag, "] ", msg)) -- print optionally colored tag
if tag ~= "" then
out.write("[")
if tag_color then out.setTextColor(tag_color) end
out.write(tag)
out.setTextColor(colors.white)
out.write("] ")
end
out.setTextColor(initial_color)
-- output message
for i = 1, #lines do
cur_x, cur_y = out.getCursorPos()
if i > 1 and cur_x > 1 then
if cur_y == out_h then
out.scroll(1)
out.setCursorPos(1, cur_y)
else
out.setCursorPos(1, cur_y + 1)
end
end
out.write(lines[i])
end
_log(util.c("[", t_stamp, "] [", tag, "] ", msg))
end
return ts_coord return ts_coord
end end
-- print a dmesg message, but then show remaining seconds instead of timestamp -- print a dmesg message, but then show remaining seconds instead of timestamp
---@nodiscard
---@param msg string message ---@param msg string message
---@param tag? string log tag ---@param tag? string log tag
---@param tag_color? integer log tag color ---@param tag_color? integer log tag color
@@ -196,55 +203,59 @@ end
function log.dmesg_working(msg, tag, tag_color) function log.dmesg_working(msg, tag, tag_color)
local ts_coord = log.dmesg(msg, tag, tag_color) local ts_coord = log.dmesg(msg, tag, tag_color)
local out = _log_sys.dmesg_out local out = log_sys.dmesg_out
local width = (ts_coord.x2 - ts_coord.x1) + 1 local width = (ts_coord.x2 - ts_coord.x1) + 1
local initial_color = out.getTextColor() if out ~= nil then
local initial_color = out.getTextColor()
local counter = 0 local counter = 0
local function update(sec_remaining) local function update(sec_remaining)
local time = util.sprintf("%ds", sec_remaining) local time = util.sprintf("%ds", sec_remaining)
local available = width - (string.len(time) + 2) local available = width - (string.len(time) + 2)
local progress = "" local progress = ""
out.setCursorPos(ts_coord.x1, ts_coord.y) out.setCursorPos(ts_coord.x1, ts_coord.y)
out.write(" ") out.write(" ")
if counter % 4 == 0 then if counter % 4 == 0 then
progress = "|" progress = "|"
elseif counter % 4 == 1 then elseif counter % 4 == 1 then
progress = "/" progress = "/"
elseif counter % 4 == 2 then elseif counter % 4 == 2 then
progress = "-" progress = "-"
elseif counter % 4 == 3 then elseif counter % 4 == 3 then
progress = "\\" progress = "\\"
end
out.setTextColor(colors.blue)
out.write(progress)
out.setTextColor(colors.lightGray)
out.write(util.spaces(available) .. time)
out.setTextColor(initial_color)
counter = counter + 1
end end
out.setTextColor(colors.blue) local function done(ok)
out.write(progress) out.setCursorPos(ts_coord.x1, ts_coord.y)
out.setTextColor(colors.lightGray)
out.write(util.spaces(available) .. time)
out.setTextColor(initial_color)
counter = counter + 1 if ok or ok == nil then
end out.setTextColor(colors.green)
out.write(util.pad("DONE", width))
else
out.setTextColor(colors.red)
out.write(util.pad("FAIL", width))
end
local function done(ok) out.setTextColor(initial_color)
out.setCursorPos(ts_coord.x1, ts_coord.y)
if ok or ok == nil then
out.setTextColor(colors.green)
out.write(util.pad("DONE", width))
else
out.setTextColor(colors.red)
out.write(util.pad("FAIL", width))
end end
out.setTextColor(initial_color) return update, done
else
return function () end, function () end
end end
return update, done
end end
-- log debug messages -- log debug messages

View File

@@ -4,7 +4,7 @@
local mqueue = {} local mqueue = {}
---@alias TYPE integer ---@enum MQ_TYPE
local TYPE = { local TYPE = {
COMMAND = 0, COMMAND = 0,
DATA = 1, DATA = 1,
@@ -14,6 +14,7 @@ local TYPE = {
mqueue.TYPE = TYPE mqueue.TYPE = TYPE
-- create a new message queue -- create a new message queue
---@nodiscard
function mqueue.new() function mqueue.new()
local queue = {} local queue = {}
@@ -21,7 +22,7 @@ function mqueue.new()
local remove = table.remove local remove = table.remove
---@class queue_item ---@class queue_item
---@field qtype TYPE ---@field qtype MQ_TYPE
---@field message any ---@field message any
---@class queue_data ---@class queue_data
@@ -35,15 +36,18 @@ function mqueue.new()
function public.length() return #queue end function public.length() return #queue end
-- check if queue is empty -- check if queue is empty
---@nodiscard
---@return boolean is_empty ---@return boolean is_empty
function public.empty() return #queue == 0 end function public.empty() return #queue == 0 end
-- check if queue has contents -- check if queue has contents
---@nodiscard
---@return boolean has_contents
function public.ready() return #queue ~= 0 end function public.ready() return #queue ~= 0 end
-- push a new item onto the queue -- push a new item onto the queue
---@param qtype TYPE ---@param qtype MQ_TYPE
---@param message string ---@param message any
local function _push(qtype, message) local function _push(qtype, message)
insert(queue, { qtype = qtype, message = message }) insert(queue, { qtype = qtype, message = message })
end end
@@ -62,12 +66,13 @@ function mqueue.new()
end end
-- push a packet onto the queue -- push a packet onto the queue
---@param packet scada_packet|modbus_packet|rplc_packet|crdn_packet|capi_packet ---@param packet packet|frame
function public.push_packet(packet) function public.push_packet(packet)
_push(TYPE.PACKET, packet) _push(TYPE.PACKET, packet)
end end
-- get an item off the queue -- get an item off the queue
---@nodiscard
---@return queue_item|nil ---@return queue_item|nil
function public.pop() function public.pop()
if #queue > 0 then if #queue > 0 then

View File

@@ -10,7 +10,13 @@ local ppm = {}
local ACCESS_FAULT = nil ---@type nil local ACCESS_FAULT = nil ---@type nil
local UNDEFINED_FIELD = "undefined field"
local VIRTUAL_DEVICE_TYPE = "ppm_vdev"
ppm.ACCESS_FAULT = ACCESS_FAULT ppm.ACCESS_FAULT = ACCESS_FAULT
ppm.UNDEFINED_FIELD = UNDEFINED_FIELD
ppm.VIRTUAL_DEVICE_TYPE = VIRTUAL_DEVICE_TYPE
---------------------------- ----------------------------
-- PRIVATE DATA/FUNCTIONS -- -- PRIVATE DATA/FUNCTIONS --
@@ -18,8 +24,9 @@ ppm.ACCESS_FAULT = ACCESS_FAULT
local REPORT_FREQUENCY = 20 -- log every 20 faults per function local REPORT_FREQUENCY = 20 -- log every 20 faults per function
local _ppm_sys = { local ppm_sys = {
mounts = {}, mounts = {},
next_vid = 0,
auto_cf = false, auto_cf = false,
faulted = false, faulted = false,
last_fault = "", last_fault = "",
@@ -27,11 +34,9 @@ local _ppm_sys = {
mute = false mute = false
} }
-- wrap peripheral calls with lua protected call as we don't want a disconnect to crash a program -- wrap peripheral calls with lua protected call as we don't want a disconnect to crash a program<br>
--- -- also provides peripheral-specific fault checks (auto-clear fault defaults to true)<br>
---also provides peripheral-specific fault checks (auto-clear fault defaults to true) -- assumes iface is a valid peripheral
---
---assumes iface is a valid peripheral
---@param iface string CC peripheral interface ---@param iface string CC peripheral interface
local function peri_init(iface) local function peri_init(iface)
local self = { local self = {
@@ -39,10 +44,15 @@ local function peri_init(iface)
last_fault = "", last_fault = "",
fault_counts = {}, fault_counts = {},
auto_cf = true, auto_cf = true,
type = peripheral.getType(iface), type = VIRTUAL_DEVICE_TYPE,
device = peripheral.wrap(iface) device = {}
} }
if iface ~= "__virtual__" then
self.type = peripheral.getType(iface)
self.device = peripheral.wrap(iface)
end
-- initialization process (re-map) -- initialization process (re-map)
for key, func in pairs(self.device) do for key, func in pairs(self.device) do
@@ -56,7 +66,7 @@ local function peri_init(iface)
if status then if status then
-- auto fault clear -- auto fault clear
if self.auto_cf then self.faulted = false end if self.auto_cf then self.faulted = false end
if _ppm_sys.auto_cf then _ppm_sys.faulted = false end if ppm_sys.auto_cf then ppm_sys.faulted = false end
self.fault_counts[key] = 0 self.fault_counts[key] = 0
@@ -68,10 +78,10 @@ local function peri_init(iface)
self.faulted = true self.faulted = true
self.last_fault = result self.last_fault = result
_ppm_sys.faulted = true ppm_sys.faulted = true
_ppm_sys.last_fault = result ppm_sys.last_fault = result
if not _ppm_sys.mute and (self.fault_counts[key] % REPORT_FREQUENCY == 0) then if not ppm_sys.mute and (self.fault_counts[key] % REPORT_FREQUENCY == 0) then
local count_str = "" local count_str = ""
if self.fault_counts[key] > 0 then if self.fault_counts[key] > 0 then
count_str = " [" .. self.fault_counts[key] .. " total faults]" count_str = " [" .. self.fault_counts[key] .. " total faults]"
@@ -83,7 +93,7 @@ local function peri_init(iface)
self.fault_counts[key] = self.fault_counts[key] + 1 self.fault_counts[key] = self.fault_counts[key] + 1
if result == "Terminated" then if result == "Terminated" then
_ppm_sys.terminate = true ppm_sys.terminate = true
end end
return ACCESS_FAULT return ACCESS_FAULT
@@ -110,6 +120,40 @@ local function peri_init(iface)
self.device.__p_enable_afc = enable_afc self.device.__p_enable_afc = enable_afc
self.device.__p_disable_afc = disable_afc self.device.__p_disable_afc = disable_afc
-- add default index function to catch undefined indicies
local mt = {
__index = function (_, key)
-- this will continuously be counting calls here as faults
-- unlike other functions, faults here can't be cleared as it is just not defined
if self.fault_counts[key] == nil then
self.fault_counts[key] = 0
end
-- function failed
self.faulted = true
self.last_fault = UNDEFINED_FIELD
ppm_sys.faulted = true
ppm_sys.last_fault = UNDEFINED_FIELD
if not ppm_sys.mute and (self.fault_counts[key] % REPORT_FREQUENCY == 0) then
local count_str = ""
if self.fault_counts[key] > 0 then
count_str = " [" .. self.fault_counts[key] .. " total calls]"
end
log.error(util.c("PPM: caught undefined function ", key, "()", count_str))
end
self.fault_counts[key] = self.fault_counts[key] + 1
return (function () return ACCESS_FAULT end)
end
}
setmetatable(self.device, mt)
return { return {
type = self.type, type = self.type,
dev = self.device dev = self.device
@@ -123,48 +167,35 @@ end
-- REPORTING -- -- REPORTING --
-- silence error prints -- silence error prints
function ppm.disable_reporting() function ppm.disable_reporting() ppm_sys.mute = true end
_ppm_sys.mute = true
end
-- allow error prints -- allow error prints
function ppm.enable_reporting() function ppm.enable_reporting() ppm_sys.mute = false end
_ppm_sys.mute = false
end
-- FAULT MEMORY -- -- FAULT MEMORY --
-- enable automatically clearing fault flag -- enable automatically clearing fault flag
function ppm.enable_afc() function ppm.enable_afc() ppm_sys.auto_cf = true end
_ppm_sys.auto_cf = true
end
-- disable automatically clearing fault flag -- disable automatically clearing fault flag
function ppm.disable_afc() function ppm.disable_afc() ppm_sys.auto_cf = false end
_ppm_sys.auto_cf = false
end
-- clear fault flag -- clear fault flag
function ppm.clear_fault() function ppm.clear_fault() ppm_sys.faulted = false end
_ppm_sys.faulted = false
end
-- check fault flag -- check fault flag
function ppm.is_faulted() ---@nodiscard
return _ppm_sys.faulted function ppm.is_faulted() return ppm_sys.faulted end
end
-- get the last fault message -- get the last fault message
function ppm.get_last_fault() ---@nodiscard
return _ppm_sys.last_fault function ppm.get_last_fault() return ppm_sys.last_fault end
end
-- TERMINATION -- -- TERMINATION --
-- if a caught error was a termination request -- if a caught error was a termination request
function ppm.should_terminate() ---@nodiscard
return _ppm_sys.terminate function ppm.should_terminate() return ppm_sys.terminate end
end
-- MOUNTING -- -- MOUNTING --
@@ -172,12 +203,12 @@ end
function ppm.mount_all() function ppm.mount_all()
local ifaces = peripheral.getNames() local ifaces = peripheral.getNames()
_ppm_sys.mounts = {} ppm_sys.mounts = {}
for i = 1, #ifaces do for i = 1, #ifaces do
_ppm_sys.mounts[ifaces[i]] = peri_init(ifaces[i]) ppm_sys.mounts[ifaces[i]] = peri_init(ifaces[i])
log.info(util.c("PPM: found a ", _ppm_sys.mounts[ifaces[i]].type, " (", ifaces[i], ")")) log.info(util.c("PPM: found a ", ppm_sys.mounts[ifaces[i]].type, " (", ifaces[i], ")"))
end end
if #ifaces == 0 then if #ifaces == 0 then
@@ -186,6 +217,7 @@ function ppm.mount_all()
end end
-- mount a particular device -- mount a particular device
---@nodiscard
---@param iface string CC peripheral interface ---@param iface string CC peripheral interface
---@return string|nil type, table|nil device ---@return string|nil type, table|nil device
function ppm.mount(iface) function ppm.mount(iface)
@@ -195,10 +227,10 @@ function ppm.mount(iface)
for i = 1, #ifaces do for i = 1, #ifaces do
if iface == ifaces[i] then if iface == ifaces[i] then
_ppm_sys.mounts[iface] = peri_init(iface) ppm_sys.mounts[iface] = peri_init(iface)
pm_type = _ppm_sys.mounts[iface].type pm_type = ppm_sys.mounts[iface].type
pm_dev = _ppm_sys.mounts[iface].dev pm_dev = ppm_sys.mounts[iface].dev
log.info(util.c("PPM: mount(", iface, ") -> found a ", pm_type)) log.info(util.c("PPM: mount(", iface, ") -> found a ", pm_type))
break break
@@ -208,7 +240,36 @@ function ppm.mount(iface)
return pm_type, pm_dev return pm_type, pm_dev
end end
-- mount a virtual, placeholder device (specifically designed for RTU startup with missing devices)
---@nodiscard
---@return string type, table device
function ppm.mount_virtual()
local iface = "ppm_vdev_" .. ppm_sys.next_vid
ppm_sys.mounts[iface] = peri_init("__virtual__")
ppm_sys.next_vid = ppm_sys.next_vid + 1
log.info(util.c("PPM: mount_virtual() -> allocated new virtual device ", iface))
return ppm_sys.mounts[iface].type, ppm_sys.mounts[iface].dev
end
-- manually unmount a peripheral from the PPM
---@param device table device table
function ppm.unmount(device)
if device then
for side, data in pairs(ppm_sys.mounts) do
if data.dev == device then
log.warning(util.c("PPM: manually unmounted ", data.type, " mounted to ", side))
ppm_sys.mounts[side] = nil
break
end
end
end
end
-- handle peripheral_detach event -- handle peripheral_detach event
---@nodiscard
---@param iface string CC peripheral interface ---@param iface string CC peripheral interface
---@return string|nil type, table|nil device ---@return string|nil type, table|nil device
function ppm.handle_unmount(iface) function ppm.handle_unmount(iface)
@@ -216,7 +277,7 @@ function ppm.handle_unmount(iface)
local pm_type = nil local pm_type = nil
-- what got disconnected? -- what got disconnected?
local lost_dev = _ppm_sys.mounts[iface] local lost_dev = ppm_sys.mounts[iface]
if lost_dev then if lost_dev then
pm_type = lost_dev.type pm_type = lost_dev.type
@@ -227,48 +288,69 @@ function ppm.handle_unmount(iface)
log.error(util.c("PPM: lost device unknown to the PPM mounted to ", iface)) log.error(util.c("PPM: lost device unknown to the PPM mounted to ", iface))
end end
ppm_sys.mounts[iface] = nil
return pm_type, pm_dev return pm_type, pm_dev
end end
-- GENERAL ACCESSORS -- -- GENERAL ACCESSORS --
-- list all available peripherals -- list all available peripherals
---@nodiscard
---@return table names ---@return table names
function ppm.list_avail() function ppm.list_avail()
return peripheral.getNames() return peripheral.getNames()
end end
-- list mounted peripherals -- list mounted peripherals
---@nodiscard
---@return table mounts ---@return table mounts
function ppm.list_mounts() function ppm.list_mounts()
return _ppm_sys.mounts return ppm_sys.mounts
end
-- get a mounted peripheral side/interface by device table
---@nodiscard
---@param device table device table
---@return string|nil iface CC peripheral interface
function ppm.get_iface(device)
if device then
for side, data in pairs(ppm_sys.mounts) do
if data.dev == device then return side end
end
end
return nil
end end
-- get a mounted peripheral by side/interface -- get a mounted peripheral by side/interface
---@nodiscard
---@param iface string CC peripheral interface ---@param iface string CC peripheral interface
---@return table|nil device function table ---@return table|nil device function table
function ppm.get_periph(iface) function ppm.get_periph(iface)
if _ppm_sys.mounts[iface] then if ppm_sys.mounts[iface] then
return _ppm_sys.mounts[iface].dev return ppm_sys.mounts[iface].dev
else return nil end else return nil end
end end
-- get a mounted peripheral type by side/interface -- get a mounted peripheral type by side/interface
---@nodiscard
---@param iface string CC peripheral interface ---@param iface string CC peripheral interface
---@return string|nil type ---@return string|nil type
function ppm.get_type(iface) function ppm.get_type(iface)
if _ppm_sys.mounts[iface] then if ppm_sys.mounts[iface] then
return _ppm_sys.mounts[iface].type return ppm_sys.mounts[iface].type
else return nil end else return nil end
end end
-- get all mounted peripherals by type -- get all mounted peripherals by type
---@nodiscard
---@param name string type name ---@param name string type name
---@return table devices device function tables ---@return table devices device function tables
function ppm.get_all_devices(name) function ppm.get_all_devices(name)
local devices = {} local devices = {}
for _, data in pairs(_ppm_sys.mounts) do for _, data in pairs(ppm_sys.mounts) do
if data.type == name then if data.type == name then
table.insert(devices, data.dev) table.insert(devices, data.dev)
end end
@@ -278,12 +360,13 @@ function ppm.get_all_devices(name)
end end
-- get a mounted peripheral by type (if multiple, returns the first) -- get a mounted peripheral by type (if multiple, returns the first)
---@nodiscard
---@param name string type name ---@param name string type name
---@return table|nil device function table ---@return table|nil device function table
function ppm.get_device(name) function ppm.get_device(name)
local device = nil local device = nil
for side, data in pairs(_ppm_sys.mounts) do for _, data in pairs(ppm_sys.mounts) do
if data.type == name then if data.type == name then
device = data.dev device = data.dev
break break
@@ -296,20 +379,21 @@ end
-- SPECIFIC DEVICE ACCESSORS -- -- SPECIFIC DEVICE ACCESSORS --
-- get the fission reactor (if multiple, returns the first) -- get the fission reactor (if multiple, returns the first)
---@nodiscard
---@return table|nil reactor function table ---@return table|nil reactor function table
function ppm.get_fission_reactor() function ppm.get_fission_reactor()
return ppm.get_device("fissionReactor") or ppm.get_device("fissionReactorLogicAdapter") return ppm.get_device("fissionReactorLogicAdapter")
end end
-- get the wireless modem (if multiple, returns the first) -- get the wireless modem (if multiple, returns the first)<br>
--
-- if this is in a CraftOS emulated environment, wired modems will be used instead -- if this is in a CraftOS emulated environment, wired modems will be used instead
---@nodiscard
---@return table|nil modem function table ---@return table|nil modem function table
function ppm.get_wireless_modem() function ppm.get_wireless_modem()
local w_modem = nil local w_modem = nil
local emulated_env = periphemu ~= nil local emulated_env = periphemu ~= nil
for _, device in pairs(_ppm_sys.mounts) do for _, device in pairs(ppm_sys.mounts) do
if device.type == "modem" and (emulated_env or device.dev.isWireless()) then if device.type == "modem" and (emulated_env or device.dev.isWireless()) then
w_modem = device.dev w_modem = device.dev
break break
@@ -320,11 +404,12 @@ function ppm.get_wireless_modem()
end end
-- list all connected monitors -- list all connected monitors
---@nodiscard
---@return table monitors ---@return table monitors
function ppm.get_monitor_list() function ppm.get_monitor_list()
local list = {} local list = {}
for iface, device in pairs(_ppm_sys.mounts) do for iface, device in pairs(ppm_sys.mounts) do
if device.type == "monitor" then if device.type == "monitor" then
list[iface] = device list[iface] = device
end end

View File

@@ -5,6 +5,7 @@
local psil = {} local psil = {}
-- instantiate a new PSI layer -- instantiate a new PSI layer
---@nodiscard
function psil.create() function psil.create()
local self = { local self = {
ic = {} ic = {}
@@ -19,8 +20,7 @@ function psil.create()
---@class psil ---@class psil
local public = {} local public = {}
-- subscribe to a data object in the interconnect -- subscribe to a data object in the interconnect<br>
--
-- will call func() right away if a value is already avaliable -- will call func() right away if a value is already avaliable
---@param key string data key ---@param key string data key
---@param func function function to call on change ---@param func function function to call on change

View File

@@ -4,26 +4,28 @@
local util = require("scada-common.util") local util = require("scada-common.util")
---@class rsio
local rsio = {} local rsio = {}
---------------------- ----------------------
-- RS I/O CONSTANTS -- -- RS I/O CONSTANTS --
---------------------- ----------------------
---@alias IO_LVL integer ---@enum IO_LVL I/O logic level
local IO_LVL = { local IO_LVL = {
DISCONNECT = -1, -- use for RTU session to indicate this RTU is not connected to this port
LOW = 0, LOW = 0,
HIGH = 1, HIGH = 1,
DISCONNECT = -1 -- use for RTU session to indicate this RTU is not connected to this channel FLOATING = 2 -- use for RTU session to indicate this RTU is connected but not yet read
} }
---@alias IO_DIR integer ---@enum IO_DIR I/O direction
local IO_DIR = { local IO_DIR = {
IN = 0, IN = 0,
OUT = 1 OUT = 1
} }
---@alias IO_MODE integer ---@enum IO_MODE I/O mode (digital/analog input/output)
local IO_MODE = { local IO_MODE = {
DIGITAL_IN = 0, DIGITAL_IN = 0,
DIGITAL_OUT = 1, DIGITAL_OUT = 1,
@@ -31,80 +33,95 @@ local IO_MODE = {
ANALOG_OUT = 3 ANALOG_OUT = 3
} }
---@alias RS_IO integer ---@enum IO_PORT redstone I/O logic port
local RS_IO = { local IO_PORT = {
-- digital inputs -- -- digital inputs --
-- facility -- facility
F_SCRAM = 1, -- active low, facility-wide scram F_SCRAM = 1, -- active low, facility-wide scram
F_ACK = 2, -- active high, facility alarm acknowledge
-- reactor -- reactor
R_SCRAM = 2, -- active low, reactor scram R_SCRAM = 3, -- active low, reactor scram
R_ENABLE = 3, -- active high, reactor enable R_RESET = 4, -- active high, reactor RPS reset
R_ENABLE = 5, -- active high, reactor enable
-- unit
U_ACK = 6, -- active high, unit alarm acknowledge
-- digital outputs -- -- digital outputs --
-- facility -- facility
F_ALARM = 4, -- active high, facility safety alarm F_ALARM = 7, -- active high, facility-wide alarm (any high priority unit alarm)
-- waste -- waste
WASTE_PO = 5, -- active low, polonium routing WASTE_PU = 8, -- active low, waste -> plutonium -> pellets route
WASTE_PU = 6, -- active low, plutonium routing WASTE_PO = 9, -- active low, waste -> polonium route
WASTE_AM = 7, -- active low, antimatter routing WASTE_POPL = 10, -- active low, polonium -> pellets route
WASTE_AM = 11, -- active low, polonium -> anti-matter route
-- reactor -- reactor
R_ALARM = 8, -- active high, reactor safety alarm R_ACTIVE = 12, -- active high, if the reactor is active
R_SCRAMMED = 9, -- active high, if the reactor is scrammed R_AUTO_CTRL = 13, -- active high, if the reactor burn rate is automatic
R_AUTO_SCRAM = 10, -- active high, if the reactor was automatically scrammed R_SCRAMMED = 14, -- active high, if the reactor is scrammed
R_ACTIVE = 11, -- active high, if the reactor is active R_AUTO_SCRAM = 15, -- active high, if the reactor was automatically scrammed
R_AUTO_CTRL = 12, -- active high, if the reactor burn rate is automatic R_HIGH_DMG = 16, -- active high, if the reactor damage is high
R_DMG_CRIT = 13, -- active high, if the reactor damage is critical R_HIGH_TEMP = 17, -- active high, if the reactor is at a high temperature
R_HIGH_TEMP = 14, -- active high, if the reactor is at a high temperature R_LOW_COOLANT = 18, -- active high, if the reactor has very low coolant
R_NO_COOLANT = 15, -- active high, if the reactor has no coolant R_EXCESS_HC = 19, -- active high, if the reactor has excess heated coolant
R_EXCESS_HC = 16, -- active high, if the reactor has excess heated coolant R_EXCESS_WS = 20, -- active high, if the reactor has excess waste
R_EXCESS_WS = 17, -- active high, if the reactor has excess waste R_INSUFF_FUEL = 21, -- active high, if the reactor has insufficent fuel
R_INSUFF_FUEL = 18, -- active high, if the reactor has insufficent fuel R_PLC_FAULT = 22, -- active high, if the reactor PLC reports a device access fault
R_PLC_FAULT = 19, -- active high, if the reactor PLC reports a device access fault R_PLC_TIMEOUT = 23, -- active high, if the reactor PLC has not been heard from
R_PLC_TIMEOUT = 20 -- active high, if the reactor PLC has not been heard from
-- unit outputs
U_ALARM = 24, -- active high, unit alarm
U_EMER_COOL = 25 -- active low, emergency coolant control
} }
rsio.IO_LVL = IO_LVL rsio.IO_LVL = IO_LVL
rsio.IO_DIR = IO_DIR rsio.IO_DIR = IO_DIR
rsio.IO_MODE = IO_MODE rsio.IO_MODE = IO_MODE
rsio.IO = RS_IO rsio.IO = IO_PORT
----------------------- -----------------------
-- UTILITY FUNCTIONS -- -- UTILITY FUNCTIONS --
----------------------- -----------------------
-- channel to string -- port to string
---@param channel RS_IO ---@nodiscard
function rsio.to_string(channel) ---@param port IO_PORT
function rsio.to_string(port)
local names = { local names = {
"F_SCRAM", "F_SCRAM",
"F_ACK",
"R_SCRAM", "R_SCRAM",
"R_RESET",
"R_ENABLE", "R_ENABLE",
"U_ACK",
"F_ALARM", "F_ALARM",
"WASTE_PO",
"WASTE_PU", "WASTE_PU",
"WASTE_PO",
"WASTE_POPL",
"WASTE_AM", "WASTE_AM",
"R_ALARM",
"R_SCRAMMED",
"R_AUTO_SCRAM",
"R_ACTIVE", "R_ACTIVE",
"R_AUTO_CTRL", "R_AUTO_CTRL",
"R_DMG_CRIT", "R_SCRAMMED",
"R_AUTO_SCRAM",
"R_HIGH_DMG",
"R_HIGH_TEMP", "R_HIGH_TEMP",
"R_NO_COOLANT", "R_LOW_COOLANT",
"R_EXCESS_HC", "R_EXCESS_HC",
"R_EXCESS_WS", "R_EXCESS_WS",
"R_INSUFF_FUEL", "R_INSUFF_FUEL",
"R_PLC_FAULT", "R_PLC_FAULT",
"R_PLC_TIMEOUT" "R_PLC_TIMEOUT",
"U_ALARM",
"U_EMER_COOL"
} }
if util.is_int(channel) and channel > 0 and channel <= #names then if util.is_int(port) and port > 0 and port <= #names then
return names[channel] return names[port]
else else
return "" return ""
end end
@@ -112,82 +129,106 @@ end
local _B_AND = bit.band local _B_AND = bit.band
local function _ACTIVE_HIGH(level) return level == IO_LVL.HIGH end local function _I_ACTIVE_HIGH(level) return level == IO_LVL.HIGH end
local function _ACTIVE_LOW(level) return level == IO_LVL.LOW end local function _I_ACTIVE_LOW(level) return level == IO_LVL.LOW end
local function _O_ACTIVE_HIGH(active) if active then return IO_LVL.HIGH else return IO_LVL.LOW end end
local function _O_ACTIVE_LOW(active) if active then return IO_LVL.LOW else return IO_LVL.HIGH end end
-- I/O mappings to I/O function and I/O mode -- I/O mappings to I/O function and I/O mode
local RS_DIO_MAP = { local RS_DIO_MAP = {
-- F_SCRAM -- F_SCRAM
{ _f = _ACTIVE_LOW, mode = IO_DIR.IN }, { _in = _I_ACTIVE_LOW, _out = _O_ACTIVE_LOW, mode = IO_DIR.IN },
-- F_ACK
{ _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.IN },
-- R_SCRAM -- R_SCRAM
{ _f = _ACTIVE_LOW, mode = IO_DIR.IN }, { _in = _I_ACTIVE_LOW, _out = _O_ACTIVE_LOW, mode = IO_DIR.IN },
-- R_RESET
{ _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.IN },
-- R_ENABLE -- R_ENABLE
{ _f = _ACTIVE_HIGH, mode = IO_DIR.IN }, { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.IN },
-- U_ACK
{ _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.IN },
-- F_ALARM -- F_ALARM
{ _f = _ACTIVE_HIGH, mode = IO_DIR.OUT }, { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT },
-- WASTE_PO
{ _f = _ACTIVE_LOW, mode = IO_DIR.OUT },
-- WASTE_PU -- WASTE_PU
{ _f = _ACTIVE_LOW, mode = IO_DIR.OUT }, { _in = _I_ACTIVE_LOW, _out = _O_ACTIVE_LOW, mode = IO_DIR.OUT },
-- WASTE_PO
{ _in = _I_ACTIVE_LOW, _out = _O_ACTIVE_LOW, mode = IO_DIR.OUT },
-- WASTE_POPL
{ _in = _I_ACTIVE_LOW, _out = _O_ACTIVE_LOW, mode = IO_DIR.OUT },
-- WASTE_AM -- WASTE_AM
{ _f = _ACTIVE_LOW, mode = IO_DIR.OUT }, { _in = _I_ACTIVE_LOW, _out = _O_ACTIVE_LOW, mode = IO_DIR.OUT },
-- R_ALARM
{ _f = _ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_SCRAMMED
{ _f = _ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_AUTO_SCRAM
{ _f = _ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_ACTIVE -- R_ACTIVE
{ _f = _ACTIVE_HIGH, mode = IO_DIR.OUT }, { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_AUTO_CTRL -- R_AUTO_CTRL
{ _f = _ACTIVE_HIGH, mode = IO_DIR.OUT }, { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_DMG_CRIT -- R_SCRAMMED
{ _f = _ACTIVE_HIGH, mode = IO_DIR.OUT }, { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_AUTO_SCRAM
{ _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_HIGH_DMG
{ _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_HIGH_TEMP -- R_HIGH_TEMP
{ _f = _ACTIVE_HIGH, mode = IO_DIR.OUT }, { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_NO_COOLANT -- R_LOW_COOLANT
{ _f = _ACTIVE_HIGH, mode = IO_DIR.OUT }, { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_EXCESS_HC -- R_EXCESS_HC
{ _f = _ACTIVE_HIGH, mode = IO_DIR.OUT }, { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_EXCESS_WS -- R_EXCESS_WS
{ _f = _ACTIVE_HIGH, mode = IO_DIR.OUT }, { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_INSUFF_FUEL -- R_INSUFF_FUEL
{ _f = _ACTIVE_HIGH, mode = IO_DIR.OUT }, { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_PLC_FAULT -- R_PLC_FAULT
{ _f = _ACTIVE_HIGH, mode = IO_DIR.OUT }, { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_PLC_TIMEOUT -- R_PLC_TIMEOUT
{ _f = _ACTIVE_HIGH, mode = IO_DIR.OUT } { _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT },
-- U_ALARM
{ _in = _I_ACTIVE_HIGH, _out = _O_ACTIVE_HIGH, mode = IO_DIR.OUT },
-- U_EMER_COOL
{ _in = _I_ACTIVE_LOW, _out = _O_ACTIVE_LOW, mode = IO_DIR.OUT }
} }
-- get the mode of a channel -- get the mode of a port
---@param channel RS_IO ---@nodiscard
---@param port IO_PORT
---@return IO_MODE ---@return IO_MODE
function rsio.get_io_mode(channel) function rsio.get_io_mode(port)
local modes = { local modes = {
IO_MODE.DIGITAL_IN, -- F_SCRAM IO_MODE.DIGITAL_IN, -- F_SCRAM
IO_MODE.DIGITAL_IN, -- F_ACK
IO_MODE.DIGITAL_IN, -- R_SCRAM IO_MODE.DIGITAL_IN, -- R_SCRAM
IO_MODE.DIGITAL_IN, -- R_RESET
IO_MODE.DIGITAL_IN, -- R_ENABLE IO_MODE.DIGITAL_IN, -- R_ENABLE
IO_MODE.DIGITAL_IN, -- U_ACK
IO_MODE.DIGITAL_OUT, -- F_ALARM IO_MODE.DIGITAL_OUT, -- F_ALARM
IO_MODE.DIGITAL_OUT, -- WASTE_PO
IO_MODE.DIGITAL_OUT, -- WASTE_PU IO_MODE.DIGITAL_OUT, -- WASTE_PU
IO_MODE.DIGITAL_OUT, -- WASTE_PO
IO_MODE.DIGITAL_OUT, -- WASTE_POPL
IO_MODE.DIGITAL_OUT, -- WASTE_AM IO_MODE.DIGITAL_OUT, -- WASTE_AM
IO_MODE.DIGITAL_OUT, -- R_ALARM
IO_MODE.DIGITAL_OUT, -- R_SCRAMMED
IO_MODE.DIGITAL_OUT, -- R_AUTO_SCRAM
IO_MODE.DIGITAL_OUT, -- R_ACTIVE IO_MODE.DIGITAL_OUT, -- R_ACTIVE
IO_MODE.DIGITAL_OUT, -- R_AUTO_CTRL IO_MODE.DIGITAL_OUT, -- R_AUTO_CTRL
IO_MODE.DIGITAL_OUT, -- R_DMG_CRIT IO_MODE.DIGITAL_OUT, -- R_SCRAMMED
IO_MODE.DIGITAL_OUT, -- R_AUTO_SCRAM
IO_MODE.DIGITAL_OUT, -- R_HIGH_DMG
IO_MODE.DIGITAL_OUT, -- R_HIGH_TEMP IO_MODE.DIGITAL_OUT, -- R_HIGH_TEMP
IO_MODE.DIGITAL_OUT, -- R_NO_COOLANT IO_MODE.DIGITAL_OUT, -- R_LOW_COOLANT
IO_MODE.DIGITAL_OUT, -- R_EXCESS_HC IO_MODE.DIGITAL_OUT, -- R_EXCESS_HC
IO_MODE.DIGITAL_OUT, -- R_EXCESS_WS IO_MODE.DIGITAL_OUT, -- R_EXCESS_WS
IO_MODE.DIGITAL_OUT, -- R_INSUFF_FUEL IO_MODE.DIGITAL_OUT, -- R_INSUFF_FUEL
IO_MODE.DIGITAL_OUT, -- R_PLC_FAULT IO_MODE.DIGITAL_OUT, -- R_PLC_FAULT
IO_MODE.DIGITAL_OUT -- R_PLC_TIMEOUT IO_MODE.DIGITAL_OUT, -- R_PLC_TIMEOUT
IO_MODE.DIGITAL_OUT, -- U_ALARM
IO_MODE.DIGITAL_OUT -- U_EMER_COOL
} }
if util.is_int(channel) and channel > 0 and channel <= #modes then if util.is_int(port) and port > 0 and port <= #modes then
return modes[channel] return modes[port]
else else
return IO_MODE.ANALOG_IN return IO_MODE.ANALOG_IN
end end
@@ -199,14 +240,16 @@ end
local RS_SIDES = rs.getSides() local RS_SIDES = rs.getSides()
-- check if a channel is valid -- check if a port is valid
---@param channel RS_IO ---@nodiscard
---@param port IO_PORT
---@return boolean valid ---@return boolean valid
function rsio.is_valid_channel(channel) function rsio.is_valid_port(port)
return util.is_int(channel) and (channel > 0) and (channel <= RS_IO.R_PLC_TIMEOUT) return util.is_int(port) and (port > 0) and (port <= IO_PORT.U_EMER_COOL)
end end
-- check if a side is valid -- check if a side is valid
---@nodiscard
---@param side string ---@param side string
---@return boolean valid ---@return boolean valid
function rsio.is_valid_side(side) function rsio.is_valid_side(side)
@@ -219,48 +262,58 @@ function rsio.is_valid_side(side)
end end
-- check if a color is a valid single color -- check if a color is a valid single color
---@nodiscard
---@param color integer ---@param color integer
---@return boolean valid ---@return boolean valid
function rsio.is_color(color) function rsio.is_color(color)
return util.is_int(color) and (color > 0) and (_B_AND(color, (color - 1)) == 0); return util.is_int(color) and (color > 0) and (_B_AND(color, (color - 1)) == 0)
end end
----------------- -----------------
-- DIGITAL I/O -- -- DIGITAL I/O --
----------------- -----------------
-- get digital IO level reading -- get digital I/O level reading from a redstone boolean input value
---@param rs_value boolean ---@nodiscard
---@param rs_value boolean raw value from redstone
---@return IO_LVL ---@return IO_LVL
function rsio.digital_read(rs_value) function rsio.digital_read(rs_value)
if rs_value then if rs_value then return IO_LVL.HIGH else return IO_LVL.LOW end
return IO_LVL.HIGH end
else
return IO_LVL.LOW -- get redstone boolean output value corresponding to a digital I/O level
end ---@nodiscard
---@param level IO_LVL logic level
---@return boolean
function rsio.digital_write(level)
return level == IO_LVL.HIGH
end end
-- returns the level corresponding to active -- returns the level corresponding to active
---@param channel RS_IO ---@nodiscard
---@param level IO_LVL ---@param port IO_PORT port (to determine active high/low)
---@return boolean ---@param active boolean state to convert to logic level
function rsio.digital_write(channel, level) ---@return IO_LVL|false
if (not util.is_int(channel)) or (channel < RS_IO.F_ALARM) or (channel > RS_IO.R_PLC_TIMEOUT) then function rsio.digital_write_active(port, active)
if (not util.is_int(port)) or (port < IO_PORT.F_ALARM) or (port > IO_PORT.U_EMER_COOL) then
return false return false
else else
return RS_DIO_MAP[channel]._f(level) return RS_DIO_MAP[port]._out(active)
end end
end end
-- returns true if the level corresponds to active -- returns true if the level corresponds to active
---@param channel RS_IO ---@nodiscard
---@param level IO_LVL ---@param port IO_PORT port (to determine active low/high)
---@return boolean ---@param level IO_LVL logic level
function rsio.digital_is_active(channel, level) ---@return boolean|nil state true for active, false for inactive, or nil if invalid port or level provided
if (not util.is_int(channel)) or (channel > RS_IO.R_ENABLE) then function rsio.digital_is_active(port, level)
return false if not util.is_int(port) then
return nil
elseif level == IO_LVL.FLOATING or level == IO_LVL.DISCONNECT then
return nil
else else
return RS_DIO_MAP[channel]._f(level) return RS_DIO_MAP[port]._in(level)
end end
end end
@@ -269,6 +322,7 @@ end
---------------- ----------------
-- read an analog value scaled from min to max -- read an analog value scaled from min to max
---@nodiscard
---@param rs_value number redstone reading (0 to 15) ---@param rs_value number redstone reading (0 to 15)
---@param min number minimum of range ---@param min number minimum of range
---@param max number maximum of range ---@param max number maximum of range
@@ -279,13 +333,14 @@ function rsio.analog_read(rs_value, min, max)
end end
-- write an analog value from the provided scale range -- write an analog value from the provided scale range
---@nodiscard
---@param value number value to write (from min to max range) ---@param value number value to write (from min to max range)
---@param min number minimum of range ---@param min number minimum of range
---@param max number maximum of range ---@param max number maximum of range
---@return number rs_value scaled redstone reading (0 to 15) ---@return number rs_value scaled redstone reading (0 to 15)
function rsio.analog_write(value, min, max) function rsio.analog_write(value, min, max)
local scaled_value = (value - min) / (max - min) local scaled_value = (value - min) / (max - min)
return scaled_value * 15 return math.floor(scaled_value * 15)
end end
return rsio return rsio

View File

@@ -2,6 +2,9 @@
-- Timer Callback Dispatcher -- Timer Callback Dispatcher
-- --
local log = require("scada-common.log")
local util = require("scada-common.util")
local tcallbackdsp = {} local tcallbackdsp = {}
local registry = {} local registry = {}
@@ -10,16 +13,72 @@ local registry = {}
---@param time number seconds ---@param time number seconds
---@param f function callback function ---@param f function callback function
function tcallbackdsp.dispatch(time, f) function tcallbackdsp.dispatch(time, f)
---@diagnostic disable-next-line: undefined-field local timer = util.start_timer(time)
registry[os.startTimer(time)] = { callback = f } registry[timer] = {
callback = f,
duration = time,
expiry = time + util.time_s()
}
end
-- request a function to be called after the specified time, aborting any registered instances of that function reference
---@param time number seconds
---@param f function callback function
function tcallbackdsp.dispatch_unique(time, f)
-- cancel if already registered
for timer, entry in pairs(registry) do
if entry.callback == f then
-- found an instance of this function reference, abort it
log.debug(util.c("TCD: aborting duplicate timer callback [timer: ", timer, ", ", f, "]"))
-- cancel event and remove from registry (even if it fires it won't call)
util.cancel_timer(timer)
registry[timer] = nil
end
end
local timer = util.start_timer(time)
registry[timer] = {
callback = f,
duration = time,
expiry = time + util.time_s()
}
end
-- abort a requested callback
---@param f function callback function
function tcallbackdsp.abort(f)
for timer, entry in pairs(registry) do
if entry.callback == f then
-- cancel event and remove from registry (even if it fires it won't call)
util.cancel_timer(timer)
registry[timer] = nil
end
end
end end
-- lookup a timer event and execute the callback if found -- lookup a timer event and execute the callback if found
---@param event integer timer event timer ID ---@param event integer timer event timer ID
function tcallbackdsp.handle(event) function tcallbackdsp.handle(event)
if registry[event] ~= nil then if registry[event] ~= nil then
registry[event].callback() local callback = registry[event].callback
-- clear first so that dispatch_unique call from inside callback won't throw a debug message
registry[event] = nil registry[event] = nil
callback()
end
end
-- identify any overdo callbacks<br>
-- prints to log debug output
function tcallbackdsp.diagnostics()
for timer, entry in pairs(registry) do
if entry.expiry < util.time_s() then
local overtime = util.time_s() - entry.expiry
log.debug(util.c("TCD: unserviced timer ", timer, " for callback ", entry.callback, " is at least ", overtime, "s late"))
else
local time = entry.expiry - util.time_s()
log.debug(util.c("TCD: pending timer ", timer, " for callback ", entry.callback, " (call after ", entry.duration, "s, expires ", time, ")"))
end
end end
end end

View File

@@ -8,16 +8,57 @@ local types = {}
-- CLASSES -- -- CLASSES --
---@class tank_fluid ---@class tank_fluid
---@field name string ---@field name fluid
---@field amount integer ---@field amount integer
-- create a new tank fluid
---@nodiscard
---@param n string name
---@param a integer amount
---@return radiation_reading
function types.new_tank_fluid(n, a) return { name = n, amount = a } end
-- create a new empty tank fluid
---@nodiscard
---@return tank_fluid
function types.new_empty_gas() return { type = "mekanism:empty_gas", amount = 0 } end
---@class radiation_reading
---@field radiation number
---@field unit string
-- create a new radiation reading
---@nodiscard
---@param r number radiaiton level
---@param u string radiation unit
---@return radiation_reading
function types.new_radiation_reading(r, u) return { radiation = r, unit = u } end
-- create a new zeroed radiation reading
---@nodiscard
---@return radiation_reading
function types.new_zero_radiation_reading() return { radiation = 0, unit = "nSv" } end
---@class coordinate ---@class coordinate
---@field x integer ---@field x integer
---@field y integer ---@field y integer
---@field z integer ---@field z integer
-- create a new coordinate
---@nodiscard
---@param x integer
---@param y integer
---@param z integer
---@return coordinate
function types.new_coordinate(x, y, z) return { x = x, y = y, z = z } end
-- create a new zero coordinate
---@nodiscard
---@return coordinate
function types.new_zero_coordinate() return { x = 0, y = 0, z = 0 } end
---@class rtu_advertisement ---@class rtu_advertisement
---@field type integer ---@field type RTU_UNIT_TYPE
---@field index integer ---@field index integer
---@field reactor integer ---@field reactor integer
---@field rsio table|nil ---@field rsio table|nil
@@ -27,15 +68,160 @@ local types = {}
---@alias color integer ---@alias color integer
-- ENUMERATION TYPES -- -- ENUMERATION TYPES --
--#region
---@alias TRI_FAIL integer ---@enum RTU_UNIT_TYPE
types.TRI_FAIL = { types.RTU_UNIT_TYPE = {
OK = 0, VIRTUAL = 0, -- virtual device
PARTIAL = 1, REDSTONE = 1, -- redstone I/O
FULL = 2 BOILER_VALVE = 2, -- boiler mekanism 10.1+
TURBINE_VALVE = 3, -- turbine, mekanism 10.1+
IMATRIX = 4, -- induction matrix
SPS = 5, -- SPS
SNA = 6, -- SNA
ENV_DETECTOR = 7 -- environment detector
} }
types.RTU_UNIT_NAMES = {
"redstone",
"boiler_valve",
"turbine_valve",
"induction_matrix",
"sps",
"sna",
"environment_detector"
}
-- safe conversion of RTU UNIT TYPE to string
---@nodiscard
---@param utype RTU_UNIT_TYPE
---@return string
function types.rtu_type_to_string(utype)
if utype == types.RTU_UNIT_TYPE.VIRTUAL then
return "virtual"
elseif utype == types.RTU_UNIT_TYPE.REDSTONE or
utype == types.RTU_UNIT_TYPE.BOILER_VALVE or
utype == types.RTU_UNIT_TYPE.TURBINE_VALVE or
utype == types.RTU_UNIT_TYPE.IMATRIX or
utype == types.RTU_UNIT_TYPE.SPS or
utype == types.RTU_UNIT_TYPE.SNA or
utype == types.RTU_UNIT_TYPE.ENV_DETECTOR then
return types.RTU_UNIT_NAMES[utype]
else
return ""
end
end
---@enum TRI_FAIL
types.TRI_FAIL = {
OK = 1,
PARTIAL = 2,
FULL = 3
}
---@enum PROCESS
types.PROCESS = {
INACTIVE = 0,
MAX_BURN = 1,
BURN_RATE = 2,
CHARGE = 3,
GEN_RATE = 4,
MATRIX_FAULT_IDLE = 5,
SYSTEM_ALARM_IDLE = 6,
GEN_RATE_FAULT_IDLE = 7
}
types.PROCESS_NAMES = {
"INACTIVE",
"MAX_BURN",
"BURN_RATE",
"CHARGE",
"GEN_RATE",
"MATRIX_FAULT_IDLE",
"SYSTEM_ALARM_IDLE",
"GEN_RATE_FAULT_IDLE"
}
---@enum WASTE_MODE
types.WASTE_MODE = {
AUTO = 1,
PLUTONIUM = 2,
POLONIUM = 3,
ANTI_MATTER = 4
}
types.WASTE_MODE_NAMES = {
"AUTO",
"PLUTONIUM",
"POLONIUM",
"ANTI_MATTER"
}
---@enum ALARM
types.ALARM = {
ContainmentBreach = 1,
ContainmentRadiation = 2,
ReactorLost = 3,
CriticalDamage = 4,
ReactorDamage = 5,
ReactorOverTemp = 6,
ReactorHighTemp = 7,
ReactorWasteLeak = 8,
ReactorHighWaste = 9,
RPSTransient = 10,
RCSTransient = 11,
TurbineTrip = 12
}
types.ALARM_NAMES = {
"ContainmentBreach",
"ContainmentRadiation",
"ReactorLost",
"CriticalDamage",
"ReactorDamage",
"ReactorOverTemp",
"ReactorHighTemp",
"ReactorWasteLeak",
"ReactorHighWaste",
"RPSTransient",
"RCSTransient",
"TurbineTrip"
}
---@enum ALARM_PRIORITY
types.ALARM_PRIORITY = {
CRITICAL = 1,
EMERGENCY = 2,
URGENT = 3,
TIMELY = 4
}
types.ALARM_PRIORITY_NAMES = {
"CRITICAL",
"EMERGENCY",
"URGENT",
"TIMELY"
}
---@enum ALARM_STATE
types.ALARM_STATE = {
INACTIVE = 1,
TRIPPED = 2,
ACKED = 3,
RING_BACK = 4
}
types.ALARM_STATE_NAMES = {
"INACTIVE",
"TRIPPED",
"ACKED",
"RING_BACK"
}
--#endregion
-- STRING TYPES -- -- STRING TYPES --
--#region
---@alias os_event ---@alias os_event
---| "alarm" ---| "alarm"
@@ -70,59 +256,70 @@ types.TRI_FAIL = {
---| "websocket_failure" ---| "websocket_failure"
---| "websocket_message" ---| "websocket_message"
---| "websocket_success" ---| "websocket_success"
---| "clock_start" custom, added for reactor PLC
---@alias fluid
---| "mekanism:empty_gas"
---| "minecraft:water"
---| "mekanism:sodium"
---| "mekanism:superheated_sodium"
types.FLUID = {
EMPTY_GAS = "mekanism:empty_gas",
WATER = "minecraft:water",
SODIUM = "mekanism:sodium",
SUPERHEATED_SODIUM = "mekanism:superheated_sodium"
}
---@alias rps_trip_cause ---@alias rps_trip_cause
---| "ok" ---| "ok"
---| "dmg_crit" ---| "high_dmg"
---| "high_temp" ---| "high_temp"
---| "no_coolant" ---| "low_coolant"
---| "full_waste" ---| "ex_waste"
---| "heated_coolant_backup" ---| "ex_heated_coolant"
---| "no_fuel" ---| "no_fuel"
---| "fault" ---| "fault"
---| "timeout" ---| "timeout"
---| "manual" ---| "manual"
---| "automatic"
---| "sys_fail"
---| "force_disabled"
---@alias rtu_t string types.RPS_TRIP_CAUSE = {
types.rtu_t = { OK = "ok",
redstone = "redstone", HIGH_DMG = "high_dmg",
boiler = "boiler", HIGH_TEMP = "high_temp",
boiler_valve = "boiler_valve", LOW_COOLANT = "low_coolant",
turbine = "turbine", EX_WASTE = "ex_waste",
turbine_valve = "turbine_valve", EX_HCOOLANT = "ex_heated_coolant",
energy_machine = "emachine", NO_FUEL = "no_fuel",
induction_matrix = "induction_matrix", FAULT = "fault",
sps = "sps", TIMEOUT = "timeout",
sna = "sna", MANUAL = "manual",
env_detector = "environment_detector" AUTOMATIC = "automatic",
SYS_FAIL = "sys_fail",
FORCE_DISABLED = "force_disabled"
} }
---@alias rps_status_t string ---@alias dumping_mode
types.rps_status_t = { ---| "IDLE"
ok = "ok", ---| "DUMPING"
dmg_crit = "dmg_crit", ---| "DUMPING_EXCESS"
high_temp = "high_temp",
no_coolant = "no_coolant",
ex_waste = "full_waste",
ex_hcoolant = "heated_coolant_backup",
no_fuel = "no_fuel",
fault = "fault",
timeout = "timeout",
manual = "manual"
}
-- turbine steam dumping modes
---@alias DUMPING_MODE string
types.DUMPING_MODE = { types.DUMPING_MODE = {
IDLE = "IDLE", IDLE = "IDLE",
DUMPING = "DUMPING", DUMPING = "DUMPING",
DUMPING_EXCESS = "DUMPING_EXCESS" DUMPING_EXCESS = "DUMPING_EXCESS"
} }
-- MODBUS --#endregion
-- modbus function codes -- MODBUS --
---@alias MODBUS_FCODE integer --#region
-- MODBUS function codes
---@enum MODBUS_FCODE
types.MODBUS_FCODE = { types.MODBUS_FCODE = {
READ_COILS = 0x01, READ_COILS = 0x01,
READ_DISCRETE_INPUTS = 0x02, READ_DISCRETE_INPUTS = 0x02,
@@ -135,8 +332,8 @@ types.MODBUS_FCODE = {
ERROR_FLAG = 0x80 ERROR_FLAG = 0x80
} }
-- modbus exception codes -- MODBUS exception codes
---@alias MODBUS_EXCODE integer ---@enum MODBUS_EXCODE
types.MODBUS_EXCODE = { types.MODBUS_EXCODE = {
ILLEGAL_FUNCTION = 0x01, ILLEGAL_FUNCTION = 0x01,
ILLEGAL_DATA_ADDR = 0x02, ILLEGAL_DATA_ADDR = 0x02,
@@ -150,4 +347,6 @@ types.MODBUS_EXCODE = {
GATEWAY_TARGET_TIMEOUT = 0x0B GATEWAY_TARGET_TIMEOUT = 0x0B
} }
--#endregion
return types return types

View File

@@ -5,10 +5,17 @@
---@class util ---@class util
local util = {} local util = {}
-- ENVIRONMENT CONSTANTS --
util.TICK_TIME_S = 0.05
util.TICK_TIME_MS = 50
-- OPERATORS -- -- OPERATORS --
--#region
-- trinary operator -- trinary operator
---@param cond boolean condition ---@nodiscard
---@param cond boolean|nil condition
---@param a any return if true ---@param a any return if true
---@param b any return if false ---@param b any return if false
---@return any value ---@return any value
@@ -16,7 +23,10 @@ function util.trinary(cond, a, b)
if cond then return a else return b end if cond then return a else return b end
end end
--#endregion
-- PRINT -- -- PRINT --
--#region
-- print -- print
---@param message any ---@param message any
@@ -42,9 +52,13 @@ function util.println_ts(message)
print(os.date("[%H:%M:%S] ") .. tostring(message)) print(os.date("[%H:%M:%S] ") .. tostring(message))
end end
--#endregion
-- STRING TOOLS -- -- STRING TOOLS --
--#region
-- get a value as a string -- get a value as a string
---@nodiscard
---@param val any ---@param val any
---@return string ---@return string
function util.strval(val) function util.strval(val)
@@ -57,6 +71,7 @@ function util.strval(val)
end end
-- repeat a string n times -- repeat a string n times
---@nodiscard
---@param str string ---@param str string
---@param n integer ---@param n integer
---@return string ---@return string
@@ -69,6 +84,7 @@ function util.strrep(str, n)
end end
-- repeat a space n times -- repeat a space n times
---@nodiscard
---@param n integer ---@param n integer
---@return string ---@return string
function util.spaces(n) function util.spaces(n)
@@ -76,6 +92,7 @@ function util.spaces(n)
end end
-- pad text to a minimum width -- pad text to a minimum width
---@nodiscard
---@param str string text ---@param str string text
---@param n integer minimum width ---@param n integer minimum width
---@return string ---@return string
@@ -88,6 +105,7 @@ function util.pad(str, n)
end end
-- wrap a string into a table of lines, supporting single dash splits -- wrap a string into a table of lines, supporting single dash splits
---@nodiscard
---@param str string ---@param str string
---@param limit integer line limit ---@param limit integer line limit
---@return table lines ---@return table lines
@@ -135,13 +153,12 @@ function util.strwrap(str, limit)
end end
-- concatenation with built-in to string -- concatenation with built-in to string
---@nodiscard
---@vararg any ---@vararg any
---@return string ---@return string
function util.concat(...) function util.concat(...)
local str = "" local str = ""
for _, v in ipairs(arg) do for _, v in ipairs(arg) do str = str .. util.strval(v) end
str = str .. util.strval(v)
end
return str return str
end end
@@ -149,30 +166,116 @@ end
util.c = util.concat util.c = util.concat
-- sprintf implementation -- sprintf implementation
---@nodiscard
---@param format string ---@param format string
---@vararg any ---@vararg any
function util.sprintf(format, ...) function util.sprintf(format, ...)
return string.format(format, table.unpack(arg)) return string.format(format, table.unpack(arg))
end end
-- format a number string with commas as the thousands separator<br>
-- subtracts from spaces at the start if present for each comma used
---@nodiscard
---@param num string number string
---@return string
function util.comma_format(num)
local formatted = num
local commas = 0
local i = 1
while i > 0 do
formatted, i = formatted:gsub("^(%s-%d+)(%d%d%d)", '%1,%2')
if i > 0 then commas = commas + 1 end
end
local _, num_spaces = formatted:gsub(" %s-", "")
local remove = math.min(num_spaces, commas)
formatted = string.sub(formatted, remove + 1)
return formatted
end
--#endregion
-- MATH -- -- MATH --
--#region
-- is a value an integer -- is a value an integer
---@nodiscard
---@param x any value ---@param x any value
---@return boolean is_integer if the number is an integer ---@return boolean is_integer if the number is an integer
function util.is_int(x) function util.is_int(x)
return type(x) == "number" and x == math.floor(x) return type(x) == "number" and x == math.floor(x)
end end
-- get the sign of a number
---@nodiscard
---@param x number value
---@return integer sign (-1 for < 0, 1 otherwise)
function util.sign(x)
return util.trinary(x < 0, -1, 1)
end
-- round a number to an integer -- round a number to an integer
---@nodiscard
---@return integer rounded ---@return integer rounded
function util.round(x) function util.round(x)
return math.floor(x + 0.5) return math.floor(x + 0.5)
end end
-- get a new moving average object
---@nodiscard
---@param length integer history length
---@param default number value to fill history with for first call to compute()
function util.mov_avg(length, default)
local data = {}
local index = 1
local last_t = 0 ---@type number|nil
---@class moving_average
local public = {}
-- reset all to a given value
---@param x number value
function public.reset(x)
data = {}
for _ = 1, length do table.insert(data, x) end
end
-- record a new value
---@param x number new value
---@param t number? optional last update time to prevent duplicated entries
function public.record(x, t)
if type(t) == "number" and last_t == t then
return
end
data[index] = x
last_t = t
index = index + 1
if index > length then index = 1 end
end
-- compute the moving average
---@nodiscard
---@return number average
function public.compute()
local sum = 0
for i = 1, length do sum = sum + data[i] end
return sum / length
end
public.reset(default)
return public
end
-- TIME -- -- TIME --
-- current time -- current time
---@nodiscard
---@return integer milliseconds ---@return integer milliseconds
function util.time_ms() function util.time_ms()
---@diagnostic disable-next-line: undefined-field ---@diagnostic disable-next-line: undefined-field
@@ -180,6 +283,7 @@ function util.time_ms()
end end
-- current time -- current time
---@nodiscard
---@return number seconds ---@return number seconds
function util.time_s() function util.time_s()
---@diagnostic disable-next-line: undefined-field ---@diagnostic disable-next-line: undefined-field
@@ -187,14 +291,17 @@ function util.time_s()
end end
-- current time -- current time
---@nodiscard
---@return integer milliseconds ---@return integer milliseconds
function util.time() function util.time() return util.time_ms() end
return util.time_ms()
end --#endregion
-- OS -- -- OS --
--#region
-- OS pull event raw wrapper with types -- OS pull event raw wrapper with types
---@nodiscard
---@param target_event? string event to wait for ---@param target_event? string event to wait for
---@return os_event event, any param1, any param2, any param3, any param4, any param5 ---@return os_event event, any param1, any param2, any param3, any param4, any param5
function util.pull_event(target_event) function util.pull_event(target_event)
@@ -202,7 +309,38 @@ function util.pull_event(target_event)
return os.pullEventRaw(target_event) return os.pullEventRaw(target_event)
end end
-- OS queue event raw wrapper with types
---@param event os_event
---@param param1 any
---@param param2 any
---@param param3 any
---@param param4 any
---@param param5 any
function util.push_event(event, param1, param2, param3, param4, param5)
---@diagnostic disable-next-line: undefined-field
return os.queueEvent(event, param1, param2, param3, param4, param5)
end
-- start an OS timer
---@nodiscard
---@param t number timer duration in seconds
---@return integer timer ID
function util.start_timer(t)
---@diagnostic disable-next-line: undefined-field
return os.startTimer(t)
end
-- cancel an OS timer
---@param timer integer timer ID
function util.cancel_timer(timer)
---@diagnostic disable-next-line: undefined-field
os.cancelTimer(timer)
end
--#endregion
-- PARALLELIZATION -- -- PARALLELIZATION --
--#region
-- protected sleep call so we still are in charge of catching termination -- protected sleep call so we still are in charge of catching termination
---@param t integer seconds ---@param t integer seconds
@@ -212,14 +350,12 @@ function util.psleep(t)
pcall(os.sleep, t) pcall(os.sleep, t)
end end
-- no-op to provide a brief pause (1 tick) to yield -- no-op to provide a brief pause (1 tick) to yield<br>
---
--- EVENT_CONSUMER: this function consumes events --- EVENT_CONSUMER: this function consumes events
function util.nop() function util.nop() util.psleep(0.05) end
util.psleep(0.05)
end
-- attempt to maintain a minimum loop timing (duration of execution) -- attempt to maintain a minimum loop timing (duration of execution)
---@nodiscard
---@param target_timing integer minimum amount of milliseconds to wait for ---@param target_timing integer minimum amount of milliseconds to wait for
---@param last_update integer millisecond time of last update ---@param last_update integer millisecond time of last update
---@return integer time_now ---@return integer time_now
@@ -227,16 +363,16 @@ end
function util.adaptive_delay(target_timing, last_update) function util.adaptive_delay(target_timing, last_update)
local sleep_for = target_timing - (util.time() - last_update) local sleep_for = target_timing - (util.time() - last_update)
-- only if >50ms since worker loops already yield 0.05s -- only if >50ms since worker loops already yield 0.05s
if sleep_for >= 50 then if sleep_for >= 50 then util.psleep(sleep_for / 1000.0) end
util.psleep(sleep_for / 1000.0)
end
return util.time() return util.time()
end end
-- TABLE UTILITIES -- --#endregion
-- delete elements from a table if the passed function returns false when passed a table element -- TABLE UTILITIES --
-- --#region
-- delete elements from a table if the passed function returns false when passed a table element<br>
-- put briefly: deletes elements that return false, keeps elements that return true -- put briefly: deletes elements that return false, keeps elements that return true
---@param t table table to remove elements from ---@param t table table to remove elements from
---@param f function should return false to delete an element when passed the element: f(elem) = true|false ---@param f function should return false to delete an element when passed the element: f(elem) = true|false
@@ -261,6 +397,7 @@ function util.filter_table(t, f, on_delete)
end end
-- check if a table contains the provided element -- check if a table contains the provided element
---@nodiscard
---@param t table table to check ---@param t table table to check
---@param element any element to check for ---@param element any element to check for
function util.table_contains(t, element) function util.table_contains(t, element)
@@ -271,74 +408,113 @@ function util.table_contains(t, element)
return false return false
end end
--#endregion
-- MEKANISM POWER -- -- MEKANISM POWER --
--#region
-- function util.kFE(fe) return fe / 1000.0 end -- convert Joules to FE
-- function util.MFE(fe) return fe / 1000000.0 end ---@nodiscard
-- function util.GFE(fe) return fe / 1000000000.0 end ---@param J number Joules
-- function util.TFE(fe) return fe / 1000000000000.0 end ---@return number FE Forge Energy
function util.joules_to_fe(J) return (J * 0.4) end
-- -- FLOATING POINT PRINTS -- -- convert FE to Joules
---@nodiscard
---@param FE number Forge Energy
---@return number J Joules
function util.fe_to_joules(FE) return (FE * 2.5) end
-- local function fractional_1s(number) local function kFE(fe) return fe / 1000.0 end
-- return number == math.round(number) local function MFE(fe) return fe / 1000000.0 end
-- end local function GFE(fe) return fe / 1000000000.0 end
local function TFE(fe) return fe / 1000000000000.0 end
local function PFE(fe) return fe / 1000000000000000.0 end
local function EFE(fe) return fe / 1000000000000000000.0 end -- if you accomplish this please touch grass
local function ZFE(fe) return fe / 1000000000000000000000.0 end -- please stop
-- local function fractional_10ths(number) -- format a power value into XXX.XX UNIT format (FE, kFE, MFE, GFE, TFE, PFE, EFE, ZFE)
-- number = number * 10 ---@nodiscard
-- return number == math.round(number) ---@param fe number forge energy value
-- end ---@param combine_label? boolean if a label should be included in the string itself
---@param format? string format override
---@return string str, string? unit
function util.power_format(fe, combine_label, format)
local unit
local value
-- local function fractional_100ths(number) if type(format) ~= "string" then format = "%.2f" end
-- number = number * 100
-- return number == math.round(number)
-- end
-- function util.power_format(fe) if fe < 1000.0 then
-- if fe < 1000 then unit = "FE"
-- return string.format("%.2f FE", fe) value = fe
-- elseif fe < 1000000 then elseif fe < 1000000.0 then
-- return string.format("%.3f kFE", kFE(fe)) unit = "kFE"
-- end value = kFE(fe)
-- end elseif fe < 1000000000.0 then
unit = "MFE"
value = MFE(fe)
elseif fe < 1000000000000.0 then
unit = "GFE"
value = GFE(fe)
elseif fe < 1000000000000000.0 then
unit = "TFE"
value = TFE(fe)
elseif fe < 1000000000000000000.0 then
unit = "PFE"
value = PFE(fe)
elseif fe < 1000000000000000000000.0 then
unit = "EFE"
value = EFE(fe)
else
unit = "ZFE"
value = ZFE(fe)
end
if combine_label then
return util.sprintf(util.c(format, " %s"), value, unit)
else
return util.sprintf(format, value), unit
end
end
--#endregion
-- UTILITY CLASSES --
--#region
-- WATCHDOG -- -- WATCHDOG --
-- ComputerCraft OS Timer based Watchdog -- OS timer based watchdog<br>
-- triggers a timer event if not fed within 'timeout' seconds
---@nodiscard
---@param timeout number timeout duration ---@param timeout number timeout duration
---
--- triggers a timer event if not fed within 'timeout' seconds
function util.new_watchdog(timeout) function util.new_watchdog(timeout)
---@diagnostic disable-next-line: undefined-field
local start_timer = os.startTimer
---@diagnostic disable-next-line: undefined-field
local cancel_timer = os.cancelTimer
local self = { local self = {
timeout = timeout, timeout = timeout,
wd_timer = start_timer(timeout) wd_timer = util.start_timer(timeout)
} }
---@class watchdog ---@class watchdog
local public = {} local public = {}
-- check if a timer is this watchdog
---@nodiscard
---@param timer number timer event timer ID ---@param timer number timer event timer ID
function public.is_timer(timer) function public.is_timer(timer) return self.wd_timer == timer end
return self.wd_timer == timer
end
-- satiate the beast -- satiate the beast
function public.feed() function public.feed()
if self.wd_timer ~= nil then if self.wd_timer ~= nil then
cancel_timer(self.wd_timer) util.cancel_timer(self.wd_timer)
end end
self.wd_timer = start_timer(self.timeout) self.wd_timer = util.start_timer(self.timeout)
end end
-- cancel the watchdog -- cancel the watchdog
function public.cancel() function public.cancel()
if self.wd_timer ~= nil then if self.wd_timer ~= nil then
cancel_timer(self.wd_timer) util.cancel_timer(self.wd_timer)
end end
end end
@@ -347,14 +523,11 @@ end
-- LOOP CLOCK -- -- LOOP CLOCK --
-- ComputerCraft OS Timer based Loop Clock -- OS timer based loop clock<br>
-- fires a timer event at the specified period, does not start at construct time
---@nodiscard
---@param period number clock period ---@param period number clock period
---
--- fires a timer event at the specified period, does not start at construct time
function util.new_clock(period) function util.new_clock(period)
---@diagnostic disable-next-line: undefined-field
local start_timer = os.startTimer
local self = { local self = {
period = period, period = period,
timer = nil timer = nil
@@ -363,22 +536,22 @@ function util.new_clock(period)
---@class clock ---@class clock
local public = {} local public = {}
-- check if a timer is this clock
---@nodiscard
---@param timer number timer event timer ID ---@param timer number timer event timer ID
function public.is_clock(timer) function public.is_clock(timer) return self.timer == timer end
return self.timer == timer
end
-- start the clock -- start the clock
function public.start() function public.start() self.timer = util.start_timer(self.period) end
self.timer = start_timer(self.period)
end
return public return public
end end
-- create a new type validator -- FIELD VALIDATOR --
--
-- create a new type validator<br>
-- can execute sequential checks and check valid() to see if it is still valid -- can execute sequential checks and check valid() to see if it is still valid
---@nodiscard
function util.new_validator() function util.new_validator()
local valid = true local valid = true
@@ -401,9 +574,13 @@ function util.new_validator()
function public.assert_port(port) valid = valid and type(port) == "number" and port >= 0 and port <= 65535 end function public.assert_port(port) valid = valid and type(port) == "number" and port >= 0 and port <= 65535 end
-- check if all assertions passed successfully
---@nodiscard
function public.valid() return valid end function public.valid() return valid end
return public return public
end end
--#endregion
return util return util

View File

@@ -4,6 +4,13 @@ local config = {}
config.SCADA_DEV_LISTEN = 16000 config.SCADA_DEV_LISTEN = 16000
-- listen port for SCADA supervisor access by coordinators -- listen port for SCADA supervisor access by coordinators
config.SCADA_SV_LISTEN = 16100 config.SCADA_SV_LISTEN = 16100
-- max trusted modem message distance (0 to disable check)
config.TRUSTED_RANGE = 0
-- time in seconds (>= 2) before assuming a remote device is no longer active
config.PLC_TIMEOUT = 5
config.RTU_TIMEOUT = 5
config.CRD_TIMEOUT = 5
-- expected number of reactors -- expected number of reactors
config.NUM_REACTORS = 4 config.NUM_REACTORS = 4
-- expected number of boilers/turbines for each reactor -- expected number of boilers/turbines for each reactor
@@ -13,6 +20,7 @@ config.REACTOR_COOLING = {
{ BOILERS = 1, TURBINES = 1 }, -- reactor unit 3 { BOILERS = 1, TURBINES = 1 }, -- reactor unit 3
{ BOILERS = 1, TURBINES = 1 } -- reactor unit 4 { BOILERS = 1, TURBINES = 1 } -- reactor unit 4
} }
-- log path -- log path
config.LOG_PATH = "/log.txt" config.LOG_PATH = "/log.txt"
-- log mode -- log mode

897
supervisor/facility.lua Normal file
View File

@@ -0,0 +1,897 @@
local const = require("scada-common.constants")
local log = require("scada-common.log")
local rsio = require("scada-common.rsio")
local types = require("scada-common.types")
local util = require("scada-common.util")
local unit = require("supervisor.unit")
local rsctl = require("supervisor.session.rsctl")
local PROCESS = types.PROCESS
local PROCESS_NAMES = types.PROCESS_NAMES
local PRIO = types.ALARM_PRIORITY
local IO = rsio.IO
-- 7.14 kJ per blade for 1 mB of fissile fuel<br>
-- 2856 FE per blade per 1 mB, 285.6 FE per blade per 0.1 mB (minimum)
local POWER_PER_BLADE = util.joules_to_fe(7140)
local FLOW_STABILITY_DELAY_S = const.FLOW_STABILITY_DELAY_MS / 1000
local ALARM_LIMS = const.ALARM_LIMITS
local AUTO_SCRAM = {
NONE = 0,
MATRIX_DC = 1,
MATRIX_FILL = 2,
CRIT_ALARM = 3,
RADIATION = 4,
GEN_FAULT = 5
}
local START_STATUS = {
OK = 0,
NO_UNITS = 1,
BLADE_MISMATCH = 2
}
local charge_Kp = 0.275
local charge_Ki = 0.0
local charge_Kd = 4.5
local rate_Kp = 2.45
local rate_Ki = 0.4825
local rate_Kd = -1.0
---@class facility_management
local facility = {}
-- create a new facility management object
---@nodiscard
---@param num_reactors integer number of reactor units
---@param cooling_conf table cooling configurations of reactor units
function facility.new(num_reactors, cooling_conf)
local self = {
units = {},
status_text = { "START UP", "initializing..." },
all_sys_ok = false,
-- rtus
rtu_conn_count = 0,
redstone = {},
induction = {},
envd = {},
-- redstone I/O control
io_ctl = nil, ---@type rs_controller
-- process control
units_ready = false,
mode = PROCESS.INACTIVE,
last_mode = PROCESS.INACTIVE,
return_mode = PROCESS.INACTIVE,
mode_set = PROCESS.MAX_BURN,
start_fail = START_STATUS.OK,
max_burn_combined = 0.0, -- maximum burn rate to clamp at
burn_target = 0.1, -- burn rate target for aggregate burn mode
charge_setpoint = 0, -- FE charge target setpoint
gen_rate_setpoint = 0, -- FE/t charge rate target setpoint
group_map = { 0, 0, 0, 0 }, -- units -> group IDs
prio_defs = { {}, {}, {}, {} }, -- priority definitions (each level is a table of units)
at_max_burn = false,
ascram = false,
ascram_reason = AUTO_SCRAM.NONE,
---@class ascram_status
ascram_status = {
matrix_dc = false,
matrix_fill = false,
crit_alarm = false,
radiation = false,
gen_fault = false
},
-- closed loop control
charge_conversion = 1.0,
time_start = 0.0,
initial_ramp = true,
waiting_on_ramp = false,
waiting_on_stable = false,
accumulator = 0.0,
saturated = false,
last_update = 0,
last_error = 0.0,
last_time = 0.0,
-- statistics
im_stat_init = false,
avg_charge = util.mov_avg(3, 0.0),
avg_inflow = util.mov_avg(6, 0.0),
avg_outflow = util.mov_avg(6, 0.0)
}
-- create units
for i = 1, num_reactors do
table.insert(self.units, unit.new(i, cooling_conf[i].BOILERS, cooling_conf[i].TURBINES))
end
-- init redstone RTU I/O controller
self.io_ctl = rsctl.new(self.redstone)
-- unlink disconnected units
---@param sessions table
local function _unlink_disconnected_units(sessions)
util.filter_table(sessions, function (u) return u.is_connected() end)
end
-- check if all auto-controlled units completed ramping
---@nodiscard
local function _all_units_ramped()
local all_ramped = true
for i = 1, #self.prio_defs do
local units = self.prio_defs[i]
for u = 1, #units do
all_ramped = all_ramped and units[u].a_ramp_complete()
end
end
return all_ramped
end
-- split a burn rate among the reactors
---@param burn_rate number burn rate assignment
---@param ramp boolean true to ramp, false to set right away
---@param abort_on_fault boolean? true to exit if one device has an effective burn rate different than its limit
---@return integer unallocated_br100, boolean? aborted
local function _allocate_burn_rate(burn_rate, ramp, abort_on_fault)
local unallocated = math.floor(burn_rate * 100)
-- go through all priority groups
for i = 1, #self.prio_defs do
local units = self.prio_defs[i]
if #units > 0 then
local split = math.floor(unallocated / #units)
local splits = {}
for u = 1, #units do splits[u] = split end
splits[#units] = splits[#units] + (unallocated % #units)
-- go through all reactor units in this group
for id = 1, #units do
local u = units[id] ---@type reactor_unit
local ctl = u.get_control_inf()
local lim_br100 = u.a_get_effective_limit()
if abort_on_fault and (lim_br100 ~= ctl.lim_br100) then
-- effective limit differs from set limit, unit is degraded
return unallocated, true
end
local last = ctl.br100
if splits[id] <= lim_br100 then
ctl.br100 = splits[id]
else
ctl.br100 = lim_br100
if id < #units then
local remaining = #units - id
split = math.floor(unallocated / remaining)
for x = (id + 1), #units do splits[x] = split end
splits[#units] = splits[#units] + (unallocated % remaining)
end
end
unallocated = math.max(0, unallocated - ctl.br100)
if last ~= ctl.br100 then u.a_commit_br100(ramp) end
end
end
end
return unallocated, false
end
-- PUBLIC FUNCTIONS --
---@class facility
local public = {}
-- ADD/LINK DEVICES --
-- link a redstone RTU session
---@param rs_unit unit_session
function public.add_redstone(rs_unit)
table.insert(self.redstone, rs_unit)
end
-- link an imatrix RTU session
---@param imatrix unit_session
function public.add_imatrix(imatrix)
table.insert(self.induction, imatrix)
end
-- link an environment detector RTU session
---@param envd unit_session
function public.add_envd(envd)
table.insert(self.envd, envd)
end
-- purge devices associated with the given RTU session ID
---@param session integer RTU session ID
function public.purge_rtu_devices(session)
util.filter_table(self.redstone, function (s) return s.get_session_id() ~= session end)
util.filter_table(self.induction, function (s) return s.get_session_id() ~= session end)
util.filter_table(self.envd, function (s) return s.get_session_id() ~= session end)
end
-- UPDATE --
-- supervisor sessions reporting the list of active RTU sessions
---@param rtu_sessions table session list of all connected RTUs
function public.report_rtus(rtu_sessions)
self.rtu_conn_count = #rtu_sessions
end
-- update (iterate) the facility management
function public.update()
-- unlink RTU unit sessions if they are closed
_unlink_disconnected_units(self.redstone)
_unlink_disconnected_units(self.induction)
_unlink_disconnected_units(self.envd)
-- current state for process control
local charge_update = 0
local rate_update = 0
-- calculate moving averages for induction matrix
if self.induction[1] ~= nil then
local matrix = self.induction[1] ---@type unit_session
local db = matrix.get_db() ---@type imatrix_session_db
charge_update = db.tanks.last_update
rate_update = db.state.last_update
if (charge_update > 0) and (rate_update > 0) then
if self.im_stat_init then
self.avg_charge.record(util.joules_to_fe(db.tanks.energy), charge_update)
self.avg_inflow.record(util.joules_to_fe(db.state.last_input), rate_update)
self.avg_outflow.record(util.joules_to_fe(db.state.last_output), rate_update)
else
self.im_stat_init = true
self.avg_charge.reset(util.joules_to_fe(db.tanks.energy))
self.avg_inflow.reset(util.joules_to_fe(db.state.last_input))
self.avg_outflow.reset(util.joules_to_fe(db.state.last_output))
end
end
else
self.im_stat_init = false
end
self.all_sys_ok = true
for i = 1, #self.units do
self.all_sys_ok = self.all_sys_ok and not self.units[i].get_control_inf().degraded
end
-------------------------
-- Run Process Control --
-------------------------
local avg_charge = self.avg_charge.compute()
local avg_inflow = self.avg_inflow.compute()
local now = util.time_s()
local state_changed = self.mode ~= self.last_mode
local next_mode = self.mode
-- once auto control is started, sort the priority sublists by limits
if state_changed then
self.saturated = false
log.debug("FAC: state changed from " .. PROCESS_NAMES[self.last_mode + 1] .. " to " .. PROCESS_NAMES[self.mode + 1])
if (self.last_mode == PROCESS.INACTIVE) or (self.last_mode == PROCESS.GEN_RATE_FAULT_IDLE) then
self.start_fail = START_STATUS.OK
if (self.mode ~= PROCESS.MATRIX_FAULT_IDLE) and (self.mode ~= PROCESS.SYSTEM_ALARM_IDLE) then
-- auto clear ASCRAM
self.ascram = false
self.ascram_reason = AUTO_SCRAM.NONE
end
local blade_count = nil
self.max_burn_combined = 0.0
for i = 1, #self.prio_defs do
table.sort(self.prio_defs[i],
---@param a reactor_unit
---@param b reactor_unit
function (a, b) return a.get_control_inf().lim_br100 < b.get_control_inf().lim_br100 end
)
for _, u in pairs(self.prio_defs[i]) do
local u_blade_count = u.get_control_inf().blade_count
if blade_count == nil then
blade_count = u_blade_count
elseif (u_blade_count ~= blade_count) and (self.mode == PROCESS.GEN_RATE) then
log.warning("FAC: cannot start GEN_RATE process with inconsistent unit blade counts")
next_mode = PROCESS.INACTIVE
self.start_fail = START_STATUS.BLADE_MISMATCH
end
if self.start_fail == START_STATUS.OK then u.a_engage() end
self.max_burn_combined = self.max_burn_combined + (u.get_control_inf().lim_br100 / 100.0)
end
end
if blade_count == nil then
-- no units
log.warning("FAC: cannot start process control with 0 units assigned")
next_mode = PROCESS.INACTIVE
self.start_fail = START_STATUS.NO_UNITS
else
self.charge_conversion = blade_count * POWER_PER_BLADE
end
elseif self.mode == PROCESS.INACTIVE then
for i = 1, #self.prio_defs do
-- SCRAM reactors and disengage auto control
-- use manual SCRAM since inactive was requested, and automatic SCRAM trips an alarm
for _, u in pairs(self.prio_defs[i]) do
u.scram()
u.a_disengage()
end
end
log.info("FAC: disengaging auto control (now inactive)")
end
self.initial_ramp = true
self.waiting_on_ramp = false
self.waiting_on_stable = false
else
self.initial_ramp = false
end
-- update unit ready state
local assign_count = 0
self.units_ready = true
for i = 1, #self.prio_defs do
for _, u in pairs(self.prio_defs[i]) do
assign_count = assign_count + 1
self.units_ready = self.units_ready and u.get_control_inf().ready
end
end
-- perform mode-specific operations
if self.mode == PROCESS.INACTIVE then
if not self.units_ready then
self.status_text = { "NOT READY", "assigned units not ready" }
else
-- clear ASCRAM once ready
self.ascram = false
self.ascram_reason = AUTO_SCRAM.NONE
if self.start_fail == START_STATUS.NO_UNITS and assign_count == 0 then
self.status_text = { "START FAILED", "no units were assigned" }
elseif self.start_fail == START_STATUS.BLADE_MISMATCH then
self.status_text = { "START FAILED", "turbine blade count mismatch" }
else
self.status_text = { "IDLE", "control disengaged" }
end
end
elseif self.mode == PROCESS.MAX_BURN then
-- run units at their limits
if state_changed then
self.time_start = now
self.saturated = true
self.status_text = { "MONITORED MODE", "running reactors at limit" }
log.info(util.c("FAC: MAX_BURN process mode started"))
end
_allocate_burn_rate(self.max_burn_combined, true)
elseif self.mode == PROCESS.BURN_RATE then
-- a total aggregate burn rate
if state_changed then
self.time_start = now
self.status_text = { "BURN RATE MODE", "running" }
log.info(util.c("FAC: BURN_RATE process mode started"))
end
local unallocated = _allocate_burn_rate(self.burn_target, true)
self.saturated = self.burn_target == self.max_burn_combined or unallocated > 0
elseif self.mode == PROCESS.CHARGE then
-- target a level of charge
if state_changed then
self.time_start = now
self.last_time = now
self.last_error = 0
self.accumulator = 0
self.status_text = { "CHARGE MODE", "running control loop" }
log.info(util.c("FAC: CHARGE mode starting PID control"))
elseif self.last_update ~= charge_update then
-- convert to kFE to make constants not microscopic
local error = util.round((self.charge_setpoint - avg_charge) / 1000) / 1000
-- stop accumulator when saturated to avoid windup
if not self.saturated then
self.accumulator = self.accumulator + (error * (now - self.last_time))
end
-- local runtime = now - self.time_start
local integral = self.accumulator
local derivative = (error - self.last_error) / (now - self.last_time)
local P = (charge_Kp * error)
local I = (charge_Ki * integral)
local D = (charge_Kd * derivative)
local output = P + I + D
-- clamp at range -> output clamped (out_c)
local out_c = math.max(0, math.min(output, self.max_burn_combined))
self.saturated = output ~= out_c
-- log.debug(util.sprintf("CHARGE[%f] { CHRG[%f] ERR[%f] INT[%f] => OUT[%f] OUT_C[%f] <= P[%f] I[%f] D[%d] }",
-- runtime, avg_charge, error, integral, output, out_c, P, I, D))
_allocate_burn_rate(out_c, true)
self.last_time = now
self.last_error = error
end
self.last_update = charge_update
elseif self.mode == PROCESS.GEN_RATE then
-- target a rate of generation
if state_changed then
-- estimate an initial output
local output = self.gen_rate_setpoint / self.charge_conversion
local unallocated = _allocate_burn_rate(output, true)
self.saturated = output >= self.max_burn_combined or unallocated > 0
self.waiting_on_ramp = true
self.status_text = { "GENERATION MODE", "starting up" }
log.info(util.c("FAC: GEN_RATE process mode initial ramp started (initial target is ", output, " mB/t)"))
elseif self.waiting_on_ramp then
if _all_units_ramped() then
self.waiting_on_ramp = false
self.waiting_on_stable = true
self.time_start = now
self.status_text = { "GENERATION MODE", "holding ramped rate" }
log.info("FAC: GEN_RATE process mode initial ramp completed, holding for stablization time")
end
elseif self.waiting_on_stable then
if (now - self.time_start) > FLOW_STABILITY_DELAY_S then
self.waiting_on_stable = false
self.time_start = now
self.last_time = now
self.last_error = 0
self.accumulator = 0
self.status_text = { "GENERATION MODE", "running control loop" }
log.info("FAC: GEN_RATE process mode initial hold completed, starting PID control")
end
elseif self.last_update ~= rate_update then
-- convert to MFE (in rounded kFE) to make constants not microscopic
local error = util.round((self.gen_rate_setpoint - avg_inflow) / 1000) / 1000
-- stop accumulator when saturated to avoid windup
if not self.saturated then
self.accumulator = self.accumulator + (error * (now - self.last_time))
end
-- local runtime = now - self.time_start
local integral = self.accumulator
local derivative = (error - self.last_error) / (now - self.last_time)
local P = (rate_Kp * error)
local I = (rate_Ki * integral)
local D = (rate_Kd * derivative)
-- velocity (rate) (derivative of charge level => rate) feed forward
local FF = self.gen_rate_setpoint / self.charge_conversion
local output = P + I + D + FF
-- clamp at range -> output clamped (sp_c)
local out_c = math.max(0, math.min(output, self.max_burn_combined))
self.saturated = output ~= out_c
-- log.debug(util.sprintf("GEN_RATE[%f] { RATE[%f] ERR[%f] INT[%f] => OUT[%f] OUT_C[%f] <= P[%f] I[%f] D[%f] }",
-- runtime, avg_inflow, error, integral, output, out_c, P, I, D))
_allocate_burn_rate(out_c, false)
self.last_time = now
self.last_error = error
end
self.last_update = rate_update
elseif self.mode == PROCESS.MATRIX_FAULT_IDLE then
-- exceeded charge, wait until condition clears
if self.ascram_reason == AUTO_SCRAM.NONE then
next_mode = self.return_mode
log.info("FAC: exiting matrix fault idle state due to fault resolution")
elseif self.ascram_reason == AUTO_SCRAM.CRIT_ALARM then
next_mode = PROCESS.SYSTEM_ALARM_IDLE
log.info("FAC: exiting matrix fault idle state due to critical unit alarm")
end
elseif self.mode == PROCESS.SYSTEM_ALARM_IDLE then
-- do nothing, wait for user to confirm (stop and reset)
elseif self.mode == PROCESS.GEN_RATE_FAULT_IDLE then
-- system faulted (degraded/not ready) while running generation rate mode
-- mode will need to be fully restarted once everything is OK to re-ramp to feed-forward
if self.units_ready then
log.info("FAC: system ready after faulting out of GEN_RATE process mode, switching back...")
next_mode = PROCESS.GEN_RATE
end
elseif self.mode ~= PROCESS.INACTIVE then
log.error(util.c("FAC: unsupported process mode ", self.mode, ", switching to inactive"))
next_mode = PROCESS.INACTIVE
end
------------------------------
-- Evaluate Automatic SCRAM --
------------------------------
local astatus = self.ascram_status
if self.induction[1] ~= nil then
local matrix = self.induction[1] ---@type unit_session
local db = matrix.get_db() ---@type imatrix_session_db
-- clear matrix disconnected
if astatus.matrix_dc then
astatus.matrix_dc = false
log.info("FAC: induction matrix reconnected, clearing ASCRAM condition")
end
-- check matrix fill too high
local was_fill = astatus.matrix_fill
astatus.matrix_fill = (db.tanks.energy_fill >= ALARM_LIMS.CHARGE_HIGH) or (astatus.matrix_fill and db.tanks.energy_fill > ALARM_LIMS.CHARGE_RE_ENABLE)
if was_fill and not astatus.matrix_fill then
log.info("FAC: charge state of induction matrix entered acceptable range <= " .. (ALARM_LIMS.CHARGE_RE_ENABLE * 100) .. "%")
end
-- check for critical unit alarms
astatus.crit_alarm = false
for i = 1, #self.units do
local u = self.units[i] ---@type reactor_unit
if u.has_alarm_min_prio(PRIO.CRITICAL) then
astatus.crit_alarm = true
break
end
end
-- check for facility radiation
if self.envd[1] ~= nil then
local envd = self.envd[1] ---@type unit_session
local e_db = envd.get_db() ---@type envd_session_db
astatus.radiation = e_db.radiation_raw > ALARM_LIMS.FAC_HIGH_RAD
else
-- don't clear, if it is true then we lost it with high radiation, so just keep alarming
-- operator can restart the system or hit the stop/reset button
end
-- system not ready, will need to restart GEN_RATE mode
-- clears when we enter the fault waiting state
astatus.gen_fault = self.mode == PROCESS.GEN_RATE and not self.units_ready
else
astatus.matrix_dc = true
end
if (self.mode ~= PROCESS.INACTIVE) and (self.mode ~= PROCESS.SYSTEM_ALARM_IDLE) then
local scram = astatus.matrix_dc or astatus.matrix_fill or astatus.crit_alarm or astatus.gen_fault
if scram and not self.ascram then
-- SCRAM all units
for i = 1, #self.prio_defs do
for _, u in pairs(self.prio_defs[i]) do
u.a_scram()
end
end
if astatus.crit_alarm then
-- highest priority alarm
next_mode = PROCESS.SYSTEM_ALARM_IDLE
self.ascram_reason = AUTO_SCRAM.CRIT_ALARM
self.status_text = { "AUTOMATIC SCRAM", "critical unit alarm tripped" }
log.info("FAC: automatic SCRAM due to critical unit alarm")
log.warning("FAC: emergency exit of process control due to critical unit alarm")
elseif astatus.radiation then
next_mode = PROCESS.SYSTEM_ALARM_IDLE
self.ascram_reason = AUTO_SCRAM.RADIATION
self.status_text = { "AUTOMATIC SCRAM", "facility radiation high" }
log.info("FAC: automatic SCRAM due to high facility radiation")
elseif astatus.matrix_dc then
next_mode = PROCESS.MATRIX_FAULT_IDLE
self.ascram_reason = AUTO_SCRAM.MATRIX_DC
self.status_text = { "AUTOMATIC SCRAM", "induction matrix disconnected" }
if self.mode ~= PROCESS.MATRIX_FAULT_IDLE then self.return_mode = self.mode end
log.info("FAC: automatic SCRAM due to induction matrix disconnection")
elseif astatus.matrix_fill then
next_mode = PROCESS.MATRIX_FAULT_IDLE
self.ascram_reason = AUTO_SCRAM.MATRIX_FILL
self.status_text = { "AUTOMATIC SCRAM", "induction matrix fill high" }
if self.mode ~= PROCESS.MATRIX_FAULT_IDLE then self.return_mode = self.mode end
log.info("FAC: automatic SCRAM due to induction matrix high charge")
elseif astatus.gen_fault then
-- lowest priority alarm
next_mode = PROCESS.GEN_RATE_FAULT_IDLE
self.ascram_reason = AUTO_SCRAM.GEN_FAULT
self.status_text = { "GENERATION MODE IDLE", "paused: system not ready" }
log.info("FAC: automatic SCRAM due to unit problem while in GEN_RATE mode, will resume once all units are ready")
end
end
self.ascram = scram
if not self.ascram then
self.ascram_reason = AUTO_SCRAM.NONE
-- reset PLC RPS trips if we should
for i = 1, #self.units do
local u = self.units[i] ---@type reactor_unit
u.a_cond_rps_reset()
end
end
end
-- update last mode and set next mode
self.last_mode = self.mode
self.mode = next_mode
-------------------------
-- Handle Redstone I/O --
-------------------------
if #self.redstone > 0 then
-- handle facility SCRAM
if self.io_ctl.digital_read(IO.F_SCRAM) then
for i = 1, #self.units do
local u = self.units[i] ---@type reactor_unit
u.cond_scram()
end
end
-- handle facility ack
if self.io_ctl.digital_read(IO.F_ACK) then public.ack_all() end
-- update facility alarm output (check if emergency+ alarms are active)
local has_alarm = false
for i = 1, #self.units do
local u = self.units[i] ---@type reactor_unit
if u.has_alarm_min_prio(PRIO.EMERGENCY) then
has_alarm = true
break
end
end
self.io_ctl.digital_write(IO.F_ALARM, has_alarm)
end
end
-- call the update function of all units in the facility
function public.update_units()
for i = 1, #self.units do
local u = self.units[i] ---@type reactor_unit
u.update()
end
end
-- COMMANDS --
-- SCRAM all reactor units
function public.scram_all()
for i = 1, #self.units do
local u = self.units[i] ---@type reactor_unit
u.scram()
end
end
-- ack all alarms on all reactor units
function public.ack_all()
for i = 1, #self.units do
local u = self.units[i] ---@type reactor_unit
u.ack_all()
end
end
-- stop auto control
function public.auto_stop()
self.mode = PROCESS.INACTIVE
end
-- set automatic control configuration and start the process
---@param config coord_auto_config configuration
---@return table response ready state (successfully started) and current configuration (after updating)
function public.auto_start(config)
local ready = false
-- load up current limits
local limits = {}
for i = 1, num_reactors do
local u = self.units[i] ---@type reactor_unit
limits[i] = u.get_control_inf().lim_br100 * 100
end
-- only allow changes if not running
if self.mode == PROCESS.INACTIVE then
if (type(config.mode) == "number") and (config.mode > PROCESS.INACTIVE) and (config.mode <= PROCESS.GEN_RATE) then
self.mode_set = config.mode
end
if (type(config.burn_target) == "number") and config.burn_target >= 0.1 then
self.burn_target = config.burn_target
end
if (type(config.charge_target) == "number") and config.charge_target >= 0 then
self.charge_setpoint = config.charge_target * 1000000 -- convert MFE to FE
end
if (type(config.gen_target) == "number") and config.gen_target >= 0 then
self.gen_rate_setpoint = config.gen_target * 1000 -- convert kFE to FE
end
if (type(config.limits) == "table") and (#config.limits == num_reactors) then
for i = 1, num_reactors do
local limit = config.limits[i]
if (type(limit) == "number") and (limit >= 0.1) then
limits[i] = limit
self.units[i].set_burn_limit(limit)
end
end
end
ready = self.mode_set > 0
if (self.mode_set == PROCESS.CHARGE) and (self.charge_setpoint <= 0) then
ready = false
elseif (self.mode_set == PROCESS.GEN_RATE) and (self.gen_rate_setpoint <= 0) then
ready = false
elseif (self.mode_set == PROCESS.BURN_RATE) and (self.burn_target < 0.1) then
ready = false
end
ready = ready and self.units_ready
if ready then self.mode = self.mode_set end
end
return { ready, self.mode_set, self.burn_target, self.charge_setpoint, self.gen_rate_setpoint, limits }
end
-- SETTINGS --
-- set the automatic control group of a unit
---@param unit_id integer unit ID
---@param group integer group ID or 0 for independent
function public.set_group(unit_id, group)
if group >= 0 and group <= 4 and self.mode == PROCESS.INACTIVE then
-- remove from old group if previously assigned
local old_group = self.group_map[unit_id]
if old_group ~= 0 then
util.filter_table(self.prio_defs[old_group], function (u) return u.get_id() ~= unit_id end)
end
self.group_map[unit_id] = group
-- add to group if not independent
if group > 0 then
table.insert(self.prio_defs[group], self.units[unit_id])
end
end
end
-- READ STATES/PROPERTIES --
-- get build properties of all machines
---@nodiscard
---@param inc_imatrix boolean? true/nil to include induction matrix build, false to exclude
function public.get_build(inc_imatrix)
local build = {}
if inc_imatrix ~= false then
build.induction = {}
for i = 1, #self.induction do
local matrix = self.induction[i] ---@type unit_session
build.induction[matrix.get_device_idx()] = { matrix.get_db().formed, matrix.get_db().build }
end
end
return build
end
-- get automatic process control status
---@nodiscard
function public.get_control_status()
local astat = self.ascram_status
return {
self.all_sys_ok,
self.units_ready,
self.mode,
self.waiting_on_ramp or self.waiting_on_stable,
self.at_max_burn or self.saturated,
self.ascram,
astat.matrix_dc,
astat.matrix_fill,
astat.crit_alarm,
astat.radiation,
astat.gen_fault or self.mode == PROCESS.GEN_RATE_FAULT_IDLE,
self.status_text[1],
self.status_text[2],
self.group_map
}
end
-- get RTU statuses
---@nodiscard
function public.get_rtu_statuses()
local status = {}
-- total count of all connected RTUs in the facility
status.count = self.rtu_conn_count
-- power averages from induction matricies
status.power = {
self.avg_charge.compute(),
self.avg_inflow.compute(),
self.avg_outflow.compute()
}
-- status of induction matricies (including tanks)
status.induction = {}
for i = 1, #self.induction do
local matrix = self.induction[i] ---@type unit_session
status.induction[matrix.get_device_idx()] = {
matrix.is_faulted(),
matrix.get_db().formed,
matrix.get_db().state,
matrix.get_db().tanks
}
end
-- radiation monitors (environment detectors)
status.rad_mon = {}
for i = 1, #self.envd do
local envd = self.envd[i] ---@type unit_session
status.rad_mon[envd.get_device_idx()] = {
envd.is_faulted(),
envd.get_db().radiation
}
end
return status
end
-- get the units in this facility
---@nodiscard
function public.get_units() return self.units end
return public
end
return facility

View File

@@ -1,13 +1,23 @@
local comms = require("scada-common.comms") local comms = require("scada-common.comms")
local log = require("scada-common.log") local log = require("scada-common.log")
local mqueue = require("scada-common.mqueue") local mqueue = require("scada-common.mqueue")
local util = require("scada-common.util") local types = require("scada-common.types")
local util = require("scada-common.util")
local svqtypes = require("supervisor.session.svqtypes")
local coordinator = {} local coordinator = {}
local PROTOCOLS = comms.PROTOCOLS local PROTOCOL = comms.PROTOCOL
local SCADA_MGMT_TYPES = comms.SCADA_MGMT_TYPES local SCADA_MGMT_TYPE = comms.SCADA_MGMT_TYPE
local SCADA_CRDN_TYPES = comms.SCADA_CRDN_TYPES local SCADA_CRDN_TYPE = comms.SCADA_CRDN_TYPE
local UNIT_COMMAND = comms.UNIT_COMMAND
local FAC_COMMAND = comms.FAC_COMMAND
local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE
local SV_Q_CMDS = svqtypes.SV_Q_CMDS
local SV_Q_DATA = svqtypes.SV_Q_DATA
local print = util.print local print = util.print
local println = util.println local println = util.println
@@ -17,6 +27,19 @@ local println_ts = util.println_ts
-- retry time constants in ms -- retry time constants in ms
local INITIAL_WAIT = 1500 local INITIAL_WAIT = 1500
local RETRY_PERIOD = 1000 local RETRY_PERIOD = 1000
local PARTIAL_RETRY_PERIOD = 2000
local CRD_S_CMDS = {
}
local CRD_S_DATA = {
CMD_ACK = 1,
RESEND_PLC_BUILD = 2,
RESEND_RTU_BUILD = 3
}
coordinator.CRD_S_CMDS = CRD_S_CMDS
coordinator.CRD_S_DATA = CRD_S_DATA
local PERIODICS = { local PERIODICS = {
KEEP_ALIVE = 2000, KEEP_ALIVE = 2000,
@@ -24,23 +47,22 @@ local PERIODICS = {
} }
-- coordinator supervisor session -- coordinator supervisor session
---@param id integer ---@nodiscard
---@param in_queue mqueue ---@param id integer session ID
---@param out_queue mqueue ---@param in_queue mqueue in message queue
---@param facility_units table ---@param out_queue mqueue out message queue
function coordinator.new_session(id, in_queue, out_queue, facility_units) ---@param timeout number communications timeout
---@param facility facility facility data table
function coordinator.new_session(id, in_queue, out_queue, timeout, facility)
local log_header = "crdn_session(" .. id .. "): " local log_header = "crdn_session(" .. id .. "): "
local self = { local self = {
id = id, units = facility.get_units(),
in_q = in_queue,
out_q = out_queue,
units = facility_units,
-- connection properties -- connection properties
seq_num = 0, seq_num = 0,
r_seq_num = nil, r_seq_num = nil,
connected = true, connected = true,
conn_watchdog = util.new_watchdog(3), conn_watchdog = util.new_watchdog(timeout),
last_rtt = 0, last_rtt = 0,
-- periodic messages -- periodic messages
periodics = { periodics = {
@@ -50,11 +72,15 @@ function coordinator.new_session(id, in_queue, out_queue, facility_units)
}, },
-- when to next retry one of these messages -- when to next retry one of these messages
retry_times = { retry_times = {
builds_packet = (util.time() + 500) builds_packet = 0,
f_builds_packet = 0,
u_builds_packet = 0
}, },
-- message acknowledgements -- message acknowledgements
acks = { acks = {
builds = true builds = false,
fac_builds = false,
unit_builds = false
} }
} }
@@ -65,37 +91,52 @@ function coordinator.new_session(id, in_queue, out_queue, facility_units)
end end
-- send a CRDN packet -- send a CRDN packet
---@param msg_type SCADA_CRDN_TYPES ---@param msg_type SCADA_CRDN_TYPE
---@param msg table ---@param msg table
local function _send(msg_type, msg) local function _send(msg_type, msg)
local s_pkt = comms.scada_packet() local s_pkt = comms.scada_packet()
local c_pkt = comms.crdn_packet() local c_pkt = comms.crdn_packet()
c_pkt.make(msg_type, msg) c_pkt.make(msg_type, msg)
s_pkt.make(self.seq_num, PROTOCOLS.SCADA_CRDN, c_pkt.raw_sendable()) s_pkt.make(self.seq_num, PROTOCOL.SCADA_CRDN, c_pkt.raw_sendable())
self.out_q.push_packet(s_pkt) out_queue.push_packet(s_pkt)
self.seq_num = self.seq_num + 1 self.seq_num = self.seq_num + 1
end end
-- send a SCADA management packet -- send a SCADA management packet
---@param msg_type SCADA_MGMT_TYPES ---@param msg_type SCADA_MGMT_TYPE
---@param msg table ---@param msg table
local function _send_mgmt(msg_type, msg) local function _send_mgmt(msg_type, msg)
local s_pkt = comms.scada_packet() local s_pkt = comms.scada_packet()
local m_pkt = comms.mgmt_packet() local m_pkt = comms.mgmt_packet()
m_pkt.make(msg_type, msg) m_pkt.make(msg_type, msg)
s_pkt.make(self.seq_num, PROTOCOLS.SCADA_MGMT, m_pkt.raw_sendable()) s_pkt.make(self.seq_num, PROTOCOL.SCADA_MGMT, m_pkt.raw_sendable())
self.out_q.push_packet(s_pkt) out_queue.push_packet(s_pkt)
self.seq_num = self.seq_num + 1 self.seq_num = self.seq_num + 1
end end
-- send unit builds -- send both facility and unit builds
local function _send_builds() local function _send_all_builds()
self.acks.builds = false local unit_builds = {}
for i = 1, #self.units do
local unit = self.units[i] ---@type reactor_unit
unit_builds[unit.get_id()] = unit.get_build()
end
_send(SCADA_CRDN_TYPE.INITIAL_BUILDS, { facility.get_build(), unit_builds })
end
-- send facility builds
local function _send_fac_builds()
_send(SCADA_CRDN_TYPE.FAC_BUILDS, { facility.get_build() })
end
-- send unit builds
local function _send_unit_builds()
local builds = {} local builds = {}
for i = 1, #self.units do for i = 1, #self.units do
@@ -103,19 +144,36 @@ function coordinator.new_session(id, in_queue, out_queue, facility_units)
builds[unit.get_id()] = unit.get_build() builds[unit.get_id()] = unit.get_build()
end end
_send(SCADA_CRDN_TYPES.STRUCT_BUILDS, builds) _send(SCADA_CRDN_TYPE.UNIT_BUILDS, { builds })
end
-- send facility status
local function _send_fac_status()
local status = {
facility.get_control_status(),
facility.get_rtu_statuses()
}
_send(SCADA_CRDN_TYPE.FAC_STATUS, status)
end end
-- send unit statuses -- send unit statuses
local function _send_status() local function _send_unit_statuses()
local status = {} local status = {}
for i = 1, #self.units do for i = 1, #self.units do
local unit = self.units[i] ---@type reactor_unit local unit = self.units[i] ---@type reactor_unit
status[unit.get_id()] = { unit.get_reactor_status(), unit.get_annunciator(), unit.get_rtu_statuses() }
status[unit.get_id()] = {
unit.get_reactor_status(),
unit.get_rtu_statuses(),
unit.get_annunciator(),
unit.get_alarms(),
unit.get_state()
}
end end
_send(SCADA_CRDN_TYPES.UNIT_STATUSES, status) _send(SCADA_CRDN_TYPE.UNIT_STATUSES, status)
end end
-- handle a packet -- handle a packet
@@ -135,8 +193,8 @@ function coordinator.new_session(id, in_queue, out_queue, facility_units)
self.conn_watchdog.feed() self.conn_watchdog.feed()
-- process packet -- process packet
if pkt.scada_frame.protocol() == PROTOCOLS.SCADA_MGMT then if pkt.scada_frame.protocol() == PROTOCOL.SCADA_MGMT then
if pkt.type == SCADA_MGMT_TYPES.KEEP_ALIVE then if pkt.type == SCADA_MGMT_TYPE.KEEP_ALIVE then
-- keep alive reply -- keep alive reply
if pkt.length == 2 then if pkt.length == 2 then
local srv_start = pkt.data[1] local srv_start = pkt.data[1]
@@ -144,8 +202,8 @@ function coordinator.new_session(id, in_queue, out_queue, facility_units)
local srv_now = util.time() local srv_now = util.time()
self.last_rtt = srv_now - srv_start self.last_rtt = srv_now - srv_start
if self.last_rtt > 500 then if self.last_rtt > 750 then
log.warning(log_header .. "COORD KEEP_ALIVE round trip time > 500ms (" .. self.last_rtt .. "ms)") log.warning(log_header .. "COORD KEEP_ALIVE round trip time > 750ms (" .. self.last_rtt .. "ms)")
end end
-- log.debug(log_header .. "COORD RTT = " .. self.last_rtt .. "ms") -- log.debug(log_header .. "COORD RTT = " .. self.last_rtt .. "ms")
@@ -153,16 +211,118 @@ function coordinator.new_session(id, in_queue, out_queue, facility_units)
else else
log.debug(log_header .. "SCADA keep alive packet length mismatch") log.debug(log_header .. "SCADA keep alive packet length mismatch")
end end
elseif pkt.type == SCADA_MGMT_TYPES.CLOSE then elseif pkt.type == SCADA_MGMT_TYPE.CLOSE then
-- close the session -- close the session
_close() _close()
else else
log.debug(log_header .. "handler received unsupported SCADA_MGMT packet type " .. pkt.type) log.debug(log_header .. "handler received unsupported SCADA_MGMT packet type " .. pkt.type)
end end
elseif pkt.scada_frame.protocol() == PROTOCOLS.SCADA_CRDN then elseif pkt.scada_frame.protocol() == PROTOCOL.SCADA_CRDN then
if pkt.type == SCADA_CRDN_TYPES.STRUCT_BUILDS then if pkt.type == SCADA_CRDN_TYPE.INITIAL_BUILDS then
-- acknowledgement to coordinator receiving builds -- acknowledgement to coordinator receiving builds
self.acks.builds = true self.acks.builds = true
elseif pkt.type == SCADA_CRDN_TYPE.FAC_BUILDS then
-- acknowledgement to coordinator receiving builds
self.acks.fac_builds = true
elseif pkt.type == SCADA_CRDN_TYPE.FAC_CMD then
if pkt.length >= 1 then
local cmd = pkt.data[1]
if cmd == FAC_COMMAND.SCRAM_ALL then
facility.scram_all()
_send(SCADA_CRDN_TYPE.FAC_CMD, { cmd, true })
elseif cmd == FAC_COMMAND.STOP then
facility.auto_stop()
_send(SCADA_CRDN_TYPE.FAC_CMD, { cmd, true })
elseif cmd == FAC_COMMAND.START then
if pkt.length == 6 then
---@type coord_auto_config
local config = {
mode = pkt.data[2],
burn_target = pkt.data[3],
charge_target = pkt.data[4],
gen_target = pkt.data[5],
limits = pkt.data[6]
}
_send(SCADA_CRDN_TYPE.FAC_CMD, { cmd, table.unpack(facility.auto_start(config)) })
else
log.debug(log_header .. "CRDN auto start (with configuration) packet length mismatch")
end
elseif cmd == FAC_COMMAND.ACK_ALL_ALARMS then
facility.ack_all()
_send(SCADA_CRDN_TYPE.FAC_CMD, { cmd, true })
else
log.debug(log_header .. "CRDN facility command unknown")
end
else
log.debug(log_header .. "CRDN facility command packet length mismatch")
end
elseif pkt.type == SCADA_CRDN_TYPE.UNIT_BUILDS then
-- acknowledgement to coordinator receiving builds
self.acks.unit_builds = true
elseif pkt.type == SCADA_CRDN_TYPE.UNIT_CMD then
if pkt.length >= 2 then
-- get command and unit id
local cmd = pkt.data[1]
local uid = pkt.data[2]
-- pkt.data[3] will be nil except for some commands
local data = { uid, pkt.data[3] }
-- continue if valid unit id
if util.is_int(uid) and uid > 0 and uid <= #self.units then
local unit = self.units[uid] ---@type reactor_unit
if cmd == UNIT_COMMAND.START then
out_queue.push_data(SV_Q_DATA.START, data)
elseif cmd == UNIT_COMMAND.SCRAM then
out_queue.push_data(SV_Q_DATA.SCRAM, data)
elseif cmd == UNIT_COMMAND.RESET_RPS then
out_queue.push_data(SV_Q_DATA.RESET_RPS, data)
elseif cmd == UNIT_COMMAND.SET_BURN then
if pkt.length == 3 then
out_queue.push_data(SV_Q_DATA.SET_BURN, data)
else
log.debug(log_header .. "CRDN unit command burn rate missing option")
end
elseif cmd == UNIT_COMMAND.SET_WASTE then
if (pkt.length == 3) and (type(pkt.data[3]) == "number") and (pkt.data[3] > 0) and (pkt.data[3] <= 4) then
unit.set_waste(pkt.data[3])
else
log.debug(log_header .. "CRDN unit command set waste missing option")
end
elseif cmd == UNIT_COMMAND.ACK_ALL_ALARMS then
unit.ack_all()
_send(SCADA_CRDN_TYPE.UNIT_CMD, { cmd, uid, true })
elseif cmd == UNIT_COMMAND.ACK_ALARM then
if pkt.length == 3 then
unit.ack_alarm(pkt.data[3])
else
log.debug(log_header .. "CRDN unit command ack alarm missing alarm id")
end
elseif cmd == UNIT_COMMAND.RESET_ALARM then
if pkt.length == 3 then
unit.reset_alarm(pkt.data[3])
else
log.debug(log_header .. "CRDN unit command reset alarm missing alarm id")
end
elseif cmd == UNIT_COMMAND.SET_GROUP then
if (pkt.length == 3) and (type(pkt.data[3]) == "number") and (pkt.data[3] >= 0) and (pkt.data[3] <= 4) then
facility.set_group(unit.get_id(), pkt.data[3])
_send(SCADA_CRDN_TYPE.UNIT_CMD, { cmd, uid, pkt.data[3] })
else
log.debug(log_header .. "CRDN unit command set group missing group id")
end
else
log.debug(log_header .. "CRDN unit command unknown")
end
else
log.debug(log_header .. "CRDN unit command invalid")
end
else
log.debug(log_header .. "CRDN unit command packet length mismatch")
end
else else
log.debug(log_header .. "handler received unexpected SCADA_CRDN packet type " .. pkt.type) log.debug(log_header .. "handler received unexpected SCADA_CRDN packet type " .. pkt.type)
end end
@@ -173,9 +333,11 @@ function coordinator.new_session(id, in_queue, out_queue, facility_units)
local public = {} local public = {}
-- get the session ID -- get the session ID
function public.get_id() return self.id end ---@nodiscard
function public.get_id() return id end
-- check if a timer matches this session's watchdog -- check if a timer matches this session's watchdog
---@nodiscard
function public.check_wd(timer) function public.check_wd(timer)
return self.conn_watchdog.is_timer(timer) and self.connected return self.conn_watchdog.is_timer(timer) and self.connected
end end
@@ -183,12 +345,13 @@ function coordinator.new_session(id, in_queue, out_queue, facility_units)
-- close the connection -- close the connection
function public.close() function public.close()
_close() _close()
_send_mgmt(SCADA_MGMT_TYPES.CLOSE, {}) _send_mgmt(SCADA_MGMT_TYPE.CLOSE, {})
println("connection to coordinator " .. self.id .. " closed by server") println("connection to coordinator " .. id .. " closed by server")
log.info(log_header .. "session closed by server") log.info(log_header .. "session closed by server")
end end
-- iterate the session -- iterate the session
---@nodiscard
---@return boolean connected ---@return boolean connected
function public.iterate() function public.iterate()
if self.connected then if self.connected then
@@ -198,9 +361,9 @@ function coordinator.new_session(id, in_queue, out_queue, facility_units)
local handle_start = util.time() local handle_start = util.time()
while self.in_q.ready() and self.connected do while in_queue.ready() and self.connected do
-- get a new message to process -- get a new message to process
local message = self.in_q.pop() local message = in_queue.pop()
if message ~= nil then if message ~= nil then
if message.qtype == mqueue.TYPE.PACKET then if message.qtype == mqueue.TYPE.PACKET then
@@ -210,6 +373,49 @@ function coordinator.new_session(id, in_queue, out_queue, facility_units)
-- handle instruction -- handle instruction
elseif message.qtype == mqueue.TYPE.DATA then elseif message.qtype == mqueue.TYPE.DATA then
-- instruction with body -- instruction with body
local cmd = message.message ---@type queue_data
if cmd.key == CRD_S_DATA.CMD_ACK then
local ack = cmd.val ---@type coord_ack
_send(SCADA_CRDN_TYPE.UNIT_CMD, { ack.cmd, ack.unit, ack.ack })
elseif cmd.key == CRD_S_DATA.RESEND_PLC_BUILD then
-- re-send PLC build
-- retry logic will be kept as-is, so as long as no retry is needed, this will be a small update
self.retry_times.builds_packet = util.time() + PARTIAL_RETRY_PERIOD
self.acks.unit_builds = false
local unit_id = cmd.val
local builds = {}
local unit = self.units[unit_id] ---@type reactor_unit
builds[unit_id] = unit.get_build(true, false, false)
_send(SCADA_CRDN_TYPE.UNIT_BUILDS, { builds })
elseif cmd.key == CRD_S_DATA.RESEND_RTU_BUILD then
local unit_id = cmd.val.unit
if unit_id > 0 then
-- re-send unit RTU builds
-- retry logic will be kept as-is, so as long as no retry is needed, this will be a small update
self.retry_times.u_builds_packet = util.time() + PARTIAL_RETRY_PERIOD
self.acks.unit_builds = false
local builds = {}
local unit = self.units[unit_id] ---@type reactor_unit
builds[unit_id] = unit.get_build(false, cmd.val.type == RTU_UNIT_TYPE.BOILER_VALVE, cmd.val.type == RTU_UNIT_TYPE.TURBINE_VALVE)
_send(SCADA_CRDN_TYPE.UNIT_BUILDS, { builds })
else
-- re-send facility RTU builds
-- retry logic will be kept as-is, so as long as no retry is needed, this will be a small update
self.retry_times.f_builds_packet = util.time() + PARTIAL_RETRY_PERIOD
self.acks.fac_builds = false
_send(SCADA_CRDN_TYPE.FAC_BUILDS, { facility.get_build(cmd.val.type == RTU_UNIT_TYPE.IMATRIX) })
end
else
log.warning(log_header .. "unsupported data command received in in_queue (this is a bug)")
end
end end
end end
@@ -222,7 +428,7 @@ function coordinator.new_session(id, in_queue, out_queue, facility_units)
-- exit if connection was closed -- exit if connection was closed
if not self.connected then if not self.connected then
println("connection to coordinator " .. self.id .. " closed by remote host") println("connection to coordinator closed by remote host")
log.info(log_header .. "session closed by remote host") log.info(log_header .. "session closed by remote host")
return self.connected return self.connected
end end
@@ -239,15 +445,16 @@ function coordinator.new_session(id, in_queue, out_queue, facility_units)
periodics.keep_alive = periodics.keep_alive + elapsed periodics.keep_alive = periodics.keep_alive + elapsed
if periodics.keep_alive >= PERIODICS.KEEP_ALIVE then if periodics.keep_alive >= PERIODICS.KEEP_ALIVE then
_send_mgmt(SCADA_MGMT_TYPES.KEEP_ALIVE, { util.time() }) _send_mgmt(SCADA_MGMT_TYPE.KEEP_ALIVE, { util.time() })
periodics.keep_alive = 0 periodics.keep_alive = 0
end end
-- unit statuses to coordinator -- statuses to coordinator
periodics.status_packet = periodics.status_packet + elapsed periodics.status_packet = periodics.status_packet + elapsed
if periodics.status_packet >= PERIODICS.STATUS then if periodics.status_packet >= PERIODICS.STATUS then
_send_status() _send_fac_status()
_send_unit_statuses()
periodics.status_packet = 0 periodics.status_packet = 0
end end
@@ -259,14 +466,28 @@ function coordinator.new_session(id, in_queue, out_queue, facility_units)
local rtimes = self.retry_times local rtimes = self.retry_times
-- builds packet retry -- builds packet retries
if not self.acks.builds then if not self.acks.builds then
if rtimes.builds_packet - util.time() <= 0 then if rtimes.builds_packet - util.time() <= 0 then
_send_builds() _send_all_builds()
rtimes.builds_packet = util.time() + RETRY_PERIOD rtimes.builds_packet = util.time() + RETRY_PERIOD
end end
end end
if not self.acks.fac_builds then
if rtimes.f_builds_packet - util.time() <= 0 then
_send_fac_builds()
rtimes.f_builds_packet = util.time() + RETRY_PERIOD
end
end
if not self.acks.unit_builds then
if rtimes.u_builds_packet - util.time() <= 0 then
_send_unit_builds()
rtimes.u_builds_packet = util.time() + RETRY_PERIOD
end
end
end end
return self.connected return self.connected

View File

@@ -1,13 +1,18 @@
local comms = require("scada-common.comms") local comms = require("scada-common.comms")
local log = require("scada-common.log") local log = require("scada-common.log")
local mqueue = require("scada-common.mqueue") local mqueue = require("scada-common.mqueue")
local util = require("scada-common.util") local types = require("scada-common.types")
local util = require("scada-common.util")
local svqtypes = require("supervisor.session.svqtypes")
local plc = {} local plc = {}
local PROTOCOLS = comms.PROTOCOLS local PROTOCOL = comms.PROTOCOL
local RPLC_TYPES = comms.RPLC_TYPES local RPLC_TYPE = comms.RPLC_TYPE
local SCADA_MGMT_TYPES = comms.SCADA_MGMT_TYPES local SCADA_MGMT_TYPE = comms.SCADA_MGMT_TYPE
local PLC_AUTO_ACK = comms.PLC_AUTO_ACK
local UNIT_COMMAND = comms.UNIT_COMMAND
local print = util.print local print = util.print
local println = util.println local println = util.println
@@ -15,18 +20,22 @@ local print_ts = util.print_ts
local println_ts = util.println_ts local println_ts = util.println_ts
-- retry time constants in ms -- retry time constants in ms
local INITIAL_WAIT = 1500 local INITIAL_WAIT = 1500
local RETRY_PERIOD = 1000 local INITIAL_AUTO_WAIT = 1000
local RETRY_PERIOD = 1000
local PLC_S_CMDS = { local PLC_S_CMDS = {
SCRAM = 0, SCRAM = 1,
ENABLE = 1, ASCRAM = 2,
RPS_RESET = 2 ENABLE = 3,
RPS_RESET = 4,
RPS_AUTO_RESET = 5
} }
local PLC_S_DATA = { local PLC_S_DATA = {
BURN_RATE = 1, BURN_RATE = 1,
RAMP_BURN_RATE = 2 RAMP_BURN_RATE = 2,
AUTO_BURN_RATE = 3
} }
plc.PLC_S_CMDS = PLC_S_CMDS plc.PLC_S_CMDS = PLC_S_CMDS
@@ -37,28 +46,28 @@ local PERIODICS = {
} }
-- PLC supervisor session -- PLC supervisor session
---@param id integer ---@nodiscard
---@param for_reactor integer ---@param id integer session ID
---@param in_queue mqueue ---@param reactor_id integer reactor ID
---@param out_queue mqueue ---@param in_queue mqueue in message queue
function plc.new_session(id, for_reactor, in_queue, out_queue) ---@param out_queue mqueue out message queue
---@param timeout number communications timeout
function plc.new_session(id, reactor_id, in_queue, out_queue, timeout)
local log_header = "plc_session(" .. id .. "): " local log_header = "plc_session(" .. id .. "): "
local self = { local self = {
id = id,
for_reactor = for_reactor,
in_q = in_queue,
out_q = out_queue,
commanded_state = false, commanded_state = false,
commanded_burn_rate = 0.0, commanded_burn_rate = 0.0,
auto_cmd_token = 0,
ramping_rate = false, ramping_rate = false,
auto_lock = false,
-- connection properties -- connection properties
seq_num = 0, seq_num = 0,
r_seq_num = nil, r_seq_num = nil,
connected = true, connected = true,
received_struct = false, received_struct = false,
received_status_cache = false, received_status_cache = false,
plc_conn_watchdog = util.new_watchdog(3), plc_conn_watchdog = util.new_watchdog(timeout),
last_rtt = 0, last_rtt = 0,
-- periodic messages -- periodic messages
periodics = { periodics = {
@@ -70,36 +79,41 @@ function plc.new_session(id, for_reactor, in_queue, out_queue)
struct_req = (util.time() + 500), struct_req = (util.time() + 500),
status_req = (util.time() + 500), status_req = (util.time() + 500),
scram_req = 0, scram_req = 0,
enable_req = 0, ascram_req = 0,
burn_rate_req = 0, burn_rate_req = 0,
rps_reset_req = 0 rps_reset_req = 0
}, },
-- command acknowledgements -- command acknowledgements
acks = { acks = {
scram = true, scram = true,
enable = true, ascram = true,
burn_rate = true, burn_rate = true,
rps_reset = true rps_reset = true
}, },
-- session database -- session database
---@class reactor_db ---@class reactor_db
sDB = { sDB = {
auto_ack_token = 0,
last_status_update = 0, last_status_update = 0,
control_state = false, control_state = false,
degraded = false, no_reactor = false,
formed = false,
rps_tripped = false, rps_tripped = false,
rps_trip_cause = "ok", ---@type rps_trip_cause rps_trip_cause = "ok", ---@type rps_trip_cause
---@class rps_status ---@class rps_status
rps_status = { rps_status = {
dmg_crit = false, high_dmg = false,
ex_hcool = false,
ex_waste = false,
high_temp = false, high_temp = false,
low_cool = false,
ex_waste = false,
ex_hcool = false,
no_fuel = false, no_fuel = false,
no_cool = false,
fault = false, fault = false,
timeout = false, timeout = false,
manual = false manual = false,
automatic = false,
sys_fail = false,
force_dis = false
}, },
---@class mek_status ---@class mek_status
mek_status = { mek_status = {
@@ -130,6 +144,11 @@ function plc.new_session(id, for_reactor, in_queue, out_queue)
}, },
---@class mek_struct ---@class mek_struct
mek_struct = { mek_struct = {
length = 0,
width = 0,
height = 0,
min_pos = types.new_zero_coordinate(),
max_pos = types.new_zero_coordinate(),
heat_cap = 0, heat_cap = 0,
fuel_asm = 0, fuel_asm = 0,
fuel_sa = 0, fuel_sa = 0,
@@ -148,15 +167,20 @@ function plc.new_session(id, for_reactor, in_queue, out_queue)
-- copy in the RPS status -- copy in the RPS status
---@param rps_status table ---@param rps_status table
local function _copy_rps_status(rps_status) local function _copy_rps_status(rps_status)
self.sDB.rps_status.dmg_crit = rps_status[1] self.sDB.rps_tripped = rps_status[1]
self.sDB.rps_status.ex_hcool = rps_status[2] self.sDB.rps_trip_cause = rps_status[2]
self.sDB.rps_status.ex_waste = rps_status[3] self.sDB.rps_status.high_dmg = rps_status[3]
self.sDB.rps_status.high_temp = rps_status[4] self.sDB.rps_status.high_temp = rps_status[4]
self.sDB.rps_status.no_fuel = rps_status[5] self.sDB.rps_status.low_cool = rps_status[5]
self.sDB.rps_status.no_cool = rps_status[6] self.sDB.rps_status.ex_waste = rps_status[6]
self.sDB.rps_status.fault = rps_status[7] self.sDB.rps_status.ex_hcool = rps_status[7]
self.sDB.rps_status.timeout = rps_status[8] self.sDB.rps_status.no_fuel = rps_status[8]
self.sDB.rps_status.manual = rps_status[9] self.sDB.rps_status.fault = rps_status[9]
self.sDB.rps_status.timeout = rps_status[10]
self.sDB.rps_status.manual = rps_status[11]
self.sDB.rps_status.automatic = rps_status[12]
self.sDB.rps_status.sys_fail = rps_status[13]
self.sDB.rps_status.force_dis = rps_status[14]
end end
-- copy in the reactor status -- copy in the reactor status
@@ -187,7 +211,7 @@ function plc.new_session(id, for_reactor, in_queue, out_queue)
if self.received_struct then if self.received_struct then
self.sDB.mek_status.fuel_need = self.sDB.mek_struct.fuel_cap - self.sDB.mek_status.fuel_fill self.sDB.mek_status.fuel_need = self.sDB.mek_struct.fuel_cap - self.sDB.mek_status.fuel_fill
self.sDB.mek_status.waste_need = self.sDB.mek_struct.waste_cap - self.sDB.mek_status.waste_fill self.sDB.mek_status.waste_need = self.sDB.mek_struct.waste_cap - self.sDB.mek_status.waste_fill
self.sDB.mek_status.cool_need = self.sDB.mek_struct.ccool_cap - self.sDB.mek_status.ccool_fill self.sDB.mek_status.cool_need = self.sDB.mek_struct.ccool_cap - self.sDB.mek_status.ccool_fill
self.sDB.mek_status.hcool_need = self.sDB.mek_struct.hcool_cap - self.sDB.mek_status.hcool_fill self.sDB.mek_status.hcool_need = self.sDB.mek_struct.hcool_cap - self.sDB.mek_status.hcool_fill
end end
end end
@@ -195,14 +219,19 @@ function plc.new_session(id, for_reactor, in_queue, out_queue)
-- copy in the reactor structure -- copy in the reactor structure
---@param mek_data table ---@param mek_data table
local function _copy_struct(mek_data) local function _copy_struct(mek_data)
self.sDB.mek_struct.heat_cap = mek_data[1] self.sDB.mek_struct.length = mek_data[1]
self.sDB.mek_struct.fuel_asm = mek_data[2] self.sDB.mek_struct.width = mek_data[2]
self.sDB.mek_struct.fuel_sa = mek_data[3] self.sDB.mek_struct.height = mek_data[3]
self.sDB.mek_struct.fuel_cap = mek_data[4] self.sDB.mek_struct.min_pos = mek_data[4]
self.sDB.mek_struct.waste_cap = mek_data[5] self.sDB.mek_struct.max_pos = mek_data[5]
self.sDB.mek_struct.ccool_cap = mek_data[6] self.sDB.mek_struct.heat_cap = mek_data[6]
self.sDB.mek_struct.hcool_cap = mek_data[7] self.sDB.mek_struct.fuel_asm = mek_data[7]
self.sDB.mek_struct.max_burn = mek_data[8] self.sDB.mek_struct.fuel_sa = mek_data[8]
self.sDB.mek_struct.fuel_cap = mek_data[9]
self.sDB.mek_struct.waste_cap = mek_data[10]
self.sDB.mek_struct.ccool_cap = mek_data[11]
self.sDB.mek_struct.hcool_cap = mek_data[12]
self.sDB.mek_struct.max_burn = mek_data[13]
end end
-- mark this PLC session as closed, stop watchdog -- mark this PLC session as closed, stop watchdog
@@ -212,34 +241,35 @@ function plc.new_session(id, for_reactor, in_queue, out_queue)
end end
-- send an RPLC packet -- send an RPLC packet
---@param msg_type RPLC_TYPES ---@param msg_type RPLC_TYPE
---@param msg table ---@param msg table
local function _send(msg_type, msg) local function _send(msg_type, msg)
local s_pkt = comms.scada_packet() local s_pkt = comms.scada_packet()
local r_pkt = comms.rplc_packet() local r_pkt = comms.rplc_packet()
r_pkt.make(self.id, msg_type, msg) r_pkt.make(reactor_id, msg_type, msg)
s_pkt.make(self.seq_num, PROTOCOLS.RPLC, r_pkt.raw_sendable()) s_pkt.make(self.seq_num, PROTOCOL.RPLC, r_pkt.raw_sendable())
self.out_q.push_packet(s_pkt) out_queue.push_packet(s_pkt)
self.seq_num = self.seq_num + 1 self.seq_num = self.seq_num + 1
end end
-- send a SCADA management packet -- send a SCADA management packet
---@param msg_type SCADA_MGMT_TYPES ---@param msg_type SCADA_MGMT_TYPE
---@param msg table ---@param msg table
local function _send_mgmt(msg_type, msg) local function _send_mgmt(msg_type, msg)
local s_pkt = comms.scada_packet() local s_pkt = comms.scada_packet()
local m_pkt = comms.mgmt_packet() local m_pkt = comms.mgmt_packet()
m_pkt.make(msg_type, msg) m_pkt.make(msg_type, msg)
s_pkt.make(self.seq_num, PROTOCOLS.SCADA_MGMT, m_pkt.raw_sendable()) s_pkt.make(self.seq_num, PROTOCOL.SCADA_MGMT, m_pkt.raw_sendable())
self.out_q.push_packet(s_pkt) out_queue.push_packet(s_pkt)
self.seq_num = self.seq_num + 1 self.seq_num = self.seq_num + 1
end end
-- get an ACK status -- get an ACK status
---@nodiscard
---@param pkt rplc_frame ---@param pkt rplc_frame
---@return boolean|nil ack ---@return boolean|nil ack
local function _get_ack(pkt) local function _get_ack(pkt)
@@ -265,10 +295,10 @@ function plc.new_session(id, for_reactor, in_queue, out_queue)
end end
-- process packet -- process packet
if pkt.scada_frame.protocol() == PROTOCOLS.RPLC then if pkt.scada_frame.protocol() == PROTOCOL.RPLC then
-- check reactor ID -- check reactor ID
if pkt.id ~= for_reactor then if pkt.id ~= reactor_id then
log.warning(log_header .. "RPLC packet with ID not matching reactor ID: reactor " .. self.for_reactor .. " != " .. pkt.id) log.warning(log_header .. "RPLC packet with ID not matching reactor ID: reactor " .. reactor_id .. " != " .. pkt.id)
return return
end end
@@ -276,36 +306,41 @@ function plc.new_session(id, for_reactor, in_queue, out_queue)
self.plc_conn_watchdog.feed() self.plc_conn_watchdog.feed()
-- handle packet by type -- handle packet by type
if pkt.type == RPLC_TYPES.STATUS then if pkt.type == RPLC_TYPE.STATUS then
-- status packet received, update data -- status packet received, update data
if pkt.length >= 5 then if pkt.length >= 5 then
self.sDB.last_status_update = pkt.data[1] self.sDB.last_status_update = pkt.data[1]
self.sDB.control_state = pkt.data[2] self.sDB.control_state = pkt.data[2]
self.sDB.rps_tripped = pkt.data[3] self.sDB.no_reactor = pkt.data[3]
self.sDB.degraded = pkt.data[4] self.sDB.formed = pkt.data[4]
self.sDB.mek_status.heating_rate = pkt.data[5] self.sDB.auto_ack_token = pkt.data[5]
-- attempt to read mek_data table if not self.sDB.no_reactor and self.sDB.formed then
if pkt.data[6] ~= nil then self.sDB.mek_status.heating_rate = pkt.data[6] or 0.0
local status = pcall(_copy_status, pkt.data[6])
if status then -- attempt to read mek_data table
-- copied in status data OK if pkt.data[7] ~= nil then
self.received_status_cache = true local status = pcall(_copy_status, pkt.data[7])
else if status then
-- error copying status data -- copied in status data OK
log.error(log_header .. "failed to parse status packet data") self.received_status_cache = true
else
-- error copying status data
log.error(log_header .. "failed to parse status packet data")
end
end end
end end
else else
log.debug(log_header .. "RPLC status packet length mismatch") log.debug(log_header .. "RPLC status packet length mismatch")
end end
elseif pkt.type == RPLC_TYPES.MEK_STRUCT then elseif pkt.type == RPLC_TYPE.MEK_STRUCT then
-- received reactor structure, record it -- received reactor structure, record it
if pkt.length == 8 then if pkt.length == 14 then
local status = pcall(_copy_struct, pkt.data) local status = pcall(_copy_struct, pkt.data)
if status then if status then
-- copied in structure data OK -- copied in structure data OK
self.received_struct = true self.received_struct = true
out_queue.push_data(svqtypes.SV_Q_DATA.PLC_BUILD_CHANGED, reactor_id)
else else
-- error copying structure data -- error copying structure data
log.error(log_header .. "failed to parse struct packet data") log.error(log_header .. "failed to parse struct packet data")
@@ -313,7 +348,7 @@ function plc.new_session(id, for_reactor, in_queue, out_queue)
else else
log.debug(log_header .. "RPLC struct packet length mismatch") log.debug(log_header .. "RPLC struct packet length mismatch")
end end
elseif pkt.type == RPLC_TYPES.MEK_BURN_RATE then elseif pkt.type == RPLC_TYPE.MEK_BURN_RATE then
-- burn rate acknowledgement -- burn rate acknowledgement
local ack = _get_ack(pkt) local ack = _get_ack(pkt)
if ack then if ack then
@@ -321,27 +356,56 @@ function plc.new_session(id, for_reactor, in_queue, out_queue)
elseif ack == false then elseif ack == false then
log.debug(log_header .. "burn rate update failed!") log.debug(log_header .. "burn rate update failed!")
end end
elseif pkt.type == RPLC_TYPES.RPS_ENABLE then
-- send acknowledgement to coordinator
out_queue.push_data(svqtypes.SV_Q_DATA.CRDN_ACK, {
unit = reactor_id,
cmd = UNIT_COMMAND.SET_BURN,
ack = ack
})
elseif pkt.type == RPLC_TYPE.RPS_ENABLE then
-- enable acknowledgement -- enable acknowledgement
local ack = _get_ack(pkt) local ack = _get_ack(pkt)
if ack then if ack then
self.acks.enable = true
self.sDB.control_state = true self.sDB.control_state = true
elseif ack == false then elseif ack == false then
log.debug(log_header .. "enable failed!") log.debug(log_header .. "enable failed!")
end end
elseif pkt.type == RPLC_TYPES.RPS_SCRAM then
-- SCRAM acknowledgement -- send acknowledgement to coordinator
out_queue.push_data(svqtypes.SV_Q_DATA.CRDN_ACK, {
unit = reactor_id,
cmd = UNIT_COMMAND.START,
ack = ack
})
elseif pkt.type == RPLC_TYPE.RPS_SCRAM then
-- manual SCRAM acknowledgement
local ack = _get_ack(pkt) local ack = _get_ack(pkt)
if ack then if ack then
self.acks.scram = true self.acks.scram = true
self.sDB.control_state = false self.sDB.control_state = false
elseif ack == false then elseif ack == false then
log.debug(log_header .. "SCRAM failed!") log.debug(log_header .. "manual SCRAM failed!")
end end
elseif pkt.type == RPLC_TYPES.RPS_STATUS then
-- send acknowledgement to coordinator
out_queue.push_data(svqtypes.SV_Q_DATA.CRDN_ACK, {
unit = reactor_id,
cmd = UNIT_COMMAND.SCRAM,
ack = ack
})
elseif pkt.type == RPLC_TYPE.RPS_ASCRAM then
-- automatic SCRAM acknowledgement
local ack = _get_ack(pkt)
if ack then
self.acks.ascram = true
self.sDB.control_state = false
elseif ack == false then
log.debug(log_header .. " automatic SCRAM failed!")
end
elseif pkt.type == RPLC_TYPE.RPS_STATUS then
-- RPS status packet received, copy data -- RPS status packet received, copy data
if pkt.length == 9 then if pkt.length == 14 then
local status = pcall(_copy_rps_status, pkt.data) local status = pcall(_copy_rps_status, pkt.data)
if status then if status then
-- copied in RPS status data OK -- copied in RPS status data OK
@@ -352,12 +416,10 @@ function plc.new_session(id, for_reactor, in_queue, out_queue)
else else
log.debug(log_header .. "RPLC RPS status packet length mismatch") log.debug(log_header .. "RPLC RPS status packet length mismatch")
end end
elseif pkt.type == RPLC_TYPES.RPS_ALARM then elseif pkt.type == RPLC_TYPE.RPS_ALARM then
-- RPS alarm -- RPS alarm
if pkt.length == 10 then if pkt.length == 13 then
self.sDB.rps_tripped = true local status = pcall(_copy_rps_status, { true, table.unpack(pkt.data) })
self.sDB.rps_trip_cause = pkt.data[1]
local status = pcall(_copy_rps_status, { table.unpack(pkt.data, 2, #pkt.length) })
if status then if status then
-- copied in RPS status data OK -- copied in RPS status data OK
else else
@@ -367,21 +429,50 @@ function plc.new_session(id, for_reactor, in_queue, out_queue)
else else
log.debug(log_header .. "RPLC RPS alarm packet length mismatch") log.debug(log_header .. "RPLC RPS alarm packet length mismatch")
end end
elseif pkt.type == RPLC_TYPES.RPS_RESET then elseif pkt.type == RPLC_TYPE.RPS_RESET then
-- RPS reset acknowledgement -- RPS reset acknowledgement
local ack = _get_ack(pkt) local ack = _get_ack(pkt)
if ack then if ack then
self.acks.rps_tripped = true self.acks.rps_reset = true
self.sDB.rps_tripped = false self.sDB.rps_tripped = false
self.sDB.rps_trip_cause = "ok" self.sDB.rps_trip_cause = "ok"
elseif ack == false then elseif ack == false then
log.debug(log_header .. "RPS reset failed") log.debug(log_header .. "RPS reset failed")
end end
-- send acknowledgement to coordinator
out_queue.push_data(svqtypes.SV_Q_DATA.CRDN_ACK, {
unit = reactor_id,
cmd = UNIT_COMMAND.RESET_RPS,
ack = ack
})
elseif pkt.type == RPLC_TYPE.RPS_AUTO_RESET then
-- RPS auto control reset acknowledgement
local ack = _get_ack(pkt)
if not ack then
log.debug(log_header .. "RPS auto reset failed")
end
elseif pkt.type == RPLC_TYPE.AUTO_BURN_RATE then
if pkt.length == 1 then
local ack = pkt.data[1]
if ack == PLC_AUTO_ACK.FAIL then
self.acks.burn_rate = false
log.debug(log_header .. "RPLC automatic burn rate set fail")
elseif ack == PLC_AUTO_ACK.DIRECT_SET_OK or ack == PLC_AUTO_ACK.RAMP_SET_OK or ack == PLC_AUTO_ACK.ZERO_DIS_OK then
self.acks.burn_rate = true
else
self.acks.burn_rate = false
log.debug(log_header .. "RPLC automatic burn rate ack unknown")
end
else
log.debug(log_header .. "RPLC automatic burn rate ack packet length mismatch")
end
else else
log.debug(log_header .. "handler received unsupported RPLC packet type " .. pkt.type) log.debug(log_header .. "handler received unsupported RPLC packet type " .. pkt.type)
end end
elseif pkt.scada_frame.protocol() == PROTOCOLS.SCADA_MGMT then elseif pkt.scada_frame.protocol() == PROTOCOL.SCADA_MGMT then
if pkt.type == SCADA_MGMT_TYPES.KEEP_ALIVE then if pkt.type == SCADA_MGMT_TYPE.KEEP_ALIVE then
-- keep alive reply -- keep alive reply
if pkt.length == 2 then if pkt.length == 2 then
local srv_start = pkt.data[1] local srv_start = pkt.data[1]
@@ -389,8 +480,8 @@ function plc.new_session(id, for_reactor, in_queue, out_queue)
local srv_now = util.time() local srv_now = util.time()
self.last_rtt = srv_now - srv_start self.last_rtt = srv_now - srv_start
if self.last_rtt > 500 then if self.last_rtt > 750 then
log.warning(log_header .. "PLC KEEP_ALIVE round trip time > 500ms (" .. self.last_rtt .. "ms)") log.warning(log_header .. "PLC KEEP_ALIVE round trip time > 750ms (" .. self.last_rtt .. "ms)")
end end
-- log.debug(log_header .. "PLC RTT = " .. self.last_rtt .. "ms") -- log.debug(log_header .. "PLC RTT = " .. self.last_rtt .. "ms")
@@ -398,7 +489,7 @@ function plc.new_session(id, for_reactor, in_queue, out_queue)
else else
log.debug(log_header .. "SCADA keep alive packet length mismatch") log.debug(log_header .. "SCADA keep alive packet length mismatch")
end end
elseif pkt.type == SCADA_MGMT_TYPES.CLOSE then elseif pkt.type == SCADA_MGMT_TYPE.CLOSE then
-- close the session -- close the session
_close() _close()
else else
@@ -410,46 +501,81 @@ function plc.new_session(id, for_reactor, in_queue, out_queue)
-- PUBLIC FUNCTIONS -- -- PUBLIC FUNCTIONS --
-- get the session ID -- get the session ID
function public.get_id() return self.id end ---@nodiscard
function public.get_id() return id end
-- get the session database -- get the session database
---@nodiscard
function public.get_db() return self.sDB end function public.get_db() return self.sDB end
-- check if ramping is completed by first verifying auto command token ack
---@nodiscard
function public.is_ramp_complete()
return (self.sDB.auto_ack_token == self.auto_cmd_token) and (self.commanded_burn_rate == self.sDB.mek_status.act_burn_rate)
end
-- get the reactor structure -- get the reactor structure
---@nodiscard
---@return mek_struct|table struct struct or empty table
function public.get_struct() function public.get_struct()
if self.received_struct then if self.received_struct then
return self.sDB.mek_struct return self.sDB.mek_struct
else else
return nil return {}
end end
end end
-- get the reactor status -- get the reactor status
---@nodiscard
---@return mek_status|table struct status or empty table
function public.get_status() function public.get_status()
if self.received_status_cache then if self.received_status_cache then
return self.sDB.mek_status return self.sDB.mek_status
else else
return nil return {}
end end
end end
-- get the reactor RPS status -- get the reactor RPS status
---@nodiscard
function public.get_rps() function public.get_rps()
return self.sDB.rps_status return self.sDB.rps_status
end end
-- get the general status information -- get the general status information
---@nodiscard
function public.get_general_status() function public.get_general_status()
return { return {
self.sDB.last_status_update, self.sDB.last_status_update,
self.sDB.control_state, self.sDB.control_state,
self.sDB.rps_tripped, self.sDB.rps_tripped,
self.sDB.rps_trip_cause, self.sDB.rps_trip_cause,
self.sDB.degraded self.sDB.no_reactor,
self.sDB.formed
} }
end end
-- lock out some manual operator actions during automatic control
---@param engage boolean true to engage the lockout
function public.auto_lock(engage)
self.auto_lock = engage
-- stop retrying a burn rate command
if engage then
self.acks.burn_rate = true
end
end
-- set the burn rate on behalf of automatic control
---@param rate number burn rate
---@param ramp boolean true to ramp, false to not
function public.auto_set_burn(rate, ramp)
self.ramping_rate = ramp
in_queue.push_data(PLC_S_DATA.AUTO_BURN_RATE, rate)
end
-- check if a timer matches this session's watchdog -- check if a timer matches this session's watchdog
---@nodiscard
function public.check_wd(timer) function public.check_wd(timer)
return self.plc_conn_watchdog.is_timer(timer) and self.connected return self.plc_conn_watchdog.is_timer(timer) and self.connected
end end
@@ -457,12 +583,13 @@ function plc.new_session(id, for_reactor, in_queue, out_queue)
-- close the connection -- close the connection
function public.close() function public.close()
_close() _close()
_send_mgmt(SCADA_MGMT_TYPES.CLOSE, {}) _send_mgmt(SCADA_MGMT_TYPE.CLOSE, {})
println("connection to reactor " .. self.for_reactor .. " PLC closed by server") println("connection to reactor " .. reactor_id .. " PLC closed by server")
log.info(log_header .. "session closed by server") log.info(log_header .. "session closed by server")
end end
-- iterate the session -- iterate the session
---@nodiscard
---@return boolean connected ---@return boolean connected
function public.iterate() function public.iterate()
if self.connected then if self.connected then
@@ -472,9 +599,9 @@ function plc.new_session(id, for_reactor, in_queue, out_queue)
local handle_start = util.time() local handle_start = util.time()
while self.in_q.ready() and self.connected do while in_queue.ready() and self.connected do
-- get a new message to process -- get a new message to process
local message = self.in_q.pop() local message = in_queue.pop()
if message ~= nil then if message ~= nil then
if message.qtype == mqueue.TYPE.PACKET then if message.qtype == mqueue.TYPE.PACKET then
@@ -485,37 +612,78 @@ function plc.new_session(id, for_reactor, in_queue, out_queue)
local cmd = message.message local cmd = message.message
if cmd == PLC_S_CMDS.ENABLE then if cmd == PLC_S_CMDS.ENABLE then
-- enable reactor -- enable reactor
self.acks.enable = false if not self.auto_lock then
self.retry_times.enable_req = util.time() + INITIAL_WAIT _send(RPLC_TYPE.RPS_ENABLE, {})
_send(RPLC_TYPES.RPS_ENABLE, {}) end
elseif cmd == PLC_S_CMDS.SCRAM then elseif cmd == PLC_S_CMDS.SCRAM then
-- SCRAM reactor -- SCRAM reactor
self.acks.scram = false self.acks.scram = false
self.retry_times.scram_req = util.time() + INITIAL_WAIT self.retry_times.scram_req = util.time() + INITIAL_WAIT
_send(RPLC_TYPES.RPS_SCRAM, {}) _send(RPLC_TYPE.RPS_SCRAM, {})
elseif cmd == PLC_S_CMDS.ASCRAM then
-- SCRAM reactor
self.acks.ascram = false
self.retry_times.ascram_req = util.time() + INITIAL_WAIT
_send(RPLC_TYPE.RPS_ASCRAM, {})
elseif cmd == PLC_S_CMDS.RPS_RESET then elseif cmd == PLC_S_CMDS.RPS_RESET then
-- reset RPS -- reset RPS
self.acks.ascram = true
self.acks.rps_reset = false self.acks.rps_reset = false
self.retry_times.rps_reset_req = util.time() + INITIAL_WAIT self.retry_times.rps_reset_req = util.time() + INITIAL_WAIT
_send(RPLC_TYPES.RPS_RESET, {}) _send(RPLC_TYPE.RPS_RESET, {})
elseif cmd == PLC_S_CMDS.RPS_AUTO_RESET then
if self.sDB.rps_status.automatic or self.sDB.rps_status.timeout then
_send(RPLC_TYPE.RPS_AUTO_RESET, {})
end
else
log.warning(log_header .. "unsupported command received in in_queue (this is a bug)")
end end
elseif message.qtype == mqueue.TYPE.DATA then elseif message.qtype == mqueue.TYPE.DATA then
-- instruction with body -- instruction with body
local cmd = message.message local cmd = message.message ---@type queue_data
if cmd.key == PLC_S_DATA.BURN_RATE then if cmd.key == PLC_S_DATA.BURN_RATE then
-- update burn rate -- update burn rate
self.commanded_burn_rate = cmd.val if not self.auto_lock then
self.ramping_rate = false cmd.val = math.floor(cmd.val * 10) / 10 -- round to 10ths place
self.acks.burn_rate = false if cmd.val > 0 and cmd.val <= self.sDB.mek_struct.max_burn then
self.retry_times.burn_rate_req = util.time() + INITIAL_WAIT self.commanded_burn_rate = cmd.val
_send(RPLC_TYPES.MEK_BURN_RATE, { self.commanded_burn_rate, self.ramping_rate }) self.auto_cmd_token = 0
self.ramping_rate = false
self.acks.burn_rate = false
self.retry_times.burn_rate_req = util.time() + INITIAL_WAIT
_send(RPLC_TYPE.MEK_BURN_RATE, { self.commanded_burn_rate, self.ramping_rate })
end
end
elseif cmd.key == PLC_S_DATA.RAMP_BURN_RATE then elseif cmd.key == PLC_S_DATA.RAMP_BURN_RATE then
-- ramp to burn rate -- ramp to burn rate
self.commanded_burn_rate = cmd.val if not self.auto_lock then
self.ramping_rate = true cmd.val = math.floor(cmd.val * 10) / 10 -- round to 10ths place
self.acks.burn_rate = false if cmd.val > 0 and cmd.val <= self.sDB.mek_struct.max_burn then
self.retry_times.burn_rate_req = util.time() + INITIAL_WAIT self.commanded_burn_rate = cmd.val
_send(RPLC_TYPES.MEK_BURN_RATE, { self.commanded_burn_rate, self.ramping_rate }) self.auto_cmd_token = 0
self.ramping_rate = true
self.acks.burn_rate = false
self.retry_times.burn_rate_req = util.time() + INITIAL_WAIT
_send(RPLC_TYPE.MEK_BURN_RATE, { self.commanded_burn_rate, self.ramping_rate })
end
end
elseif cmd.key == PLC_S_DATA.AUTO_BURN_RATE then
-- set automatic burn rate
if self.auto_lock then
cmd.val = math.floor(cmd.val * 100) / 100 -- round to 100ths place
if cmd.val >= 0 and cmd.val <= self.sDB.mek_struct.max_burn then
self.auto_cmd_token = util.time_ms()
self.commanded_burn_rate = cmd.val
-- this is only for manual control, only retry auto ramps
self.acks.burn_rate = not self.ramping_rate
self.retry_times.burn_rate_req = util.time() + INITIAL_AUTO_WAIT
_send(RPLC_TYPE.AUTO_BURN_RATE, { self.commanded_burn_rate, self.ramping_rate, self.auto_cmd_token })
end
end
else
log.warning(log_header .. "unsupported data command received in in_queue (this is a bug)")
end end
end end
end end
@@ -529,7 +697,7 @@ function plc.new_session(id, for_reactor, in_queue, out_queue)
-- exit if connection was closed -- exit if connection was closed
if not self.connected then if not self.connected then
println("connection to reactor " .. self.for_reactor .. " PLC closed by remote host") println("connection to reactor " .. reactor_id .. " PLC closed by remote host")
log.info(log_header .. "session closed by remote host") log.info(log_header .. "session closed by remote host")
return self.connected return self.connected
end end
@@ -546,7 +714,7 @@ function plc.new_session(id, for_reactor, in_queue, out_queue)
periodics.keep_alive = periodics.keep_alive + elapsed periodics.keep_alive = periodics.keep_alive + elapsed
if periodics.keep_alive >= PERIODICS.KEEP_ALIVE then if periodics.keep_alive >= PERIODICS.KEEP_ALIVE then
_send_mgmt(SCADA_MGMT_TYPES.KEEP_ALIVE, { util.time() }) _send_mgmt(SCADA_MGMT_TYPE.KEEP_ALIVE, { util.time() })
periodics.keep_alive = 0 periodics.keep_alive = 0
end end
@@ -558,48 +726,63 @@ function plc.new_session(id, for_reactor, in_queue, out_queue)
local rtimes = self.retry_times local rtimes = self.retry_times
-- struct request retry if (not self.sDB.no_reactor) and self.sDB.formed then
-- struct request retry
if not self.received_struct then if not self.received_struct then
if rtimes.struct_req - util.time() <= 0 then if rtimes.struct_req - util.time() <= 0 then
_send(RPLC_TYPES.MEK_STRUCT, {}) _send(RPLC_TYPE.MEK_STRUCT, {})
rtimes.struct_req = util.time() + RETRY_PERIOD rtimes.struct_req = util.time() + RETRY_PERIOD
end
end end
end
-- status cache request retry -- status cache request retry
if not self.received_status_cache then if not self.received_status_cache then
if rtimes.status_req - util.time() <= 0 then if rtimes.status_req - util.time() <= 0 then
_send(RPLC_TYPES.MEK_STATUS, {}) _send(RPLC_TYPE.MEK_STATUS, {})
rtimes.status_req = util.time() + RETRY_PERIOD rtimes.status_req = util.time() + RETRY_PERIOD
end
end
-- burn rate request retry
if not self.acks.burn_rate then
if rtimes.burn_rate_req - util.time() <= 0 then
if self.auto_cmd_token > 0 then
if self.auto_lock then
_send(RPLC_TYPE.AUTO_BURN_RATE, { self.commanded_burn_rate, self.ramping_rate, self.auto_cmd_token })
else
-- would have been an auto command, but disengaged, so stop retrying
self.acks.burn_rate = true
end
elseif not self.auto_lock then
_send(RPLC_TYPE.MEK_BURN_RATE, { self.commanded_burn_rate, self.ramping_rate })
else
-- shouldn't be in this state, just pretend it was acknowledged
self.acks.burn_rate = true
end
rtimes.burn_rate_req = util.time() + RETRY_PERIOD
end
end end
end end
-- SCRAM request retry -- SCRAM request retry
if not self.acks.scram then if not self.acks.scram then
if rtimes.scram_req - util.time() <= 0 then if rtimes.scram_req - util.time() <= 0 then
_send(RPLC_TYPES.RPS_SCRAM, {}) _send(RPLC_TYPE.RPS_SCRAM, {})
rtimes.scram_req = util.time() + RETRY_PERIOD rtimes.scram_req = util.time() + RETRY_PERIOD
end end
end end
-- enable request retry -- automatic SCRAM request retry
if not self.acks.enable then if not self.acks.ascram then
if rtimes.enable_req - util.time() <= 0 then if rtimes.ascram_req - util.time() <= 0 then
_send(RPLC_TYPES.RPS_ENABLE, {}) _send(RPLC_TYPE.RPS_ASCRAM, {})
rtimes.enable_req = util.time() + RETRY_PERIOD rtimes.ascram_req = util.time() + RETRY_PERIOD
end
end
-- burn rate request retry
if not self.acks.burn_rate then
if rtimes.burn_rate_req - util.time() <= 0 then
_send(RPLC_TYPES.MEK_BURN_RATE, { self.commanded_burn_rate, self.ramping_rate })
rtimes.burn_rate_req = util.time() + RETRY_PERIOD
end end
end end
@@ -607,7 +790,7 @@ function plc.new_session(id, for_reactor, in_queue, out_queue)
if not self.acks.rps_reset then if not self.acks.rps_reset then
if rtimes.rps_reset_req - util.time() <= 0 then if rtimes.rps_reset_req - util.time() <= 0 then
_send(RPLC_TYPES.RPS_RESET, {}) _send(RPLC_TYPE.RPS_RESET, {})
rtimes.rps_reset_req = util.time() + RETRY_PERIOD rtimes.rps_reset_req = util.time() + RETRY_PERIOD
end end
end end

View File

@@ -0,0 +1,40 @@
--
-- Redstone RTU Session I/O Controller
--
local rsctl = {}
-- create a new redstone RTU I/O controller
---@nodiscard
---@param redstone_rtus table redstone RTU sessions
function rsctl.new(redstone_rtus)
---@class rs_controller
local public = {}
-- write to a digital redstone port (applies to all RTUs)
---@param port IO_PORT
---@param value boolean
function public.digital_write(port, value)
for i = 1, #redstone_rtus do
local db = redstone_rtus[i].get_db() ---@type redstone_session_db
local io = db.io[port] ---@type rs_db_dig_io|nil
if io ~= nil then io.write(value) end
end
end
-- read a digital redstone port<br>
-- this will read from the first one encountered if there are multiple, because there should not be multiple
---@param port IO_PORT
---@return boolean|nil
function public.digital_read(port)
for i = 1, #redstone_rtus do
local db = redstone_rtus[i].get_db() ---@type redstone_session_db
local io = db.io[port] ---@type rs_db_dig_io|nil
if io ~= nil then return io.read() end
end
end
return public
end
return rsctl

View File

@@ -1,82 +1,62 @@
local comms = require("scada-common.comms") local comms = require("scada-common.comms")
local log = require("scada-common.log") local log = require("scada-common.log")
local mqueue = require("scada-common.mqueue") local mqueue = require("scada-common.mqueue")
local rsio = require("scada-common.rsio") local types = require("scada-common.types")
local util = require("scada-common.util") local util = require("scada-common.util")
local svqtypes = require("supervisor.session.svqtypes")
-- supervisor rtu sessions (svrs) -- supervisor rtu sessions (svrs)
local svrs_boiler = require("supervisor.session.rtu.boiler") local unit_session = require("supervisor.session.rtu.unit_session")
local svrs_boilerv = require("supervisor.session.rtu.boilerv") local svrs_boilerv = require("supervisor.session.rtu.boilerv")
local svrs_emachine = require("supervisor.session.rtu.emachine")
local svrs_envd = require("supervisor.session.rtu.envd") local svrs_envd = require("supervisor.session.rtu.envd")
local svrs_imatrix = require("supervisor.session.rtu.imatrix") local svrs_imatrix = require("supervisor.session.rtu.imatrix")
local svrs_redstone = require("supervisor.session.rtu.redstone") local svrs_redstone = require("supervisor.session.rtu.redstone")
local svrs_sna = require("supervisor.session.rtu.sna") local svrs_sna = require("supervisor.session.rtu.sna")
local svrs_sps = require("supervisor.session.rtu.sps") local svrs_sps = require("supervisor.session.rtu.sps")
local svrs_turbine = require("supervisor.session.rtu.turbine")
local svrs_turbinev = require("supervisor.session.rtu.turbinev") local svrs_turbinev = require("supervisor.session.rtu.turbinev")
local rtu = {} local rtu = {}
local PROTOCOLS = comms.PROTOCOLS local PROTOCOL = comms.PROTOCOL
local SCADA_MGMT_TYPES = comms.SCADA_MGMT_TYPES local SCADA_MGMT_TYPE = comms.SCADA_MGMT_TYPE
local RTU_UNIT_TYPES = comms.RTU_UNIT_TYPES local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE
local print = util.print local print = util.print
local println = util.println local println = util.println
local print_ts = util.print_ts local print_ts = util.print_ts
local println_ts = util.println_ts local println_ts = util.println_ts
local RTU_S_CMDS = {
}
local RTU_S_DATA = {
RS_COMMAND = 1,
UNIT_COMMAND = 2
}
rtu.RTU_S_CMDS = RTU_S_CMDS
rtu.RTU_S_DATA = RTU_S_DATA
local PERIODICS = { local PERIODICS = {
KEEP_ALIVE = 2000 KEEP_ALIVE = 2000
} }
---@class rs_session_command
---@field reactor integer
---@field channel RS_IO
---@field value integer|boolean
-- create a new RTU session -- create a new RTU session
---@param id integer ---@nodiscard
---@param in_queue mqueue ---@param id integer session ID
---@param out_queue mqueue ---@param in_queue mqueue in message queue
---@param advertisement table ---@param out_queue mqueue out message queue
---@param facility_units table ---@param timeout number communications timeout
function rtu.new_session(id, in_queue, out_queue, advertisement, facility_units) ---@param advertisement table RTU device advertisement
---@param facility facility facility data table
function rtu.new_session(id, in_queue, out_queue, timeout, advertisement, facility)
local log_header = "rtu_session(" .. id .. "): " local log_header = "rtu_session(" .. id .. "): "
local self = { local self = {
id = id,
in_q = in_queue,
out_q = out_queue,
modbus_q = mqueue.new(), modbus_q = mqueue.new(),
f_units = facility_units,
advert = advertisement, advert = advertisement,
fac_units = facility.get_units(),
-- connection properties -- connection properties
seq_num = 0, seq_num = 0,
r_seq_num = nil, r_seq_num = nil,
connected = true, connected = true,
rtu_conn_watchdog = util.new_watchdog(3), rtu_conn_watchdog = util.new_watchdog(timeout),
last_rtt = 0, last_rtt = 0,
-- periodic messages -- periodic messages
periodics = { periodics = {
last_update = 0, last_update = 0,
keep_alive = 0 keep_alive = 0
}, },
rs_io_q = {},
turbine_cmd_q = {},
turbine_cmd_capable = false,
units = {} units = {}
} }
@@ -85,15 +65,17 @@ function rtu.new_session(id, in_queue, out_queue, advertisement, facility_units)
local function _reset_config() local function _reset_config()
self.units = {} self.units = {}
self.rs_io_q = {}
self.turbine_cmd_q = {}
self.turbine_cmd_capable = false
end end
-- parse the recorded advertisement and create unit sub-sessions -- parse the recorded advertisement and create unit sub-sessions
local function _handle_advertisement() local function _handle_advertisement()
self.units = {} _reset_config()
self.rs_io_q = {}
for i = 1, #self.fac_units do
local unit = self.fac_units[i] ---@type reactor_unit
unit.purge_rtu_devices(id)
facility.purge_rtu_devices(id)
end
for i = 1, #self.advert do for i = 1, #self.advert do
local unit = nil ---@type unit_session|nil local unit = nil ---@type unit_session|nil
@@ -108,9 +90,7 @@ function rtu.new_session(id, in_queue, out_queue, advertisement, facility_units)
rsio = self.advert[i][4] rsio = self.advert[i][4]
} }
local target_unit = self.f_units[unit_advert.reactor] ---@type reactor_unit local u_type = unit_advert.type ---@type integer|boolean
local u_type = unit_advert.type
-- validate unit advertisement -- validate unit advertisement
@@ -118,92 +98,80 @@ function rtu.new_session(id, in_queue, out_queue, advertisement, facility_units)
advert_validator.assert_type_int(unit_advert.index) advert_validator.assert_type_int(unit_advert.index)
advert_validator.assert_type_int(unit_advert.reactor) advert_validator.assert_type_int(unit_advert.reactor)
if u_type == RTU_UNIT_TYPES.REDSTONE then if u_type == RTU_UNIT_TYPE.REDSTONE then
advert_validator.assert_type_table(unit_advert.rsio) advert_validator.assert_type_table(unit_advert.rsio)
end end
if advert_validator.valid() then if advert_validator.valid() then
advert_validator.assert_min(unit_advert.index, 1) advert_validator.assert_min(unit_advert.index, 1)
advert_validator.assert_min(unit_advert.reactor, 1) advert_validator.assert_min(unit_advert.reactor, 0)
advert_validator.assert_max(unit_advert.reactor, #self.fac_units)
if not advert_validator.valid() then u_type = false end if not advert_validator.valid() then u_type = false end
else else
u_type = false u_type = false
end end
local type_string = util.strval(u_type)
if type(u_type) == "number" then type_string = types.rtu_type_to_string(u_type) end
-- create unit by type -- create unit by type
if u_type == false then if u_type == false then
-- validation fail -- validation fail
elseif u_type == RTU_UNIT_TYPES.REDSTONE then log.debug(log_header .. "advertisement unit validation failure")
-- redstone
unit, rs_in_q = svrs_redstone.new(self.id, i, unit_advert, self.modbus_q)
elseif u_type == RTU_UNIT_TYPES.BOILER then
-- boiler
unit = svrs_boiler.new(self.id, i, unit_advert, self.modbus_q)
target_unit.add_boiler(unit)
elseif u_type == RTU_UNIT_TYPES.BOILER_VALVE then
-- boiler (Mekanism 10.1+)
unit = svrs_boilerv.new(self.id, i, unit_advert, self.modbus_q)
target_unit.add_boiler(unit)
elseif u_type == RTU_UNIT_TYPES.TURBINE then
-- turbine
unit = svrs_turbine.new(self.id, i, unit_advert, self.modbus_q)
target_unit.add_turbine(unit)
elseif u_type == RTU_UNIT_TYPES.TURBINE_VALVE then
-- turbine (Mekanism 10.1+)
unit, tbv_in_q = svrs_turbinev.new(self.id, i, unit_advert, self.modbus_q)
target_unit.add_turbine(unit)
self.turbine_cmd_capable = true
elseif u_type == RTU_UNIT_TYPES.EMACHINE then
-- mekanism [energy] machine
unit = svrs_emachine.new(self.id, i, unit_advert, self.modbus_q)
elseif u_type == RTU_UNIT_TYPES.IMATRIX then
-- induction matrix
unit = svrs_imatrix.new(self.id, i, unit_advert, self.modbus_q)
elseif u_type == RTU_UNIT_TYPES.SPS then
-- super-critical phase shifter
unit = svrs_sps.new(self.id, i, unit_advert, self.modbus_q)
elseif u_type == RTU_UNIT_TYPES.SNA then
-- solar neutron activator
unit = svrs_sna.new(self.id, i, unit_advert, self.modbus_q)
elseif u_type == RTU_UNIT_TYPES.ENV_DETECTOR then
-- environment detector
unit = svrs_envd.new(self.id, i, unit_advert, self.modbus_q)
else else
log.error(log_header .. "bad advertisement: encountered unsupported RTU type") if unit_advert.reactor > 0 then
local target_unit = self.fac_units[unit_advert.reactor] ---@type reactor_unit
if u_type == RTU_UNIT_TYPE.REDSTONE then
-- redstone
unit = svrs_redstone.new(id, i, unit_advert, self.modbus_q)
if type(unit) ~= "nil" then target_unit.add_redstone(unit) end
elseif u_type == RTU_UNIT_TYPE.BOILER_VALVE then
-- boiler (Mekanism 10.1+)
unit = svrs_boilerv.new(id, i, unit_advert, self.modbus_q)
if type(unit) ~= "nil" then target_unit.add_boiler(unit) end
elseif u_type == RTU_UNIT_TYPE.TURBINE_VALVE then
-- turbine (Mekanism 10.1+)
unit = svrs_turbinev.new(id, i, unit_advert, self.modbus_q)
if type(unit) ~= "nil" then target_unit.add_turbine(unit) end
elseif u_type == RTU_UNIT_TYPE.ENV_DETECTOR then
-- environment detector
unit = svrs_envd.new(id, i, unit_advert, self.modbus_q)
if type(unit) ~= "nil" then target_unit.add_envd(unit) end
else
log.error(util.c(log_header, "bad advertisement: encountered unsupported reactor-specific RTU type ", type_string))
end
else
if u_type == RTU_UNIT_TYPE.REDSTONE then
-- redstone
unit = svrs_redstone.new(id, i, unit_advert, self.modbus_q)
if type(unit) ~= "nil" then facility.add_redstone(unit) end
elseif u_type == RTU_UNIT_TYPE.IMATRIX then
-- induction matrix
unit = svrs_imatrix.new(id, i, unit_advert, self.modbus_q)
if type(unit) ~= "nil" then facility.add_imatrix(unit) end
elseif u_type == RTU_UNIT_TYPE.SPS then
-- super-critical phase shifter
unit = svrs_sps.new(id, i, unit_advert, self.modbus_q)
elseif u_type == RTU_UNIT_TYPE.SNA then
-- solar neutron activator
unit = svrs_sna.new(id, i, unit_advert, self.modbus_q)
elseif u_type == RTU_UNIT_TYPE.ENV_DETECTOR then
-- environment detector
unit = svrs_envd.new(id, i, unit_advert, self.modbus_q)
if type(unit) ~= "nil" then facility.add_envd(unit) end
else
log.error(util.c(log_header, "bad advertisement: encountered unsupported reactor-independent RTU type ", type_string))
end
end
end end
if unit ~= nil then if unit ~= nil then
table.insert(self.units, unit) table.insert(self.units, unit)
if u_type == RTU_UNIT_TYPES.REDSTONE then
if self.rs_io_q[unit_advert.reactor] == nil then
self.rs_io_q[unit_advert.reactor] = rs_in_q
else
_reset_config()
log.error(log_header .. util.c("bad advertisement: duplicate redstone RTU for reactor " .. unit_advert.reactor))
break
end
elseif u_type == RTU_UNIT_TYPES.TURBINE_VALVE then
if self.turbine_cmd_q[unit_advert.reactor] == nil then
self.turbine_cmd_q[unit_advert.reactor] = {}
end
local queues = self.turbine_cmd_q[unit_advert.reactor]
if queues[unit_advert.index] == nil then
queues[unit_advert.index] = tbv_in_q
else
_reset_config()
log.error(log_header .. util.c("bad advertisement: duplicate turbine RTU (same index of ",
unit_advert.index, ") for reactor ", unit_advert.reactor))
break
end
end
else else
_reset_config() _reset_config()
local type_string = util.strval(comms.advert_type_to_rtu_t(u_type)) log.error(util.c(log_header, "bad advertisement: error occured while creating a unit (type is ", type_string, ")"))
log.error(log_header .. "bad advertisement: error occured while creating a unit (type is " .. type_string .. ")")
break break
end end
end end
@@ -225,23 +193,23 @@ function rtu.new_session(id, in_queue, out_queue, advertisement, facility_units)
local function _send_modbus(m_pkt) local function _send_modbus(m_pkt)
local s_pkt = comms.scada_packet() local s_pkt = comms.scada_packet()
s_pkt.make(self.seq_num, PROTOCOLS.MODBUS_TCP, m_pkt.raw_sendable()) s_pkt.make(self.seq_num, PROTOCOL.MODBUS_TCP, m_pkt.raw_sendable())
self.out_q.push_packet(s_pkt) out_queue.push_packet(s_pkt)
self.seq_num = self.seq_num + 1 self.seq_num = self.seq_num + 1
end end
-- send a SCADA management packet -- send a SCADA management packet
---@param msg_type SCADA_MGMT_TYPES ---@param msg_type SCADA_MGMT_TYPE
---@param msg table ---@param msg table
local function _send_mgmt(msg_type, msg) local function _send_mgmt(msg_type, msg)
local s_pkt = comms.scada_packet() local s_pkt = comms.scada_packet()
local m_pkt = comms.mgmt_packet() local m_pkt = comms.mgmt_packet()
m_pkt.make(msg_type, msg) m_pkt.make(msg_type, msg)
s_pkt.make(self.seq_num, PROTOCOLS.SCADA_MGMT, m_pkt.raw_sendable()) s_pkt.make(self.seq_num, PROTOCOL.SCADA_MGMT, m_pkt.raw_sendable())
self.out_q.push_packet(s_pkt) out_queue.push_packet(s_pkt)
self.seq_num = self.seq_num + 1 self.seq_num = self.seq_num + 1
end end
@@ -262,14 +230,15 @@ function rtu.new_session(id, in_queue, out_queue, advertisement, facility_units)
self.rtu_conn_watchdog.feed() self.rtu_conn_watchdog.feed()
-- process packet -- process packet
if pkt.scada_frame.protocol() == PROTOCOLS.MODBUS_TCP then if pkt.scada_frame.protocol() == PROTOCOL.MODBUS_TCP then
if self.units[pkt.unit_id] ~= nil then if self.units[pkt.unit_id] ~= nil then
local unit = self.units[pkt.unit_id] ---@type unit_session local unit = self.units[pkt.unit_id] ---@type unit_session
---@diagnostic disable-next-line: param-type-mismatch
unit.handle_packet(pkt) unit.handle_packet(pkt)
end end
elseif pkt.scada_frame.protocol() == PROTOCOLS.SCADA_MGMT then elseif pkt.scada_frame.protocol() == PROTOCOL.SCADA_MGMT then
-- handle management packet -- handle management packet
if pkt.type == SCADA_MGMT_TYPES.KEEP_ALIVE then if pkt.type == SCADA_MGMT_TYPE.KEEP_ALIVE then
-- keep alive reply -- keep alive reply
if pkt.length == 2 then if pkt.length == 2 then
local srv_start = pkt.data[1] local srv_start = pkt.data[1]
@@ -277,8 +246,8 @@ function rtu.new_session(id, in_queue, out_queue, advertisement, facility_units)
local srv_now = util.time() local srv_now = util.time()
self.last_rtt = srv_now - srv_start self.last_rtt = srv_now - srv_start
if self.last_rtt > 500 then if self.last_rtt > 750 then
log.warning(log_header .. "RTU KEEP_ALIVE round trip time > 500ms (" .. self.last_rtt .. "ms)") log.warning(log_header .. "RTU KEEP_ALIVE round trip time > 750ms (" .. self.last_rtt .. "ms)")
end end
-- log.debug(log_header .. "RTU RTT = " .. self.last_rtt .. "ms") -- log.debug(log_header .. "RTU RTT = " .. self.last_rtt .. "ms")
@@ -286,14 +255,26 @@ function rtu.new_session(id, in_queue, out_queue, advertisement, facility_units)
else else
log.debug(log_header .. "SCADA keep alive packet length mismatch") log.debug(log_header .. "SCADA keep alive packet length mismatch")
end end
elseif pkt.type == SCADA_MGMT_TYPES.CLOSE then elseif pkt.type == SCADA_MGMT_TYPE.CLOSE then
-- close the session -- close the session
_close() _close()
elseif pkt.type == SCADA_MGMT_TYPES.RTU_ADVERT then elseif pkt.type == SCADA_MGMT_TYPE.RTU_ADVERT then
-- RTU unit advertisement -- RTU unit advertisement
-- handle advertisement; this will re-create all unit sub-sessions log.debug(log_header .. "received updated advertisement")
self.advert = pkt.data self.advert = pkt.data
-- handle advertisement; this will re-create all unit sub-sessions
_handle_advertisement() _handle_advertisement()
elseif pkt.type == SCADA_MGMT_TYPE.RTU_DEV_REMOUNT then
if pkt.length == 1 then
local unit_id = pkt.data[1]
if self.units[unit_id] ~= nil then
local unit = self.units[unit_id] ---@type unit_session
unit.invalidate_cache()
end
else
log.debug(log_header .. "SCADA RTU device re-mount packet length mismatch")
end
else else
log.debug(log_header .. "handler received unsupported SCADA_MGMT packet type " .. pkt.type) log.debug(log_header .. "handler received unsupported SCADA_MGMT packet type " .. pkt.type)
end end
@@ -303,9 +284,10 @@ function rtu.new_session(id, in_queue, out_queue, advertisement, facility_units)
-- PUBLIC FUNCTIONS -- -- PUBLIC FUNCTIONS --
-- get the session ID -- get the session ID
function public.get_id() return self.id end function public.get_id() return id end
-- check if a timer matches this session's watchdog -- check if a timer matches this session's watchdog
---@nodiscard
---@param timer number ---@param timer number
function public.check_wd(timer) function public.check_wd(timer)
return self.rtu_conn_watchdog.is_timer(timer) and self.connected return self.rtu_conn_watchdog.is_timer(timer) and self.connected
@@ -314,12 +296,13 @@ function rtu.new_session(id, in_queue, out_queue, advertisement, facility_units)
-- close the connection -- close the connection
function public.close() function public.close()
_close() _close()
_send_mgmt(SCADA_MGMT_TYPES.CLOSE, {}) _send_mgmt(SCADA_MGMT_TYPE.CLOSE, {})
println(log_header .. "connection to RTU closed by server") println(log_header .. "connection to RTU closed by server")
log.info(log_header .. "session closed by server") log.info(log_header .. "session closed by server")
end end
-- iterate the session -- iterate the session
---@nodiscard
---@return boolean connected ---@return boolean connected
function public.iterate() function public.iterate()
if self.connected then if self.connected then
@@ -329,9 +312,9 @@ function rtu.new_session(id, in_queue, out_queue, advertisement, facility_units)
local handle_start = util.time() local handle_start = util.time()
while self.in_q.ready() and self.connected do while in_queue.ready() and self.connected do
-- get a new message to process -- get a new message to process
local msg = self.in_q.pop() local msg = in_queue.pop()
if msg ~= nil then if msg ~= nil then
if msg.qtype == mqueue.TYPE.PACKET then if msg.qtype == mqueue.TYPE.PACKET then
@@ -341,26 +324,6 @@ function rtu.new_session(id, in_queue, out_queue, advertisement, facility_units)
-- handle instruction -- handle instruction
elseif msg.qtype == mqueue.TYPE.DATA then elseif msg.qtype == mqueue.TYPE.DATA then
-- instruction with body -- instruction with body
local cmd = msg.message ---@type queue_data
if cmd.key == RTU_S_DATA.RS_COMMAND then
local rs_cmd = cmd.val ---@type rs_session_command
if rsio.is_valid_channel(rs_cmd.channel) then
cmd.key = svrs_redstone.RS_RTU_S_DATA.RS_COMMAND
if rs_cmd.reactor == nil then
-- for all reactors (facility)
for i = 1, #self.rs_io_q do
local q = self.rs_io.q[i] ---@type mqueue
q.push_data(msg)
end
elseif self.rs_io_q[rs_cmd.reactor] ~= nil then
-- for just one reactor
local q = self.rs_io.q[rs_cmd.reactor] ---@type mqueue
q.push_data(msg)
end
end
end
end end
end end
@@ -373,7 +336,7 @@ function rtu.new_session(id, in_queue, out_queue, advertisement, facility_units)
-- exit if connection was closed -- exit if connection was closed
if not self.connected then if not self.connected then
println(log_header .. "connection to RTU closed by remote host") println("RTU connection " .. id .. " closed by remote host")
log.info(log_header .. "session closed by remote host") log.info(log_header .. "session closed by remote host")
return self.connected return self.connected
end end
@@ -400,15 +363,15 @@ function rtu.new_session(id, in_queue, out_queue, advertisement, facility_units)
periodics.keep_alive = periodics.keep_alive + elapsed periodics.keep_alive = periodics.keep_alive + elapsed
if periodics.keep_alive >= PERIODICS.KEEP_ALIVE then if periodics.keep_alive >= PERIODICS.KEEP_ALIVE then
_send_mgmt(SCADA_MGMT_TYPES.KEEP_ALIVE, { util.time() }) _send_mgmt(SCADA_MGMT_TYPE.KEEP_ALIVE, { util.time() })
periodics.keep_alive = 0 periodics.keep_alive = 0
end end
self.periodics.last_update = util.time() self.periodics.last_update = util.time()
---------------------------------------------- --------------------------------------------
-- pass MODBUS packets on to main out queue -- -- process RTU session handler out queues --
---------------------------------------------- --------------------------------------------
for _ = 1, self.modbus_q.length() do for _ = 1, self.modbus_q.length() do
-- get the next message -- get the next message
@@ -416,7 +379,16 @@ function rtu.new_session(id, in_queue, out_queue, advertisement, facility_units)
if msg ~= nil then if msg ~= nil then
if msg.qtype == mqueue.TYPE.PACKET then if msg.qtype == mqueue.TYPE.PACKET then
-- handle a packet
_send_modbus(msg.message) _send_modbus(msg.message)
elseif msg.qtype == mqueue.TYPE.COMMAND then
-- handle instruction
elseif msg.qtype == mqueue.TYPE.DATA then
-- instruction with body
local cmd = msg.message ---@type queue_data
if cmd.key == unit_session.RTU_US_DATA.BUILD_CHANGED then
out_queue.push_data(svqtypes.SV_Q_DATA.RTU_BUILD_CHANGED, cmd.val)
end
end end
end end
end end

View File

@@ -1,191 +0,0 @@
local comms = require("scada-common.comms")
local log = require("scada-common.log")
local types = require("scada-common.types")
local unit_session = require("supervisor.session.rtu.unit_session")
local boiler = {}
local RTU_UNIT_TYPES = comms.RTU_UNIT_TYPES
local MODBUS_FCODE = types.MODBUS_FCODE
local TXN_TYPES = {
BUILD = 1,
STATE = 2,
TANKS = 3
}
local TXN_TAGS = {
"boiler.build",
"boiler.state",
"boiler.tanks"
}
local PERIODICS = {
BUILD = 1000,
STATE = 500,
TANKS = 1000
}
-- create a new boiler rtu session runner
---@param session_id integer
---@param unit_id integer
---@param advert rtu_advertisement
---@param out_queue mqueue
function boiler.new(session_id, unit_id, advert, out_queue)
-- type check
if advert.type ~= RTU_UNIT_TYPES.BOILER then
log.error("attempt to instantiate boiler RTU for type '" .. advert.type .. "'. this is a bug.")
return nil
end
local log_tag = "session.rtu(" .. session_id .. ").boiler(" .. advert.index .. "): "
local self = {
session = unit_session.new(unit_id, advert, out_queue, log_tag, TXN_TAGS),
has_build = false,
periodics = {
next_build_req = 0,
next_state_req = 0,
next_tanks_req = 0
},
---@class boiler_session_db
db = {
build = {
boil_cap = 0.0,
steam_cap = 0,
water_cap = 0,
hcoolant_cap = 0,
ccoolant_cap = 0,
superheaters = 0,
max_boil_rate = 0.0
},
state = {
temperature = 0.0,
boil_rate = 0.0
},
tanks = {
steam = 0,
steam_need = 0,
steam_fill = 0.0,
water = 0,
water_need = 0,
water_fill = 0.0,
hcool = {}, ---@type tank_fluid
hcool_need = 0,
hcool_fill = 0.0,
ccool = {}, ---@type tank_fluid
ccool_need = 0,
ccool_fill = 0.0
}
}
}
local public = self.session.get()
-- PRIVATE FUNCTIONS --
-- query the build of the device
local function _request_build()
-- read input registers 1 through 7 (start = 1, count = 7)
self.session.send_request(TXN_TYPES.BUILD, MODBUS_FCODE.READ_INPUT_REGS, { 1, 7 })
end
-- query the state of the device
local function _request_state()
-- read input registers 8 through 9 (start = 8, count = 2)
self.session.send_request(TXN_TYPES.STATE, MODBUS_FCODE.READ_INPUT_REGS, { 8, 2 })
end
-- query the tanks of the device
local function _request_tanks()
-- read input registers 10 through 21 (start = 10, count = 12)
self.session.send_request(TXN_TYPES.TANKS, MODBUS_FCODE.READ_INPUT_REGS, { 10, 12 })
end
-- PUBLIC FUNCTIONS --
-- handle a packet
---@param m_pkt modbus_frame
function public.handle_packet(m_pkt)
local txn_type = self.session.try_resolve(m_pkt)
if txn_type == false then
-- nothing to do
elseif txn_type == TXN_TYPES.BUILD then
-- build response
-- load in data if correct length
if m_pkt.length == 7 then
self.db.build.boil_cap = m_pkt.data[1]
self.db.build.steam_cap = m_pkt.data[2]
self.db.build.water_cap = m_pkt.data[3]
self.db.build.hcoolant_cap = m_pkt.data[4]
self.db.build.ccoolant_cap = m_pkt.data[5]
self.db.build.superheaters = m_pkt.data[6]
self.db.build.max_boil_rate = m_pkt.data[7]
self.has_build = true
else
log.debug(log_tag .. "MODBUS transaction reply length mismatch (" .. TXN_TAGS[txn_type] .. ")")
end
elseif txn_type == TXN_TYPES.STATE then
-- state response
-- load in data if correct length
if m_pkt.length == 2 then
self.db.state.temperature = m_pkt.data[1]
self.db.state.boil_rate = m_pkt.data[2]
else
log.debug(log_tag .. "MODBUS transaction reply length mismatch (" .. TXN_TAGS[txn_type] .. ")")
end
elseif txn_type == TXN_TYPES.TANKS then
-- tanks response
-- load in data if correct length
if m_pkt.length == 12 then
self.db.tanks.steam = m_pkt.data[1]
self.db.tanks.steam_need = m_pkt.data[2]
self.db.tanks.steam_fill = m_pkt.data[3]
self.db.tanks.water = m_pkt.data[4]
self.db.tanks.water_need = m_pkt.data[5]
self.db.tanks.water_fill = m_pkt.data[6]
self.db.tanks.hcool = m_pkt.data[7]
self.db.tanks.hcool_need = m_pkt.data[8]
self.db.tanks.hcool_fill = m_pkt.data[9]
self.db.tanks.ccool = m_pkt.data[10]
self.db.tanks.ccool_need = m_pkt.data[11]
self.db.tanks.ccool_fill = m_pkt.data[12]
else
log.debug(log_tag .. "MODBUS transaction reply length mismatch (" .. TXN_TAGS[txn_type] .. ")")
end
elseif txn_type == nil then
log.error(log_tag .. "unknown transaction reply")
else
log.error(log_tag .. "unknown transaction type " .. txn_type)
end
end
-- update this runner
---@param time_now integer milliseconds
function public.update(time_now)
if not self.has_build and self.periodics.next_build_req <= time_now then
_request_build()
self.periodics.next_build_req = time_now + PERIODICS.BUILD
end
if self.periodics.next_state_req <= time_now then
_request_state()
self.periodics.next_state_req = time_now + PERIODICS.STATE
end
if self.periodics.next_tanks_req <= time_now then
_request_tanks()
self.periodics.next_tanks_req = time_now + PERIODICS.TANKS
end
self.session.post_update()
end
-- get the unit session database
function public.get_db() return self.db end
return public
end
return boiler

View File

@@ -1,4 +1,3 @@
local comms = require("scada-common.comms")
local log = require("scada-common.log") local log = require("scada-common.log")
local types = require("scada-common.types") local types = require("scada-common.types")
local util = require("scada-common.util") local util = require("scada-common.util")
@@ -7,7 +6,7 @@ local unit_session = require("supervisor.session.rtu.unit_session")
local boilerv = {} local boilerv = {}
local RTU_UNIT_TYPES = comms.RTU_UNIT_TYPES local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE
local MODBUS_FCODE = types.MODBUS_FCODE local MODBUS_FCODE = types.MODBUS_FCODE
local TXN_TYPES = { local TXN_TYPES = {
@@ -32,21 +31,22 @@ local PERIODICS = {
} }
-- create a new boilerv rtu session runner -- create a new boilerv rtu session runner
---@param session_id integer ---@nodiscard
---@param unit_id integer ---@param session_id integer RTU session ID
---@param advert rtu_advertisement ---@param unit_id integer RTU unit ID
---@param out_queue mqueue ---@param advert rtu_advertisement RTU advertisement table
---@param out_queue mqueue RTU unit message out queue
function boilerv.new(session_id, unit_id, advert, out_queue) function boilerv.new(session_id, unit_id, advert, out_queue)
-- type check -- type check
if advert.type ~= RTU_UNIT_TYPES.BOILER_VALVE then if advert.type ~= RTU_UNIT_TYPE.BOILER_VALVE then
log.error("attempt to instantiate boilerv RTU for type '" .. advert.type .. "'. this is a bug.") log.error("attempt to instantiate boilerv RTU for type '" .. types.rtu_type_to_string(advert.type) .. "'. this is a bug.")
return nil return nil
end end
local log_tag = "session.rtu(" .. session_id .. ").boilerv(" .. advert.index .. "): " local log_tag = "session.rtu(" .. session_id .. ").boilerv(" .. advert.index .. "): "
local self = { local self = {
session = unit_session.new(unit_id, advert, out_queue, log_tag, TXN_TAGS), session = unit_session.new(session_id, unit_id, advert, out_queue, log_tag, TXN_TAGS),
has_build = false, has_build = false,
periodics = { periodics = {
next_formed_req = 0, next_formed_req = 0,
@@ -58,11 +58,12 @@ function boilerv.new(session_id, unit_id, advert, out_queue)
db = { db = {
formed = false, formed = false,
build = { build = {
last_update = 0,
length = 0, length = 0,
width = 0, width = 0,
height = 0, height = 0,
min_pos = { x = 0, y = 0, z = 0 }, ---@type coordinate min_pos = types.new_zero_coordinate(),
max_pos = { x = 0, y = 0, z = 0 }, ---@type coordinate max_pos = types.new_zero_coordinate(),
boil_cap = 0.0, boil_cap = 0.0,
steam_cap = 0, steam_cap = 0,
water_cap = 0, water_cap = 0,
@@ -70,23 +71,25 @@ function boilerv.new(session_id, unit_id, advert, out_queue)
ccoolant_cap = 0, ccoolant_cap = 0,
superheaters = 0, superheaters = 0,
max_boil_rate = 0.0, max_boil_rate = 0.0,
env_loss = 0.0
}, },
state = { state = {
last_update = 0,
temperature = 0.0, temperature = 0.0,
boil_rate = 0.0 boil_rate = 0.0,
env_loss = 0.0
}, },
tanks = { tanks = {
steam = { type = "mekanism:empty_gas", amount = 0 }, ---@type tank_fluid last_update = 0,
steam = types.new_empty_gas(),
steam_need = 0, steam_need = 0,
steam_fill = 0.0, steam_fill = 0.0,
water = { type = "mekanism:empty_gas", amount = 0 }, ---@type tank_fluid water = types.new_empty_gas(),
water_need = 0, water_need = 0,
water_fill = 0.0, water_fill = 0.0,
hcool = { type = "mekanism:empty_gas", amount = 0 }, ---@type tank_fluid hcool = types.new_empty_gas(),
hcool_need = 0, hcool_need = 0,
hcool_fill = 0.0, hcool_fill = 0.0,
ccool = { type = "mekanism:empty_gas", amount = 0 }, ---@type tank_fluid ccool = types.new_empty_gas(),
ccool_need = 0, ccool_need = 0,
ccool_fill = 0.0 ccool_fill = 0.0
} }
@@ -105,14 +108,14 @@ function boilerv.new(session_id, unit_id, advert, out_queue)
-- query the build of the device -- query the build of the device
local function _request_build() local function _request_build()
-- read input registers 1 through 13 (start = 1, count = 13) -- read input registers 1 through 12 (start = 1, count = 12)
self.session.send_request(TXN_TYPES.BUILD, MODBUS_FCODE.READ_INPUT_REGS, { 1, 13 }) self.session.send_request(TXN_TYPES.BUILD, MODBUS_FCODE.READ_INPUT_REGS, { 1, 12 })
end end
-- query the state of the device -- query the state of the device
local function _request_state() local function _request_state()
-- read input registers 14 through 15 (start = 14, count = 2) -- read input registers 13 through 15 (start = 13, count = 3)
self.session.send_request(TXN_TYPES.STATE, MODBUS_FCODE.READ_INPUT_REGS, { 14, 2 }) self.session.send_request(TXN_TYPES.STATE, MODBUS_FCODE.READ_INPUT_REGS, { 13, 3 })
end end
-- query the tanks of the device -- query the tanks of the device
@@ -134,13 +137,16 @@ function boilerv.new(session_id, unit_id, advert, out_queue)
-- load in data if correct length -- load in data if correct length
if m_pkt.length == 1 then if m_pkt.length == 1 then
self.db.formed = m_pkt.data[1] self.db.formed = m_pkt.data[1]
if not self.db.formed then self.has_build = false end
else else
log.debug(log_tag .. "MODBUS transaction reply length mismatch (" .. TXN_TAGS[txn_type] .. ")") log.debug(log_tag .. "MODBUS transaction reply length mismatch (" .. TXN_TAGS[txn_type] .. ")")
end end
elseif txn_type == TXN_TYPES.BUILD then elseif txn_type == TXN_TYPES.BUILD then
-- build response -- build response
-- load in data if correct length -- load in data if correct length
if m_pkt.length == 13 then if m_pkt.length == 12 then
self.db.build.last_update = util.time_ms()
self.db.build.length = m_pkt.data[1] self.db.build.length = m_pkt.data[1]
self.db.build.width = m_pkt.data[2] self.db.build.width = m_pkt.data[2]
self.db.build.height = m_pkt.data[3] self.db.build.height = m_pkt.data[3]
@@ -153,17 +159,20 @@ function boilerv.new(session_id, unit_id, advert, out_queue)
self.db.build.ccoolant_cap = m_pkt.data[10] self.db.build.ccoolant_cap = m_pkt.data[10]
self.db.build.superheaters = m_pkt.data[11] self.db.build.superheaters = m_pkt.data[11]
self.db.build.max_boil_rate = m_pkt.data[12] self.db.build.max_boil_rate = m_pkt.data[12]
self.db.build.env_loss = m_pkt.data[13]
self.has_build = true self.has_build = true
out_queue.push_data(unit_session.RTU_US_DATA.BUILD_CHANGED, { unit = advert.reactor, type = advert.type })
else else
log.debug(log_tag .. "MODBUS transaction reply length mismatch (" .. TXN_TAGS[txn_type] .. ")") log.debug(log_tag .. "MODBUS transaction reply length mismatch (" .. TXN_TAGS[txn_type] .. ")")
end end
elseif txn_type == TXN_TYPES.STATE then elseif txn_type == TXN_TYPES.STATE then
-- state response -- state response
-- load in data if correct length -- load in data if correct length
if m_pkt.length == 2 then if m_pkt.length == 3 then
self.db.state.last_update = util.time_ms()
self.db.state.temperature = m_pkt.data[1] self.db.state.temperature = m_pkt.data[1]
self.db.state.boil_rate = m_pkt.data[2] self.db.state.boil_rate = m_pkt.data[2]
self.db.state.env_loss = m_pkt.data[3]
else else
log.debug(log_tag .. "MODBUS transaction reply length mismatch (" .. TXN_TAGS[txn_type] .. ")") log.debug(log_tag .. "MODBUS transaction reply length mismatch (" .. TXN_TAGS[txn_type] .. ")")
end end
@@ -171,18 +180,19 @@ function boilerv.new(session_id, unit_id, advert, out_queue)
-- tanks response -- tanks response
-- load in data if correct length -- load in data if correct length
if m_pkt.length == 12 then if m_pkt.length == 12 then
self.db.tanks.steam = m_pkt.data[1] self.db.tanks.last_update = util.time_ms()
self.db.tanks.steam_need = m_pkt.data[2] self.db.tanks.steam = m_pkt.data[1]
self.db.tanks.steam_fill = m_pkt.data[3] self.db.tanks.steam_need = m_pkt.data[2]
self.db.tanks.water = m_pkt.data[4] self.db.tanks.steam_fill = m_pkt.data[3]
self.db.tanks.water_need = m_pkt.data[5] self.db.tanks.water = m_pkt.data[4]
self.db.tanks.water_fill = m_pkt.data[6] self.db.tanks.water_need = m_pkt.data[5]
self.db.tanks.hcool = m_pkt.data[7] self.db.tanks.water_fill = m_pkt.data[6]
self.db.tanks.hcool_need = m_pkt.data[8] self.db.tanks.hcool = m_pkt.data[7]
self.db.tanks.hcool_fill = m_pkt.data[9] self.db.tanks.hcool_need = m_pkt.data[8]
self.db.tanks.ccool = m_pkt.data[10] self.db.tanks.hcool_fill = m_pkt.data[9]
self.db.tanks.ccool_need = m_pkt.data[11] self.db.tanks.ccool = m_pkt.data[10]
self.db.tanks.ccool_fill = m_pkt.data[12] self.db.tanks.ccool_need = m_pkt.data[11]
self.db.tanks.ccool_fill = m_pkt.data[12]
else else
log.debug(log_tag .. "MODBUS transaction reply length mismatch (" .. TXN_TAGS[txn_type] .. ")") log.debug(log_tag .. "MODBUS transaction reply length mismatch (" .. TXN_TAGS[txn_type] .. ")")
end end
@@ -221,7 +231,15 @@ function boilerv.new(session_id, unit_id, advert, out_queue)
self.session.post_update() self.session.post_update()
end end
-- invalidate build cache
function public.invalidate_cache()
self.periodics.next_formed_req = 0
self.periodics.next_build_req = 0
self.has_build = false
end
-- get the unit session database -- get the unit session database
---@nodiscard
function public.get_db() return self.db end function public.get_db() return self.db end
return public return public

View File

@@ -1,131 +0,0 @@
local comms = require("scada-common.comms")
local log = require("scada-common.log")
local types = require("scada-common.types")
local unit_session = require("supervisor.session.rtu.unit_session")
local emachine = {}
local RTU_UNIT_TYPES = comms.RTU_UNIT_TYPES
local MODBUS_FCODE = types.MODBUS_FCODE
local TXN_TYPES = {
BUILD = 1,
STORAGE = 2
}
local TXN_TAGS = {
"emachine.build",
"emachine.storage"
}
local PERIODICS = {
BUILD = 1000,
STORAGE = 500
}
-- create a new energy machine rtu session runner
---@param session_id integer
---@param unit_id integer
---@param advert rtu_advertisement
---@param out_queue mqueue
function emachine.new(session_id, unit_id, advert, out_queue)
-- type check
if advert.type ~= RTU_UNIT_TYPES.EMACHINE then
log.error("attempt to instantiate emachine RTU for type '" .. advert.type .. "'. this is a bug.")
return nil
end
local log_tag = "session.rtu(" .. session_id .. ").emachine(" .. advert.index .. "): "
local self = {
session = unit_session.new(unit_id, advert, out_queue, log_tag, TXN_TAGS),
has_build = false,
periodics = {
next_build_req = 0,
next_storage_req = 0
},
---@class emachine_session_db
db = {
build = {
max_energy = 0
},
storage = {
energy = 0,
energy_need = 0,
energy_fill = 0.0
}
}
}
local public = self.session.get()
-- PRIVATE FUNCTIONS --
-- query the build of the device
local function _request_build()
-- read input register 1 (start = 1, count = 1)
self.session.send_request(TXN_TYPES.BUILD, MODBUS_FCODE.READ_INPUT_REGS, { 1, 1 })
end
-- query the state of the energy storage
local function _request_storage()
-- read input registers 2 through 4 (start = 2, count = 3)
self.session.send_request(TXN_TYPES.STORAGE, MODBUS_FCODE.READ_INPUT_REGS, { 2, 3 })
end
-- PUBLIC FUNCTIONS --
-- handle a packet
---@param m_pkt modbus_frame
function public.handle_packet(m_pkt)
local txn_type = self.session.try_resolve(m_pkt)
if txn_type == false then
-- nothing to do
elseif txn_type == TXN_TYPES.BUILD then
-- build response
if m_pkt.length == 1 then
self.db.build.max_energy = m_pkt.data[1]
self.has_build = true
else
log.debug(log_tag .. "MODBUS transaction reply length mismatch (" .. TXN_TAGS[txn_type] .. ")")
end
elseif txn_type == TXN_TYPES.STORAGE then
-- storage response
if m_pkt.length == 3 then
self.db.storage.energy = m_pkt.data[1]
self.db.storage.energy_need = m_pkt.data[2]
self.db.storage.energy_fill = m_pkt.data[3]
else
log.debug(log_tag .. "MODBUS transaction reply length mismatch (" .. TXN_TAGS[txn_type] .. ")")
end
elseif txn_type == nil then
log.error(log_tag .. "unknown transaction reply")
else
log.error(log_tag .. "unknown transaction type " .. txn_type)
end
end
-- update this runner
---@param time_now integer milliseconds
function public.update(time_now)
if not self.has_build and self.periodics.next_build_req <= time_now then
_request_build()
self.periodics.next_build_req = time_now + PERIODICS.BUILD
end
if self.periodics.next_storage_req <= time_now then
_request_storage()
self.periodics.next_storage_req = time_now + PERIODICS.STORAGE
end
self.session.post_update()
end
-- get the unit session database
function public.get_db() return self.db end
return public
end
return emachine

View File

@@ -1,12 +1,12 @@
local comms = require("scada-common.comms")
local log = require("scada-common.log") local log = require("scada-common.log")
local types = require("scada-common.types") local types = require("scada-common.types")
local util = require("scada-common.util")
local unit_session = require("supervisor.session.rtu.unit_session") local unit_session = require("supervisor.session.rtu.unit_session")
local envd = {} local envd = {}
local RTU_UNIT_TYPES = comms.RTU_UNIT_TYPES local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE
local MODBUS_FCODE = types.MODBUS_FCODE local MODBUS_FCODE = types.MODBUS_FCODE
local TXN_TYPES = { local TXN_TYPES = {
@@ -22,27 +22,29 @@ local PERIODICS = {
} }
-- create a new environment detector rtu session runner -- create a new environment detector rtu session runner
---@nodiscard
---@param session_id integer ---@param session_id integer
---@param unit_id integer ---@param unit_id integer
---@param advert rtu_advertisement ---@param advert rtu_advertisement
---@param out_queue mqueue ---@param out_queue mqueue
function envd.new(session_id, unit_id, advert, out_queue) function envd.new(session_id, unit_id, advert, out_queue)
-- type check -- type check
if advert.type ~= RTU_UNIT_TYPES.ENV_DETECTOR then if advert.type ~= RTU_UNIT_TYPE.ENV_DETECTOR then
log.error("attempt to instantiate envd RTU for type '" .. advert.type .. "'. this is a bug.") log.error("attempt to instantiate envd RTU for type '" .. types.rtu_type_to_string(advert.type) .. "'. this is a bug.")
return nil return nil
end end
local log_tag = "session.rtu(" .. session_id .. ").envd(" .. advert.index .. "): " local log_tag = "session.rtu(" .. session_id .. ").envd(" .. advert.index .. "): "
local self = { local self = {
session = unit_session.new(unit_id, advert, out_queue, log_tag, TXN_TAGS), session = unit_session.new(session_id, unit_id, advert, out_queue, log_tag, TXN_TAGS),
periodics = { periodics = {
next_rad_req = 0 next_rad_req = 0
}, },
---@class envd_session_db ---@class envd_session_db
db = { db = {
radiation = {}, last_update = 0,
radiation = types.new_zero_radiation_reading(),
radiation_raw = 0 radiation_raw = 0
} }
} }
@@ -68,7 +70,8 @@ function envd.new(session_id, unit_id, advert, out_queue)
elseif txn_type == TXN_TYPES.RAD then elseif txn_type == TXN_TYPES.RAD then
-- radiation status response -- radiation status response
if m_pkt.length == 2 then if m_pkt.length == 2 then
self.db.radiation = m_pkt.data[1] self.db.last_update = util.time_ms()
self.db.radiation = m_pkt.data[1]
self.db.radiation_raw = m_pkt.data[2] self.db.radiation_raw = m_pkt.data[2]
else else
log.debug(log_tag .. "MODBUS transaction reply length mismatch (" .. TXN_TAGS[txn_type] .. ")") log.debug(log_tag .. "MODBUS transaction reply length mismatch (" .. TXN_TAGS[txn_type] .. ")")
@@ -91,7 +94,13 @@ function envd.new(session_id, unit_id, advert, out_queue)
self.session.post_update() self.session.post_update()
end end
-- invalidate build cache
function public.invalidate_cache()
-- no build cache for this device
end
-- get the unit session database -- get the unit session database
---@nodiscard
function public.get_db() return self.db end function public.get_db() return self.db end
return public return public

View File

@@ -1,12 +1,12 @@
local comms = require("scada-common.comms")
local log = require("scada-common.log") local log = require("scada-common.log")
local types = require("scada-common.types") local types = require("scada-common.types")
local util = require("scada-common.util")
local unit_session = require("supervisor.session.rtu.unit_session") local unit_session = require("supervisor.session.rtu.unit_session")
local imatrix = {} local imatrix = {}
local RTU_UNIT_TYPES = comms.RTU_UNIT_TYPES local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE
local MODBUS_FCODE = types.MODBUS_FCODE local MODBUS_FCODE = types.MODBUS_FCODE
local TXN_TYPES = { local TXN_TYPES = {
@@ -31,21 +31,22 @@ local PERIODICS = {
} }
-- create a new imatrix rtu session runner -- create a new imatrix rtu session runner
---@param session_id integer ---@nodiscard
---@param unit_id integer ---@param session_id integer RTU session ID
---@param advert rtu_advertisement ---@param unit_id integer RTU unit ID
---@param out_queue mqueue ---@param advert rtu_advertisement RTU advertisement table
---@param out_queue mqueue RTU unit message out queue
function imatrix.new(session_id, unit_id, advert, out_queue) function imatrix.new(session_id, unit_id, advert, out_queue)
-- type check -- type check
if advert.type ~= RTU_UNIT_TYPES.IMATRIX then if advert.type ~= RTU_UNIT_TYPE.IMATRIX then
log.error("attempt to instantiate imatrix RTU for type '" .. advert.type .. "'. this is a bug.") log.error("attempt to instantiate imatrix RTU for type '" .. types.rtu_type_to_string(advert.type) .. "'. this is a bug.")
return nil return nil
end end
local log_tag = "session.rtu(" .. session_id .. ").imatrix(" .. advert.index .. "): " local log_tag = "session.rtu(" .. session_id .. ").imatrix(" .. advert.index .. "): "
local self = { local self = {
session = unit_session.new(unit_id, advert, out_queue, log_tag, TXN_TAGS), session = unit_session.new(session_id, unit_id, advert, out_queue, log_tag, TXN_TAGS),
has_build = false, has_build = false,
periodics = { periodics = {
next_formed_req = 0, next_formed_req = 0,
@@ -57,21 +58,24 @@ function imatrix.new(session_id, unit_id, advert, out_queue)
db = { db = {
formed = false, formed = false,
build = { build = {
last_update = 0,
length = 0, length = 0,
width = 0, width = 0,
height = 0, height = 0,
min_pos = 0, min_pos = types.new_zero_coordinate(),
max_pos = 0, max_pos = types.new_zero_coordinate(),
max_energy = 0, max_energy = 0,
transfer_cap = 0, transfer_cap = 0,
cells = 0, cells = 0,
providers = 0 providers = 0
}, },
state = { state = {
last_update = 0,
last_input = 0, last_input = 0,
last_output = 0 last_output = 0
}, },
tanks = { tanks = {
last_update = 0,
energy = 0, energy = 0,
energy_need = 0, energy_need = 0,
energy_fill = 0.0 energy_fill = 0.0
@@ -120,6 +124,8 @@ function imatrix.new(session_id, unit_id, advert, out_queue)
-- load in data if correct length -- load in data if correct length
if m_pkt.length == 1 then if m_pkt.length == 1 then
self.db.formed = m_pkt.data[1] self.db.formed = m_pkt.data[1]
if not self.db.formed then self.has_build = false end
else else
log.debug(log_tag .. "MODBUS transaction reply length mismatch (" .. TXN_TAGS[txn_type] .. ")") log.debug(log_tag .. "MODBUS transaction reply length mismatch (" .. TXN_TAGS[txn_type] .. ")")
end end
@@ -127,6 +133,7 @@ function imatrix.new(session_id, unit_id, advert, out_queue)
-- build response -- build response
-- load in data if correct length -- load in data if correct length
if m_pkt.length == 9 then if m_pkt.length == 9 then
self.db.build.last_update = util.time_ms()
self.db.build.length = m_pkt.data[1] self.db.build.length = m_pkt.data[1]
self.db.build.width = m_pkt.data[2] self.db.build.width = m_pkt.data[2]
self.db.build.height = m_pkt.data[3] self.db.build.height = m_pkt.data[3]
@@ -137,6 +144,8 @@ function imatrix.new(session_id, unit_id, advert, out_queue)
self.db.build.cells = m_pkt.data[8] self.db.build.cells = m_pkt.data[8]
self.db.build.providers = m_pkt.data[9] self.db.build.providers = m_pkt.data[9]
self.has_build = true self.has_build = true
out_queue.push_data(unit_session.RTU_US_DATA.BUILD_CHANGED, { unit = advert.reactor, type = advert.type })
else else
log.debug(log_tag .. "MODBUS transaction reply length mismatch (" .. TXN_TAGS[txn_type] .. ")") log.debug(log_tag .. "MODBUS transaction reply length mismatch (" .. TXN_TAGS[txn_type] .. ")")
end end
@@ -144,6 +153,7 @@ function imatrix.new(session_id, unit_id, advert, out_queue)
-- state response -- state response
-- load in data if correct length -- load in data if correct length
if m_pkt.length == 2 then if m_pkt.length == 2 then
self.db.state.last_update = util.time_ms()
self.db.state.last_input = m_pkt.data[1] self.db.state.last_input = m_pkt.data[1]
self.db.state.last_output = m_pkt.data[2] self.db.state.last_output = m_pkt.data[2]
else else
@@ -153,6 +163,7 @@ function imatrix.new(session_id, unit_id, advert, out_queue)
-- tanks response -- tanks response
-- load in data if correct length -- load in data if correct length
if m_pkt.length == 3 then if m_pkt.length == 3 then
self.db.tanks.last_update = util.time_ms()
self.db.tanks.energy = m_pkt.data[1] self.db.tanks.energy = m_pkt.data[1]
self.db.tanks.energy_need = m_pkt.data[2] self.db.tanks.energy_need = m_pkt.data[2]
self.db.tanks.energy_fill = m_pkt.data[3] self.db.tanks.energy_fill = m_pkt.data[3]
@@ -194,7 +205,15 @@ function imatrix.new(session_id, unit_id, advert, out_queue)
self.session.post_update() self.session.post_update()
end end
-- invalidate build cache
function public.invalidate_cache()
self.periodics.next_formed_req = 0
self.periodics.next_build_req = 0
self.has_build = false
end
-- get the unit session database -- get the unit session database
---@nodiscard
function public.get_db() return self.db end function public.get_db() return self.db end
return public return public

Some files were not shown because too many files have changed in this diff Show More