Compare commits

...

285 Commits

Author SHA1 Message Date
Mikayla
d7e2884634 Merge pull request #221 from MikaylaFischler/devel
2023.04.22 Release
2023-04-22 11:03:47 -04:00
Mikayla Fischler
43e708aa0d #219 bugfixes with renderer exit handling 2023-04-21 23:43:28 -04:00
Mikayla
783c4936cc #213 strict sequence verification 2023-04-21 21:10:15 +00:00
Mikayla
c75f08a9f7 added python to devcontainer and recommendations 2023-04-21 18:56:32 +00:00
Mikayla
e1da8b59d3 #219 properly close out GUI on error on pocket and coordinator 2023-04-21 18:53:28 +00:00
Mikayla
706fb5ea74 updated devcontainer and workspace extension recommendations 2023-04-21 13:34:46 +00:00
Mikayla Fischler
419ca2e6ef #220 close ui on crash 2023-04-20 21:19:16 -04:00
Mikayla Fischler
4c8723eb32 #217 close log file on pocket too 2023-04-20 21:01:41 -04:00
Mikayla Fischler
5db517cedc #217 close log files on exit (including crash) 2023-04-20 21:00:10 -04:00
Mikayla Fischler
e9788abde7 #219 fixed PLC renderer crash handling 2023-04-20 20:47:14 -04:00
Mikayla
be077aa1fb Merge pull request #218 from MikaylaFischler/front-panels
#183 RTU front panel
2023-04-20 20:42:28 -04:00
Mikayla Fischler
d143015cc7 #183 RTU front panel 2023-04-20 20:40:28 -04:00
Mikayla
df45f6c984 Merge pull request #215 from MikaylaFischler/193-pocket-main-application
193 pocket main application
2023-04-19 23:01:39 -04:00
Mikayla
f6fe99a5fd Merge branch 'devel' into 193-pocket-main-application 2023-04-19 23:01:10 -04:00
Mikayla Fischler
a843c8eb79 fixes and cleanup 2023-04-19 23:00:27 -04:00
Mikayla Fischler
a614b97d02 cleanup to pass checks 2023-04-19 21:26:54 -04:00
Mikayla Fischler
eca303e289 #208 ui cleanup for indicating emergency coolant status 2023-04-19 21:21:19 -04:00
Mikayla Fischler
ccdc31ed87 fixed typo in check workflow 2023-04-19 20:40:09 -04:00
Mikayla Fischler
c49ad63d6a Merge branch 'devel' into 193-pocket-main-application 2023-04-19 20:37:19 -04:00
Mikayla Fischler
7929318096 #201 functional pocket comms with supervisor and coordinator, adjusted some UI element positioning, bugfixes with apisessions and svsessions 2023-04-19 20:35:42 -04:00
Mikayla
2371a75130 #214 log level cleanup 2023-04-19 13:30:17 +00:00
Mikayla
fee54db43e #203 removed log message on failed structure send, lowered some other log levels to debug 2023-04-18 22:01:35 +00:00
Mikayla Fischler
b48c956354 #201 coordinator apisessions for pocket access 2023-04-18 13:55:18 -04:00
Mikayla Fischler
449e393b73 #201 supervisor pocket diagnostics session 2023-04-18 13:49:59 -04:00
Mikayla Fischler
d295c2b3c3 #201 added pocket connecting screens 2023-04-18 13:47:06 -04:00
Mikayla Fischler
438ab55f4f updated lua diagnostics config 2023-04-18 13:46:00 -04:00
Mikayla
46607dd690 #208 indicate emergency coolant control on PLC front panel 2023-04-18 15:28:46 +00:00
Mikayla Fischler
33c570075c supervisor code cleanup 2023-04-17 19:48:03 -04:00
Mikayla Fischler
93776a0421 update luacheck args and copied lua extension configs to workspace 2023-04-17 15:40:30 -04:00
Mikayla
14dc814925 #201 removed redundant close handling 2023-04-17 00:22:47 +00:00
Mikayla
a7ba0e43e8 #201 pocket comms establishes 2023-04-16 23:50:16 +00:00
Mikayla Fischler
e9290540f5 #193 pocket main application core 2023-04-16 15:05:28 -04:00
Mikayla
b35bf98dec update devcontainer with extensions 2023-04-13 14:45:02 +00:00
Mikayla
59512bb0cf Create devcontainer.json 2023-04-13 10:43:03 -04:00
Mikayla
64449c6674 restore shields action to just main branch 2023-04-13 09:41:56 -04:00
Mikayla
5bcd885f53 shortened shields URLs after adjustment to action 2023-04-13 09:33:44 -04:00
Mikayla
ba70aa31dc test update of shields.yml 2023-04-13 09:29:16 -04:00
Mikayla Fischler
d9ec3d7825 Merge branch 'devel' into 193-pocket-main-application 2023-04-12 18:06:24 -04:00
Mikayla Fischler
9b9ce7eae1 finally got shields component versions working with github actions 2023-04-12 18:03:48 -04:00
Mikayla Fischler
e2a3252d8a possible fix for actions 10 2023-04-12 17:55:18 -04:00
Mikayla Fischler
c0547fe463 possible fix for actions 9 2023-04-12 17:54:18 -04:00
Mikayla Fischler
36b86a4825 possible fix for actions 8 2023-04-12 17:52:45 -04:00
Mikayla Fischler
37dd52b12b possible fix for actions 7 2023-04-12 17:50:43 -04:00
Mikayla Fischler
6b8b38b8cb possible fix for actions 6 2023-04-12 17:49:08 -04:00
Mikayla Fischler
2b23dac1fe possible fix for actions 4 2023-04-12 17:44:51 -04:00
Mikayla Fischler
76f6cca42d possible fix for actions 3 2023-04-12 17:44:00 -04:00
Mikayla Fischler
ab9e487a2d possible fix for actions 2 2023-04-12 17:41:06 -04:00
Mikayla Fischler
982fded31d possible fix for actions 2023-04-12 17:39:38 -04:00
Mikayla Fischler
a8e0538804 debugging actions 2023-04-12 17:37:16 -04:00
Mikayla Fischler
8c42a05bbd test for sheilds 2023-04-12 17:34:46 -04:00
Mikayla Fischler
60a3fc8c37 Merge branch 'main' into devel 2023-04-12 17:33:16 -04:00
Mikayla
83cc4d3067 pages fix 2023-04-12 17:25:16 -04:00
Mikayla
fb31afc89c Merge pull request #211 from MikaylaFischler/sheilds-pages
create shields.yml
2023-04-12 17:23:36 -04:00
Mikayla
36c8a9ccfa Create shields.yml 2023-04-12 17:22:44 -04:00
Mikayla Fischler
f108db9cfc alternate plan for shields 2023-04-12 17:21:39 -04:00
Mikayla Fischler
f48266e27c added subversions to readme 2023-04-12 17:09:53 -04:00
Mikayla Fischler
5c333c2a07 test for adding subversions to shields.io 2023-04-12 17:04:28 -04:00
Mikayla Fischler
df0ee7c4f7 updated shields readme elements 2023-04-12 16:07:15 -04:00
Mikayla
c987d14d8d added Luacheck GitHub action (#210)
* added shields.io elements
* #209 luacheck action
* #209 cleanup to pass luacheck
* added check statuses to readme
2023-04-12 16:02:29 -04:00
Mikayla Fischler
075a0280ac #193 WIP pocket initial app, sidebar added 2023-04-12 12:40:13 -04:00
Mikayla Fischler
4b1c982292 #209 luacheck action 2023-04-12 12:13:11 -04:00
Mikayla
e276a99cb3 added shields.io elements 2023-04-12 09:51:40 -04:00
Mikayla Fischler
3ae39b2455 #204 replaced util.strwrap implementation with cc.strings.wrap 2023-04-11 23:53:42 -04:00
Mikayla Fischler
fc9d86f23e Merge branch 'latest' 2023-04-09 18:11:20 -04:00
Mikayla Fischler
b325992a0d disabled emergency coolant example config 2023-04-09 18:10:20 -04:00
Mikayla
04d73cdcd3 Merge pull request #199 from MikaylaFischler/latest
2023.04.09 Release
2023-04-09 18:04:01 -04:00
Mikayla Fischler
0c0055d5ae disabled debug logs for release 2023-04-09 18:03:28 -04:00
Mikayla Fischler
4ef73a8580 #147 fixed bug with the fix for startup race condition with RTUs 2023-04-09 14:25:22 -04:00
Mikayla Fischler
fa88392438 someday i'll remember to gen the install manifest with the actual commit 2023-04-09 12:57:42 -04:00
Mikayla Fischler
6e95755db4 rectangle fix and RCS annunciator cleanup 2023-04-09 12:56:18 -04:00
Mikayla Fischler
5b1f304467 updated install manifest 2023-04-09 12:30:05 -04:00
Mikayla Fischler
a2ea6438b5 #190 updated high startup rate warning per wiki 2023-04-09 12:29:29 -04:00
Mikayla
c9b67f68dd Merge pull request #196 from MikaylaFischler/front-panels
PLC Front Panel
2023-04-08 22:01:09 -04:00
Mikayla Fischler
d624690b6b comment/indentation fixes 2023-04-08 22:00:51 -04:00
Mikayla Fischler
527f3446a1 Merge branch 'devel' of github.com:MikaylaFischler/cc-mek-scada into front-panels 2023-04-08 21:51:34 -04:00
Mikayla Fischler
27a697c27e #182 added scram/reset buttons to PLC front panel 2023-04-08 21:35:44 -04:00
Mikayla Fischler
6bd7dd0271 improved rectangle graphics element feature set 2023-04-08 21:35:16 -04:00
Mikayla Fischler
67872a1053 updated graphics touch events to be mouse events 2023-04-08 21:33:54 -04:00
Mikayla Fischler
4aad591d3a #182 linked up PLC front panel indicators, cleaned up panel display and added flashing to RPS trip 2023-04-08 16:49:54 -04:00
Mikayla Fischler
9bc4f0f7a6 #182 WIP work on PLC PSIL 2023-04-08 00:38:46 -04:00
Mikayla Fischler
d642f28fa9 updated manifest 2023-04-07 08:07:58 -04:00
Mikayla Fischler
ccc0aa18ff #181 independent emergency coolant valve control 2023-04-07 08:05:14 -04:00
Mikayla Fischler
6ea530635f #182 WIP PLC front panel 2023-04-06 22:10:33 -04:00
Mikayla Fischler
c2132ea7eb #147 possible fix for MODBUS failures on server startup 2023-04-06 12:52:25 -04:00
Mikayla Fischler
40b11dbfd3 change generation to use same paths on unix vs windows 2023-04-06 12:49:23 -04:00
Mikayla Fischler
6e1edce8e7 remove unicode
make python on windows happy
2023-04-06 12:48:54 -04:00
Mikayla Fischler
efef4a845f Merge branch 'main' into devel 2023-04-04 15:48:06 -04:00
Mikayla Fischler
91f72ace24 #176 generator trip display on coordinator 2023-04-03 17:18:30 -04:00
Mikayla Fischler
0f735d049e #176 generator trip detection on supervisor 2023-04-02 09:57:57 -04:00
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
151 changed files with 16387 additions and 5254 deletions

View File

@@ -0,0 +1,16 @@
{
"image": "mcr.microsoft.com/devcontainers/universal:2",
"features": {
},
"customizations": {
"vscode": {
"extensions": [
"sumneko.lua",
"jackmacwindows.vscode-computercraft",
"ms-python.python",
"Catppuccin.catppuccin-vsc-icons",
"melishev.feather-vscode"
]
}
}
}

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.

32
.github/workflows/check.yml vendored Normal file
View File

@@ -0,0 +1,32 @@
name: Lua Checks
on:
workflow_dispatch:
push:
branches:
- main
- latest
- devel
pull_request:
branches:
- main
- latest
- devel
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: checkout
uses: actions/checkout@v3.5.1
- name: Luacheck
uses: lunarmodules/luacheck@v1.1.0
with:
# Argument Explanations
# -a = Disable warning for unused arguments
# -i 121 = Setting a read-only global variable
# 512 = Loop can be executed at most once
# 542 = An empty if branch
# --no-max-line-length = Disable warnings for long line lengths
# --exclude-files ... = Exclude lockbox library (external) and config files
# --globals ... = Override all globals overridden in .vscode/settings.json AND 'os' since CraftOS 'os' differs from Lua's 'os'
args: . --no-max-line-length -a -i 121 512 542 --exclude-files ./lockbox/* ./*/config.lua --globals os _HOST bit colors fs http parallel periphemu peripheral read rs settings shell term textutils window

47
.github/workflows/shields.yml vendored Normal file
View File

@@ -0,0 +1,47 @@
# Simple workflow for deploying static content to GitHub Pages
name: Deploy Component Versions
on:
# Runs on pushes targeting the default branch
push:
branches: ["main"]
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
contents: read
pages: write
id-token: write
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
# Single deploy job since we're just deploying
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup Pages
uses: actions/configure-pages@v3
- name: Setup Python
uses: actions/setup-python@v3.1.3
- run: mkdir shields
- run: python imgen.py shields
- name: Upload artifact
uses: actions/upload-pages-artifact@v1
with:
# Upload shields JSON
path: 'shields/'
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v2

1
.gitignore vendored
View File

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

7
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"recommendations": [
"sumneko.lua",
"jackmacwindows.vscode-computercraft",
"ms-python.python"
]
}

32
.vscode/settings.json vendored
View File

@@ -1,17 +1,29 @@
{
"Lua.diagnostics.globals": [
"term",
"fs",
"peripheral",
"rs",
"_HOST",
"bit",
"parallel",
"colors",
"textutils",
"shell",
"settings",
"window",
"fs",
"http",
"parallel",
"periphemu",
"peripheral",
"read",
"periphemu"
"rs",
"settings",
"shell",
"term",
"textutils",
"window"
],
"Lua.diagnostics.severity": {
"unused-local": "Information",
"unused-vararg": "Information",
"unused-function": "Warning",
"unused-label": "Information"
},
"Lua.hint.setType": true,
"Lua.diagnostics.disable": [
"duplicate-set-field"
]
}

View File

@@ -1,6 +1,6 @@
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
of this software and associated documentation files (the "Software"), to deal

View File

@@ -1,13 +1,52 @@
# 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!
This requires CC: Tweaked and Mekanism v10.0+ (10.1 recommended for full feature set).
![GitHub](https://img.shields.io/github/license/MikaylaFischler/cc-mek-scada)
![GitHub release (latest by date including pre-releases)](https://img.shields.io/github/v/release/MikaylaFischler/cc-mek-scada?include_prereleases)
![GitHub Workflow Status (with branch)](https://img.shields.io/github/actions/workflow/status/MikaylaFischler/cc-mek-scada/check.yml?branch=main&label=main)
![GitHub Workflow Status (with branch)](https://img.shields.io/github/actions/workflow/status/MikaylaFischler/cc-mek-scada/check.yml?branch=latest&label=latest)
![GitHub Workflow Status (with branch)](https://img.shields.io/github/actions/workflow/status/MikaylaFischler/cc-mek-scada/check.yml?branch=devel&label=devel)
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.
## Released Component Versions
### Core
![Bootloader](https://img.shields.io/endpoint?url=https%3A%2F%2Fmikaylafischler.github.io%2Fcc-mek-scada%2Fbootloader.json)
![Comms](https://img.shields.io/endpoint?url=https%3A%2F%2Fmikaylafischler.github.io%2Fcc-mek-scada%2Fcomms.json)
### Utilities
![Installer](https://img.shields.io/endpoint?url=https%3A%2F%2Fmikaylafischler.github.io%2Fcc-mek-scada%2Finstaller.json)
### Applications
![Reactor PLC](https://img.shields.io/endpoint?url=https%3A%2F%2Fmikaylafischler.github.io%2Fcc-mek-scada%2Freactor-plc.json)
![RTU](https://img.shields.io/endpoint?url=https%3A%2F%2Fmikaylafischler.github.io%2Fcc-mek-scada%2Frtu.json)
![Supervisor](https://img.shields.io/endpoint?url=https%3A%2F%2Fmikaylafischler.github.io%2Fcc-mek-scada%2Fsupervisor.json)
![Coordinator](https://img.shields.io/endpoint?url=https%3A%2F%2Fmikaylafischler.github.io%2Fcc-mek-scada%2Fcoordinator.json)
![Pocket](https://img.shields.io/endpoint?url=https%3A%2F%2Fmikaylafischler.github.io%2Fcc-mek-scada%2Fpocket.json)
## 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)
> 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)
@@ -35,7 +74,7 @@ The RTU control code is relatively unique, as instead of having instructions be
### 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.
@@ -53,9 +92,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.
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
GitHub issue \#29:
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.
None yet since the switch to requiring 10.1+!

670
ccmsi.lua Normal file
View File

@@ -0,0 +1,670 @@
--
-- ComputerCraft Mekanism SCADA System Installer Utility
--
--[[
Copyright (c) 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

@@ -1,16 +0,0 @@
local apisessions = {}
---@param packet capi_frame
function apisessions.handle_packet(packet)
end
function apisessions.check_all_watchdogs()
end
function apisessions.close_all()
end
function apisessions.free_all_closed()
end
return apisessions

View File

@@ -3,22 +3,30 @@ local config = {}
-- port of the SCADA supervisor
config.SCADA_SV_PORT = 16100
-- port to listen to incoming packets from supervisor
config.SCADA_SV_LISTEN = 16101
config.SCADA_SV_CTL_LISTEN = 16101
-- listen port for SCADA coordinator API access
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.SV_TIMEOUT = 5
config.API_TIMEOUT = 5
-- expected number of reactor units, used only to require that number of unit monitors
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
config.LOG_PATH = "/log.txt"
-- log mode
-- 0 = APPEND (adds to existing file on start)
-- 1 = NEW (replaces existing file on start)
config.LOG_MODE = 0
-- crypto config
config.SECURE = true
-- must be common between all devices
config.PASSWORD = "testpassword!"
return config

View File

@@ -1,26 +1,33 @@
local comms = require("scada-common.comms")
local log = require("scada-common.log")
local ppm = require("scada-common.ppm")
local util = require("scada-common.util")
local comms = require("scada-common.comms")
local log = require("scada-common.log")
local ppm = require("scada-common.ppm")
local util = require("scada-common.util")
local apisessions = require("coordinator.apisessions")
local iocontrol = require("coordinator.iocontrol")
local process = require("coordinator.process")
local dialog = require("coordinator.ui.dialog")
local apisessions = require("coordinator.session.apisessions")
local coordinator = {}
local dialog = require("coordinator.ui.dialog")
local print = util.print
local println = util.println
local print_ts = util.print_ts
local println_ts = util.println_ts
local PROTOCOLS = comms.PROTOCOLS
local SCADA_MGMT_TYPES = comms.SCADA_MGMT_TYPES
local SCADA_CRDN_TYPES = comms.SCADA_CRDN_TYPES
local PROTOCOL = comms.PROTOCOL
local DEVICE_TYPE = comms.DEVICE_TYPE
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
---@nodiscard
---@param names table available monitors
---@return boolean|string|nil
local function ask_monitor(names)
println("available monitors:")
for i = 1, #names do
@@ -40,6 +47,7 @@ end
-- configure monitor layout
---@param num_units integer number of units expected
---@return boolean success, monitors_struct? monitors
function coordinator.configure_monitors(num_units)
---@class monitors_struct
local monitors = {
@@ -51,27 +59,42 @@ function coordinator.configure_monitors(num_units)
local monitors_avail = ppm.get_monitor_list()
local names = {}
local available = {}
-- get all interface names
for iface, _ in pairs(monitors_avail) do
table.insert(names, iface)
table.insert(available, iface)
end
-- we need a certain number of monitors (1 per unit + 1 primary display)
if #names < num_units + 1 then
println("not enough monitors connected (need " .. num_units + 1 .. ")")
log.warning("insufficient monitors present (need " .. num_units + 1 .. ")")
local num_displays_needed = num_units + 1
if #names < num_displays_needed then
local message = "not enough monitors connected (need " .. num_displays_needed .. ")"
println(message)
log.warning(message)
return false
end
-- 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 --
---------------------
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
println("primary display is not connected")
@@ -80,15 +103,15 @@ function coordinator.configure_monitors(num_units)
iface_primary_display = nil
end
while iface_primary_display == nil and #names > 0 do
while iface_primary_display == nil and #available > 0 do
-- lets get a monitor
iface_primary_display = ask_monitor(names)
iface_primary_display = ask_monitor(available)
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)
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_name = iface_primary_display
@@ -104,10 +127,10 @@ function coordinator.configure_monitors(num_units)
for i = 1, num_units do
local display = nil
while display == nil and #names > 0 do
while display == nil and #available > 0 do
-- lets get a monitor
println("please select monitor for unit " .. i)
display = ask_monitor(names)
println("please select monitor for unit #" .. i)
display = ask_monitor(available)
end
if display == false then return false end
@@ -117,18 +140,18 @@ function coordinator.configure_monitors(num_units)
else
-- make sure all displays are connected
for i = 1, num_units do
---@diagnostic disable-next-line: need-check-nil
local display = unit_displays[i]
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
display = nil
end
while display == nil and #names > 0 do
while display == nil and #available > 0 do
-- lets get a monitor
display = ask_monitor(names)
display = ask_monitor(available)
end
if display == false then return false end
@@ -138,7 +161,9 @@ function coordinator.configure_monitors(num_units)
end
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
monitors.unit_displays[i] = ppm.get_periph(unit_displays[i])
@@ -173,55 +198,63 @@ function coordinator.log_sys(message) log_dmesg(message, "SYSTEM") end
function coordinator.log_boot(message) log_dmesg(message, "BOOT") 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
---@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
---@param version string
---@param modem table
---@param sv_port integer
---@param sv_listen integer
---@param api_listen integer
---@nodiscard
---@param version string coordinator version
---@param modem table modem device
---@param sv_port integer port of configured supervisor
---@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
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 = {
sv_linked = false,
sv_seq_num = 0,
sv_r_seq_num = nil,
modem = modem,
connected = false
sv_config_err = false,
connected = false,
last_est_ack = ESTABLISH_ACK.ALLOW,
last_api_est_acks = {}
}
---@class coord_comms
local public = {}
comms.set_trusted_range(range)
-- PRIVATE FUNCTIONS --
-- open all channels
local function _open_channels()
if not self.modem.isOpen(sv_listen) then
self.modem.open(sv_listen)
end
if not self.modem.isOpen(api_listen) then
self.modem.open(api_listen)
end
-- configure modem channels
local function _conf_channels()
modem.closeAll()
modem.open(sv_listen)
modem.open(api_listen)
end
-- open at construct time
_open_channels()
_conf_channels()
-- link modem to apisessions
apisessions.init(modem)
-- 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
local function _send_sv(protocol, msg_type, msg)
local s_pkt = comms.scada_packet()
local pkt = nil ---@type mgmt_packet|crdn_packet
local pkt ---@type mgmt_packet|crdn_packet
if protocol == PROTOCOLS.SCADA_MGMT then
if protocol == PROTOCOL.SCADA_MGMT then
pkt = comms.mgmt_packet()
elseif protocol == PROTOCOLS.SCADA_CRDN then
elseif protocol == PROTOCOL.SCADA_CRDN then
pkt = comms.crdn_packet()
else
return
@@ -230,39 +263,56 @@ function coordinator.comms(version, modem, sv_port, sv_listen, api_listen, sv_wa
pkt.make(msg_type, msg)
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
end
-- send an API establish request response
---@param dest integer
---@param msg table
local function _send_api_establish_ack(seq_id, dest, msg)
local s_pkt = comms.scada_packet()
local m_pkt = comms.mgmt_packet()
m_pkt.make(SCADA_MGMT_TYPE.ESTABLISH, msg)
s_pkt.make(seq_id, PROTOCOL.SCADA_MGMT, m_pkt.raw_sendable())
modem.transmit(dest, api_listen, s_pkt.raw_sendable())
end
-- attempt connection establishment
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
-- keep alive ack
---@param srv_time integer
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
-- PUBLIC FUNCTIONS --
---@class coord_comms
local public = {}
-- reconnect a newly connected modem
---@param modem table
---@diagnostic disable-next-line: redefined-local
function public.reconnect_modem(modem)
self.modem = modem
_open_channels()
---@param new_modem table
function public.reconnect_modem(new_modem)
modem = new_modem
apisessions.relink_modem(new_modem)
_conf_channels()
end
-- close the connection to the server
function public.close()
sv_watchdog.cancel()
self.sv_linked = false
_send_sv(PROTOCOLS.SCADA_MGMT, SCADA_MGMT_TYPES.CLOSE, {})
_send_sv(PROTOCOL.SCADA_MGMT, SCADA_MGMT_TYPE.CLOSE, {})
end
-- attempt to connect to the subervisor
---@nodiscard
---@param timeout_s number timeout in seconds
---@param tick_dmesg_waiting function callback to tick dmesg waiting
---@param task_done function callback to show done on dmesg
@@ -277,7 +327,7 @@ function coordinator.comms(version, modem, sv_port, sv_listen, api_listen, sv_wa
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()
if event == "timer" and clock.is_clock(p1) then
@@ -288,7 +338,7 @@ function coordinator.comms(version, modem, sv_port, sv_listen, api_listen, sv_wa
elseif event == "modem_message" then
-- handle message
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)
end
elseif event == "terminate" then
@@ -301,11 +351,45 @@ function coordinator.comms(version, modem, sv_port, sv_listen, api_listen, sv_wa
if terminated then
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
return self.sv_linked
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
---@param side string
---@param sender integer
@@ -322,19 +406,19 @@ function coordinator.comms(version, modem, sv_port, sv_listen, api_listen, sv_wa
if s_pkt.is_valid() then
-- 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()
if mgmt_pkt.decode(s_pkt) then
pkt = mgmt_pkt.get()
end
-- 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()
if crdn_pkt.decode(s_pkt) then
pkt = crdn_pkt.get()
end
-- 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()
if capi_pkt.decode(s_pkt) then
pkt = capi_pkt.get()
@@ -348,18 +432,81 @@ function coordinator.comms(version, modem, sv_port, sv_listen, api_listen, sv_wa
end
-- 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)
if packet ~= nil then
local l_port = packet.scada_frame.local_port()
local r_port = packet.scada_frame.remote_port()
local protocol = packet.scada_frame.protocol()
if protocol == PROTOCOLS.COORD_API then
apisessions.handle_packet(packet)
else
if l_port == api_listen then
if protocol == PROTOCOL.COORD_API then
---@cast packet capi_frame
-- look for an associated session
local session = apisessions.find_session(r_port)
-- API packet
if session ~= nil then
-- pass the packet onto the session handler
session.in_queue.push_packet(packet)
else
-- any other packet should be session related, discard it
log.debug("discarding COORD_API packet without a known session")
end
elseif protocol == PROTOCOL.SCADA_MGMT then
---@cast packet mgmt_frame
-- look for an associated session
local session = apisessions.find_session(r_port)
-- SCADA management packet
if session ~= nil then
-- pass the packet onto the session handler
session.in_queue.push_packet(packet)
elseif packet.type == SCADA_MGMT_TYPE.ESTABLISH then
-- establish a new session
local next_seq_id = packet.scada_frame.seq_num() + 1
-- validate packet and continue
if packet.length == 3 and type(packet.data[1]) == "string" and type(packet.data[2]) == "string" then
local comms_v = packet.data[1]
local firmware_v = packet.data[2]
local dev_type = packet.data[3]
if comms_v ~= comms.version then
if self.last_api_est_acks[r_port] ~= ESTABLISH_ACK.BAD_VERSION then
log.info(util.c("dropping API establish packet with incorrect comms version v", comms_v, " (expected v", comms.version, ")"))
self.last_api_est_acks[r_port] = ESTABLISH_ACK.BAD_VERSION
end
_send_api_establish_ack(next_seq_id, r_port, { ESTABLISH_ACK.BAD_VERSION })
elseif dev_type == DEVICE_TYPE.PKT then
-- pocket linking request
local id = apisessions.establish_session(l_port, r_port, firmware_v)
println(util.c("API: pocket (", firmware_v, ") [:", r_port, "] connected with session ID ", id))
coordinator.log_comms(util.c("API: pocket (", firmware_v, ") [:", r_port, "] connected with session ID ", id))
_send_api_establish_ack(next_seq_id, r_port, { ESTABLISH_ACK.ALLOW })
self.last_api_est_acks[r_port] = ESTABLISH_ACK.ALLOW
else
log.debug(util.c("illegal establish packet for device ", dev_type, " on API listening channel"))
_send_api_establish_ack(next_seq_id, r_port, { ESTABLISH_ACK.DENY })
end
else
log.debug("invalid establish packet (on API listening channel)")
_send_api_establish_ack(next_seq_id, r_port, { ESTABLISH_ACK.DENY })
end
else
-- any other packet should be session related, discard it
log.debug(util.c(r_port, "->", l_port, ": discarding SCADA_MGMT packet without a known session"))
end
else
log.debug("illegal packet type " .. protocol .. " on api listening channel", true)
end
elseif l_port == sv_listen then
-- check sequence number
if self.sv_r_seq_num == nil then
self.sv_r_seq_num = packet.scada_frame.seq_num()
elseif self.connected and self.sv_r_seq_num >= packet.scada_frame.seq_num() then
elseif self.connected and ((self.sv_r_seq_num + 1) ~= packet.scada_frame.seq_num()) then
log.warning("sequence out-of-order: last = " .. self.sv_r_seq_num .. ", new = " .. packet.scada_frame.seq_num())
return
else
@@ -370,87 +517,227 @@ function coordinator.comms(version, modem, sv_port, sv_listen, api_listen, sv_wa
sv_watchdog.feed()
-- handle packet
if protocol == PROTOCOLS.SCADA_CRDN then
if packet.type == SCADA_CRDN_TYPES.ESTABLISH then
if protocol == PROTOCOL.SCADA_CRDN 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.debug("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.debug("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
if packet.length > 1 then
-- get configuration
if packet.length == 2 then
local est_ack = packet.data[1]
local config = packet.data[2]
---@class facility_conf
local conf = {
num_units = packet.data[1],
defs = {} -- boilers and turbines
}
if est_ack == ESTABLISH_ACK.ALLOW then
if type(config) == "table" and #config > 1 then
-- get configuration
if (packet.length - 1) == (conf.num_units * 2) then
-- record sequence of pairs of [#boilers, #turbines] per unit
for i = 2, packet.length do
table.insert(conf.defs, packet.data[i])
---@class facility_conf
local conf = {
num_units = config[1], ---@type integer
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.warning("supervisor connection denied due to collision")
end
elseif est_ack == ESTABLISH_ACK.BAD_VERSION then
if self.last_est_ack ~= est_ack then
log.warning("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
-- init io controller
iocontrol.init(conf)
-- log.debug("coord RTT = " .. trip_time .. "ms")
self.sv_linked = true
iocontrol.get_db().facility.ps.publish("sv_ping", trip_time)
_send_keep_alive_ack(timestamp)
else
log.debug("supervisor conn establish packet length mismatch")
log.debug("SCADA keep alive packet length mismatch")
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
log.debug("supervisor conn establish packet length mismatch")
log.debug("received unknown SCADA_MGMT packet type " .. packet.type)
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
log.warning("received unknown SCADA_CRDN packet type " .. packet.type)
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)
log.debug("discarding non-link SCADA_MGMT packet before linked")
end
else
-- should be unreachable assuming packet is from parse_packet()
log.error("illegal packet type " .. protocol, true)
log.debug("illegal packet type " .. protocol .. " on supervisor listening channel", true)
end
else
log.debug("received packet on unconfigured channel " .. l_port, true)
end
end
end
-- check if the coordinator is still linked to the supervisor
---@nodiscard
function public.is_linked() return self.sv_linked end
return public

View File

@@ -1,26 +1,85 @@
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 = {}
---@class ioctl
local io = {}
-- placeholder acknowledge function for type hinting
---@param success boolean
---@diagnostic disable-next-line: unused-local
local function __generic_ack(success) end
-- initialize the coordinator IO controller
---@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 = {
scram = false,
num_units = conf.num_units,
ps = psil.create()
num_units = conf.num_units, ---@type integer
all_sys_ok = false,
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 = __generic_ack,
start_ack = __generic_ack,
stop_ack = __generic_ack,
scram_ack = __generic_ack,
ack_alarms_ack = __generic_ack,
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 = {}
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 = {
unit_id = i, ---@type integer
initialized = false,
unit_id = i,
num_boilers = 0,
num_turbines = 0,
@@ -28,13 +87,61 @@ function iocontrol.init(conf)
control_state = false,
burn_rate_cmd = 0.0,
waste_control = 0,
radiation = types.new_zero_radiation_reading(),
---@fixme debug stubs to be linked into comms later?
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,
-- auto control group
a_group = 0,
reactor_ps = psil.create(),
start = function () process.start(i) end,
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 = __generic_ack,
scram_ack = __generic_ack,
reset_rps_ack = __generic_ack,
ack_alarms_ack = __generic_ack,
set_burn_ack = __generic_ack,
set_waste_ack = __generic_ack,
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 = {},
@@ -44,14 +151,16 @@ function iocontrol.init(conf)
turbine_data_tbl = {}
}
-- create boiler tables
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_data_tbl, data)
end
-- create turbine tables
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_data_tbl, data)
end
@@ -61,222 +170,595 @@ function iocontrol.init(conf)
table.insert(io.units, entry)
end
-- pass IO control here since it can't be require'd due to a require loop
process.init(io, comms)
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
---@return boolean valid
function iocontrol.record_builds(builds)
if #builds ~= #io.units then
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]
function iocontrol.record_unit_builds(builds)
local valid = true
-- 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
unit.reactor_data.mek_struct = build.reactor
for key, val in pairs(unit.reactor_data.mek_struct) do
unit.reactor_ps.publish(key, val)
if type(build.reactor) == "table" then
unit.reactor_data.mek_struct = build.reactor ---@type mek_struct
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
-- boiler builds
for id, boiler in pairs(build.boilers) do
unit.boiler_data_tbl[id] = {
formed = boiler[2], ---@type boolean|nil
build = boiler[1] ---@type table
}
if type(build.boilers) == "table" then
for b_id, boiler in pairs(build.boilers) do
if type(unit.boiler_data_tbl[b_id]) == "table" then
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
unit.boiler_ps_tbl[id].publish(key, val)
for key, val in pairs(unit.boiler_data_tbl[b_id].build) do
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
-- turbine builds
for id, turbine in pairs(build.turbines) do
unit.turbine_data_tbl[id] = {
formed = turbine[2], ---@type boolean|nil
build = turbine[1] ---@type table
}
if type(build.turbines) == "table" then
for t_id, turbine in pairs(build.turbines) do
if type(unit.turbine_data_tbl[t_id]) == "table" then
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
unit.turbine_ps_tbl[id].publish(key, val)
for key, val in pairs(unit.turbine_data_tbl[t_id].build) do
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
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
-- update unit statuses
---@param statuses table
---@return boolean valid
function iocontrol.update_statuses(statuses)
if #statuses ~= #io.units then
log.error("number of provided unit statuses does not match expected number of units")
return false
function iocontrol.update_unit_statuses(statuses)
local valid = true
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
local burn_rate_sum = 0.0
-- get all unit statuses
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]
-- reactor PLC status
local reactor_status = status[1]
if #reactor_status == 0 then
unit.reactor_ps.publish("computed_status", 1) -- disconnected
if type(status) ~= "table" or #status ~= 5 then
log.debug(log_header .. "invalid status entry in unit statuses (not a table or invalid length)")
valid = false
else
local mek_status = reactor_status[1]
local rps_status = reactor_status[2]
local gen_status = reactor_status[3]
-- reactor PLC status
local reactor_status = status[1]
unit.reactor_data.last_status_update = gen_status[1]
unit.reactor_data.control_state = gen_status[2]
unit.reactor_data.rps_tripped = gen_status[3]
unit.reactor_data.rps_trip_cause = gen_status[4]
unit.reactor_data.degraded = gen_status[5]
if type(reactor_status) ~= "table" then
reactor_status = {}
log.debug(log_header .. "reactor status not a table")
end
unit.reactor_data.rps_status = rps_status ---@type rps_status
unit.reactor_data.mek_status = mek_status ---@type mek_status
if #reactor_status == 0 then
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
unit.reactor_ps.publish("computed_status", 3) -- running
else
if unit.reactor_data.degraded then
unit.reactor_ps.publish("computed_status", 5) -- faulted
elseif unit.reactor_data.rps_tripped and unit.reactor_data.rps_trip_cause ~= "manual" then
unit.reactor_ps.publish("computed_status", 4) -- SCRAM
if #gen_status == 6 then
unit.reactor_data.last_status_update = gen_status[1]
unit.reactor_data.control_state = gen_status[2]
unit.reactor_data.rps_tripped = gen_status[3]
unit.reactor_data.rps_trip_cause = gen_status[4]
unit.reactor_data.no_reactor = gen_status[5]
unit.reactor_data.formed = gen_status[6]
else
unit.reactor_ps.publish("computed_status", 2) -- 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.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])
log.debug(log_header .. "reactor general status length mismatch")
end
unit.reactor_ps.publish("TurbineTrip", any)
elseif key == "BoilerOnline" or key == "HeatingRateLow" then
-- split up array for all boilers
for id = 1, #val do
unit.boiler_ps_tbl[id].publish(key, val[id])
unit.reactor_data.rps_status = rps_status ---@type rps_status
unit.reactor_data.mek_status = mek_status ---@type mek_status
-- if status hasn't been received, mek_status = {}
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
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])
if unit.reactor_data.mek_status.status then
unit.unit_ps.publish("computed_status", 5) -- running
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
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
-- non-table fields
unit.reactor_ps.publish(key, val)
log.debug(log_header .. "reactor status length mismatch")
valid = false
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
if rtu_statuses.boilers[i] == nil then
-- disconnected
unit.boiler_ps_tbl[id].publish("computed_status", 1)
end
end
local data = unit.boiler_data_tbl[id] ---@type boilerv_session_db
for id, boiler in pairs(rtu_statuses.boilers) do
unit.boiler_data_tbl[id].state = boiler[1] ---@type table
unit.boiler_data_tbl[id].tanks = boiler[2] ---@type table
unit.boiler_ps_tbl[id].publish("formed", data.formed)
unit.boiler_ps_tbl[id].publish("faulted", rtu_faulted)
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
unit.boiler_ps_tbl[id].publish("computed_status", 3) -- active
for key, val in pairs(unit.boiler_data_tbl[id].state) do
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
unit.boiler_ps_tbl[id].publish("computed_status", 2) -- idle
log.debug(log_header .. "rtu list not a table")
valid = false
end
for key, val in pairs(unit.boiler_data_tbl[id].state) do
unit.boiler_ps_tbl[id].publish(key, val)
-- annunciator
unit.annunciator = status[3]
if type(unit.annunciator) ~= "table" then
unit.annunciator = {}
log.debug(log_header .. "annunciator state not a table")
valid = false
end
for key, val in pairs(unit.boiler_data_tbl[id].tanks) do
unit.boiler_ps_tbl[id].publish(key, val)
for key, val in pairs(unit.annunciator) do
if 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" or
key == "GeneratorTrip" or key == "TurbineTrip" 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
-- turbine statuses
-- alarms
local alarm_states = status[4]
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
if type(alarm_states) == "table" then
for id = 1, #alarm_states do
local state = alarm_states[id]
for id, turbine in pairs(rtu_statuses.turbines) do
unit.turbine_data_tbl[id].state = turbine[1] ---@type table
unit.turbine_data_tbl[id].tanks = turbine[2] ---@type table
unit.alarms[id] = state
local data = unit.turbine_data_tbl[id] ---@type turbine_session_db|turbinev_session_db
if data.tanks.steam_fill >= 0.99 then
unit.turbine_ps_tbl[id].publish("computed_status", 4) -- trip
elseif data.state.flow_rate < 100 then
unit.turbine_ps_tbl[id].publish("computed_status", 2) -- idle
if state == types.ALARM_STATE.TRIPPED or state == types.ALARM_STATE.ACKED then
unit.unit_ps.publish("Alarm_" .. id, 2)
elseif state == types.ALARM_STATE.RING_BACK then
unit.unit_ps.publish("Alarm_" .. id, 3)
else
unit.unit_ps.publish("Alarm_" .. id, 1)
end
end
else
unit.turbine_ps_tbl[id].publish("computed_status", 3) -- active
log.debug(log_header .. "alarm states not a table")
valid = false
end
for key, val in pairs(unit.turbine_data_tbl[id].state) do
unit.turbine_ps_tbl[id].publish(key, val)
end
-- unit state fields
local unit_state = status[5]
for key, val in pairs(unit.turbine_data_tbl[id].tanks) do
unit.turbine_ps_tbl[id].publish(key, val)
if type(unit_state) == "table" then
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
io.facility.ps.publish("burn_sum", burn_rate_sum)
-- update alarm sounder
sounder.eval(io.units)
end
return true
return valid
end
-- 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,50 +1,44 @@
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 unit_view = require("coordinator.ui.layout.unit_view")
local main_view = require("coordinator.ui.layout.main_view")
local unit_view = require("coordinator.ui.layout.unit_view")
local flasher = require("graphics.flasher")
local DisplayBox = require("graphics.elements.displaybox")
local renderer = {}
-- render engine
local engine = {
monitors = nil,
dmesg_window = nil,
ui_ready = false
monitors = nil, ---@type monitors_struct|nil
dmesg_window = nil, ---@type table|nil
ui_ready = false,
ui = {
main_display = nil, ---@type graphics_element|nil
unit_displays = {}
}
}
-- UI layouts
local ui = {
main_layout = nil,
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 recolor? boolean override default color palette
local function _reset_display(monitor, recolor)
local function _init_display(monitor)
monitor.setTextScale(0.5)
monitor.setTextColor(colors.white)
monitor.setBackgroundColor(colors.black)
monitor.clear()
monitor.setCursorPos(1, 1)
if recolor then
-- set overridden colors
for i = 1, #style.colors do
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
-- set overridden colors
for i = 1, #style.colors do
monitor.setPaletteColor(style.colors[i].c, style.colors[i].hex)
end
end
@@ -55,6 +49,7 @@ function renderer.set_displays(monitors)
end
-- check if the renderer is configured to use a given monitor peripheral
---@nodiscard
---@param periph table peripheral
---@return boolean is_used
function renderer.is_monitor_used(periph)
@@ -62,10 +57,8 @@ function renderer.is_monitor_used(periph)
if engine.monitors.primary == periph then
return true
else
for i = 1, #engine.monitors.unit_displays do
if engine.monitors.unit_displays[i] == periph then
return true
end
for _, monitor in ipairs(engine.monitors.unit_displays) do
if monitor == periph then return true end
end
end
end
@@ -73,23 +66,46 @@ function renderer.is_monitor_used(periph)
return false
end
-- reset all displays in use by the renderer
---@param recolor? boolean true to use color palette from style
function renderer.reset(recolor)
-- reset primary monitor
_reset_display(engine.monitors.primary, recolor)
-- init all displays in use by the renderer
function renderer.init_displays()
-- init primary monitor
_init_display(engine.monitors.primary)
-- reset unit displays
for _, monitor in pairs(engine.monitors.unit_displays) do
_reset_display(monitor, recolor)
-- init unit displays
for _, monitor in ipairs(engine.monitors.unit_displays) do
_init_display(monitor)
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 ipairs(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
function renderer.init_dmesg()
local disp_x, disp_y = engine.monitors.primary.getSize()
engine.dmesg_window = window.create(engine.monitors.primary, 1, 1, disp_x, disp_y)
log.direct_dmesg(engine.dmesg_window)
end
@@ -100,13 +116,18 @@ function renderer.start_ui()
engine.dmesg_window.setVisible(false)
-- show main view on main monitor
ui.main_layout = main_view(engine.monitors.primary)
engine.ui.main_display = DisplayBox{window=engine.monitors.primary,fg_bg=style.root}
main_view(engine.ui.main_display)
-- show unit views on unit displays
for id, monitor in pairs(engine.monitors.unit_displays) do
table.insert(ui.unit_layouts, unit_view(monitor, id))
for i = 1, #engine.monitors.unit_displays do
engine.ui.unit_displays[i] = DisplayBox{window=engine.monitors.unit_displays[i],fg_bg=style.root}
unit_view(engine.ui.unit_displays[i], i)
end
-- start flasher callback task
flasher.run()
-- report ui as ready
engine.ui_ready = true
end
@@ -114,41 +135,45 @@ end
-- close out the UI
function renderer.close_ui()
if engine.ui_ready then
-- report ui as not ready
engine.ui_ready = false
-- stop blinking indicators
flasher.clear()
-- hide to stop animation callbacks
ui.main_layout.hide()
for i = 1, #ui.unit_layouts do
ui.unit_layouts[i].hide()
engine.monitors.unit_displays[i].clear()
end
-- hide to stop animation callbacks
if engine.ui.main_display ~= nil then engine.ui.main_display.hide() end
for _, display in ipairs(engine.ui.unit_displays) do display.hide() end
-- clear root UI elements
ui.main_layout = nil
ui.unit_layouts = {}
-- report ui as not ready
engine.ui_ready = false
-- re-draw dmesg
engine.dmesg_window.setVisible(true)
engine.dmesg_window.redraw()
end
-- clear root UI elements
engine.ui.main_display = nil
engine.ui.unit_displays = {}
-- clear unit monitors
for _, monitor in ipairs(engine.monitors.unit_displays) do monitor.clear() end
-- re-draw dmesg
engine.dmesg_window.setVisible(true)
engine.dmesg_window.redraw()
end
-- is the UI ready?
---@nodiscard
---@return boolean ready
function renderer.ui_ready() return engine.ui_ready end
-- handle a touch event
---@param event monitor_touch
function renderer.handle_touch(event)
if event.monitor == engine.monitors.primary_name then
ui.main_layout.handle_touch(event)
else
for id, monitor in pairs(engine.monitors.unit_name_map) do
if event.monitor == monitor then
local layout = ui.unit_layouts[id] ---@type graphics_element
layout.handle_touch(event)
---@param event mouse_interaction
function renderer.handle_mouse(event)
if engine.ui_ready then
if event.monitor == engine.monitors.primary_name then
engine.ui.main_display.handle_mouse(event)
else
for id, monitor in ipairs(engine.monitors.unit_name_map) do
if event.monitor == monitor then
local layout = engine.ui.unit_displays[id] ---@type graphics_element
layout.handle_mouse(event)
end
end
end
end

251
coordinator/session/api.lua Normal file
View File

@@ -0,0 +1,251 @@
local comms = require("scada-common.comms")
local log = require("scada-common.log")
local mqueue = require("scada-common.mqueue")
local util = require("scada-common.util")
local api = {}
local PROTOCOL = comms.PROTOCOL
-- local CAPI_TYPE = comms.CAPI_TYPE
local SCADA_MGMT_TYPE = comms.SCADA_MGMT_TYPE
local println = util.println
-- retry time constants in ms
-- local INITIAL_WAIT = 1500
-- local RETRY_PERIOD = 1000
local API_S_CMDS = {
}
local API_S_DATA = {
}
api.API_S_CMDS = API_S_CMDS
api.API_S_DATA = API_S_DATA
local PERIODICS = {
KEEP_ALIVE = 2000
}
-- pocket API session
---@nodiscard
---@param id integer session ID
---@param in_queue mqueue in message queue
---@param out_queue mqueue out message queue
---@param timeout number communications timeout
function api.new_session(id, in_queue, out_queue, timeout)
local log_header = "api_session(" .. id .. "): "
local self = {
-- connection properties
seq_num = 0,
r_seq_num = nil,
connected = true,
conn_watchdog = util.new_watchdog(timeout),
last_rtt = 0,
-- periodic messages
periodics = {
last_update = 0,
keep_alive = 0
},
-- when to next retry one of these requests
retry_times = {
},
-- command acknowledgements
acks = {
},
-- session database
---@class api_db
sDB = {
}
}
---@class api_session
local public = {}
-- mark this API session as closed, stop watchdog
local function _close()
self.conn_watchdog.cancel()
self.connected = false
end
-- send a CAPI packet
-----@param msg_type CAPI_TYPE
-----@param msg table
-- local function _send(msg_type, msg)
-- local s_pkt = comms.scada_packet()
-- local c_pkt = comms.capi_packet()
-- c_pkt.make(msg_type, msg)
-- s_pkt.make(self.seq_num, PROTOCOL.COORD_API, c_pkt.raw_sendable())
-- out_queue.push_packet(s_pkt)
-- self.seq_num = self.seq_num + 1
-- end
-- send a SCADA management packet
---@param msg_type SCADA_MGMT_TYPE
---@param msg table
local function _send_mgmt(msg_type, msg)
local s_pkt = comms.scada_packet()
local m_pkt = comms.mgmt_packet()
m_pkt.make(msg_type, msg)
s_pkt.make(self.seq_num, PROTOCOL.SCADA_MGMT, m_pkt.raw_sendable())
out_queue.push_packet(s_pkt)
self.seq_num = self.seq_num + 1
end
-- handle a packet
---@param pkt mgmt_frame|capi_frame
local function _handle_packet(pkt)
-- check sequence number
if self.r_seq_num == nil then
self.r_seq_num = pkt.scada_frame.seq_num()
elseif (self.r_seq_num + 1) ~= pkt.scada_frame.seq_num() then
log.warning(log_header .. "sequence out-of-order: last = " .. self.r_seq_num .. ", new = " .. pkt.scada_frame.seq_num())
return
else
self.r_seq_num = pkt.scada_frame.seq_num()
end
-- feed watchdog
self.conn_watchdog.feed()
-- process packet
if pkt.scada_frame.protocol() == PROTOCOL.COORD_API then
---@cast pkt capi_frame
-- feed watchdog
self.conn_watchdog.feed()
-- handle packet by type
if pkt.type == nil then
else
log.debug(log_header .. "handler received unsupported CAPI packet type " .. pkt.type)
end
elseif pkt.scada_frame.protocol() == PROTOCOL.SCADA_MGMT then
---@cast pkt mgmt_frame
if pkt.type == SCADA_MGMT_TYPE.KEEP_ALIVE then
-- keep alive reply
if pkt.length == 2 then
local srv_start = pkt.data[1]
-- local api_send = pkt.data[2]
local srv_now = util.time()
self.last_rtt = srv_now - srv_start
if self.last_rtt > 750 then
log.warning(log_header .. "API KEEP_ALIVE round trip time > 750ms (" .. self.last_rtt .. "ms)")
end
-- log.debug(log_header .. "API RTT = " .. self.last_rtt .. "ms")
-- log.debug(log_header .. "API TT = " .. (srv_now - api_send) .. "ms")
else
log.debug(log_header .. "SCADA keep alive packet length mismatch")
end
elseif pkt.type == SCADA_MGMT_TYPE.CLOSE then
-- close the session
_close()
else
log.debug(log_header .. "handler received unsupported SCADA_MGMT packet type " .. pkt.type)
end
end
end
-- PUBLIC FUNCTIONS --
-- get the session ID
---@nodiscard
function public.get_id() return id end
-- get the session database
---@nodiscard
function public.get_db() return self.sDB end
-- check if a timer matches this session's watchdog
---@nodiscard
function public.check_wd(timer)
return self.conn_watchdog.is_timer(timer) and self.connected
end
-- close the connection
function public.close()
_close()
_send_mgmt(SCADA_MGMT_TYPE.CLOSE, {})
println("connection to API session " .. id .. " closed by server")
log.info(log_header .. "session closed by server")
end
-- iterate the session
---@nodiscard
---@return boolean connected
function public.iterate()
if self.connected then
------------------
-- handle queue --
------------------
local handle_start = util.time()
while in_queue.ready() and self.connected do
-- get a new message to process
local message = in_queue.pop()
if message ~= nil then
if message.qtype == mqueue.TYPE.PACKET then
-- handle a packet
_handle_packet(message.message)
elseif message.qtype == mqueue.TYPE.COMMAND then
-- handle instruction
elseif message.qtype == mqueue.TYPE.DATA then
-- instruction with body
end
end
-- max 100ms spent processing queue
if util.time() - handle_start > 100 then
log.warning(log_header .. "exceeded 100ms queue process limit")
break
end
end
-- exit if connection was closed
if not self.connected then
println("connection to API session " .. id .. " closed by remote host")
log.info(log_header .. "session closed by remote host")
return self.connected
end
----------------------
-- update periodics --
----------------------
local elapsed = util.time() - self.periodics.last_update
local periodics = self.periodics
-- keep alive
periodics.keep_alive = periodics.keep_alive + elapsed
if periodics.keep_alive >= PERIODICS.KEEP_ALIVE then
_send_mgmt(SCADA_MGMT_TYPE.KEEP_ALIVE, { util.time() })
periodics.keep_alive = 0
end
self.periodics.last_update = util.time()
---------------------
-- attempt retries --
---------------------
-- local rtimes = self.retry_times
end
return self.connected
end
return public
end
return api

View File

@@ -0,0 +1,174 @@
local log = require("scada-common.log")
local mqueue = require("scada-common.mqueue")
local util = require("scada-common.util")
local config = require("coordinator.config")
local api = require("coordinator.session.api")
local apisessions = {}
local self = {
modem = nil,
next_id = 0,
sessions = {}
}
-- PRIVATE FUNCTIONS --
-- handle a session output queue
---@param session api_session_struct
local function _api_handle_outq(session)
-- record handler start time
local handle_start = util.time()
-- process output queue
while session.out_queue.ready() do
-- get a new message to process
local msg = session.out_queue.pop()
if msg ~= nil then
if msg.qtype == mqueue.TYPE.PACKET then
-- handle a packet to be sent
self.modem.transmit(session.r_port, session.l_port, msg.message.raw_sendable())
elseif msg.qtype == mqueue.TYPE.COMMAND then
-- handle instruction/notification
elseif msg.qtype == mqueue.TYPE.DATA then
-- instruction/notification with body
end
end
-- max 100ms spent processing queue
if util.time() - handle_start > 100 then
log.warning("API out queue handler exceeded 100ms queue process limit")
log.warning(util.c("offending session: port ", session.r_port))
break
end
end
end
-- cleanly close a session
---@param session api_session_struct
local function _shutdown(session)
session.open = false
session.instance.close()
-- send packets in out queue (namely the close packet)
while session.out_queue.ready() do
local msg = session.out_queue.pop()
if msg ~= nil and msg.qtype == mqueue.TYPE.PACKET then
self.modem.transmit(session.r_port, session.l_port, msg.message.raw_sendable())
end
end
log.debug(util.c("closed API session ", session.instance.get_id(), " on remote port ", session.r_port))
end
-- PUBLIC FUNCTIONS --
-- initialize apisessions
---@param modem table
function apisessions.init(modem)
self.modem = modem
end
-- re-link the modem
---@param modem table
function apisessions.relink_modem(modem)
self.modem = modem
end
-- find a session by remote port
---@nodiscard
---@param port integer
---@return api_session_struct|nil
function apisessions.find_session(port)
for i = 1, #self.sessions do
if self.sessions[i].r_port == port then return self.sessions[i] end
end
return nil
end
-- establish a new API session
---@nodiscard
---@param local_port integer
---@param remote_port integer
---@param version string
---@return integer session_id
function apisessions.establish_session(local_port, remote_port, version)
---@class api_session_struct
local api_s = {
open = true,
version = version,
l_port = local_port,
r_port = remote_port,
in_queue = mqueue.new(),
out_queue = mqueue.new(),
instance = nil ---@type api_session
}
api_s.instance = api.new_session(self.next_id, api_s.in_queue, api_s.out_queue, config.API_TIMEOUT)
table.insert(self.sessions, api_s)
log.debug(util.c("established new API session to ", remote_port, " with ID ", self.next_id))
self.next_id = self.next_id + 1
-- success
return api_s.instance.get_id()
end
-- attempt to identify which session's watchdog timer fired
---@param timer_event number
function apisessions.check_all_watchdogs(timer_event)
for i = 1, #self.sessions do
local session = self.sessions[i] ---@type api_session_struct
if session.open then
local triggered = session.instance.check_wd(timer_event)
if triggered then
log.debug(util.c("watchdog closing API session ", session.instance.get_id(),
" on remote port ", session.r_port, "..."))
_shutdown(session)
end
end
end
end
-- iterate all the API sessions
function apisessions.iterate_all()
for i = 1, #self.sessions do
local session = self.sessions[i] ---@type api_session_struct
if session.open and session.instance.iterate() then
_api_handle_outq(session)
else
session.open = false
end
end
end
-- delete all closed sessions
function apisessions.free_all_closed()
local f = function (session) return session.open end
---@param session api_session_struct
local on_delete = function (session)
log.debug(util.c("free'ing closed API session ", session.instance.get_id(),
" on remote port ", session.r_port))
end
util.filter_table(self.sessions, f, on_delete)
end
-- close all open connections
function apisessions.close_all()
for i = 1, #self.sessions do
local session = self.sessions[i] ---@type api_session_struct
if session.open then _shutdown(session) end
end
apisessions.free_all_closed()
end
return apisessions

469
coordinator/sounder.lua Normal file
View File

@@ -0,0 +1,469 @@
--
-- 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 = {}
-- note: max samples = 0x20000 (128 * 1024 samples)
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 _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()
local crash = require("scada-common.crash")
local log = require("scada-common.log")
local ppm = require("scada-common.ppm")
local tcallbackdsp = require("scada-common.tcallbackdsp")
@@ -11,16 +12,17 @@ local util = require("scada-common.util")
local core = require("graphics.core")
local apisessions = require("coordinator.apisessions")
local config = require("coordinator.config")
local coordinator = require("coordinator.coordinator")
local iocontrol = require("coordinator.iocontrol")
local renderer = require("coordinator.renderer")
local sounder = require("coordinator.sounder")
local COORDINATOR_VERSION = "alpha-v0.4.12"
local apisessions = require("coordinator.session.apisessions")
local COORDINATOR_VERSION = "v0.13.8"
local print = util.print
local println = util.println
local print_ts = util.print_ts
local println_ts = util.println_ts
local log_graphics = coordinator.log_graphics
@@ -36,14 +38,19 @@ local log_comms_connecting = coordinator.log_comms_connecting
local cfv = util.new_validator()
cfv.assert_port(config.SCADA_SV_PORT)
cfv.assert_port(config.SCADA_SV_LISTEN)
cfv.assert_port(config.SCADA_SV_CTL_LISTEN)
cfv.assert_port(config.SCADA_API_LISTEN)
cfv.assert_type_int(config.TRUSTED_RANGE)
cfv.assert_type_num(config.SV_TIMEOUT)
cfv.assert_min(config.SV_TIMEOUT, 2)
cfv.assert_type_num(config.API_TIMEOUT)
cfv.assert_min(config.API_TIMEOUT, 2)
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_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")
----------------------------------------
@@ -57,262 +64,331 @@ log.info("BOOTING coordinator.startup " .. COORDINATOR_VERSION)
log.info("========================================")
println(">> SCADA 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)
crash.set_env("coordinator", COORDINATOR_VERSION)
----------------------------------------
-- setup communications
-- main application
----------------------------------------
-- get the communications modem
local modem = ppm.get_wireless_modem()
if modem == nil then
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
local function main()
----------------------------------------
-- system startup
----------------------------------------
-- create connection watchdog
local conn_watchdog = util.new_watchdog(5)
conn_watchdog.cancel()
log.debug("boot> conn watchdog created")
-- mount connected devices
ppm.mount_all()
-- 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, conn_watchdog)
log.debug("boot> comms init")
log_comms("comms initialized")
-- base loop clock (2Hz, 10 ticks)
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
-- setup monitors
local configured, monitors = coordinator.configure_monitors(config.NUM_UNITS)
if not configured or monitors == nil then
println("startup> monitor setup failed")
log.fatal("monitor configuration failed")
return
end
return true
end
-- init renderer
renderer.set_displays(monitors)
renderer.init_displays()
if not init_connect_sv() then
println("boot> failed to connect to supervisor")
log_sys("system shutdown")
return
else
log_sys("supervisor connected, proceeding to UI start")
end
if not renderer.validate_main_display_width() then
println("startup> main display must be 8 blocks wide")
log.fatal("main display not wide enough")
return
elseif not renderer.validate_unit_display_sizes() then
println("startup> one or more unit display dimensions incorrect; they must be 4x4 blocks")
log.fatal("unit display dimensions incorrect")
return
end
----------------------------------------
-- start the UI
----------------------------------------
renderer.init_dmesg()
-- start up the UI
---@return boolean ui_ok started ok
local function init_start_ui()
log_graphics("starting UI...")
-- util.psleep(3)
-- lets get started!
log.info("monitors ready, dmesg output incoming...")
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
renderer.close_ui()
log_graphics(util.c("UI crashed: ", message))
println_ts("UI crashed")
log.fatal(util.c("ui crashed with error ", message))
----------------------------------------
-- setup alarm sounder subsystem
----------------------------------------
local speaker = ppm.get_device("speaker")
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
log_graphics("first UI draw took " .. (util.time_ms() - draw_start) .. "ms")
-- start clock
loop_clock.start()
local sounder_start = util.time_ms()
log_boot("annunciator alarm speaker connected")
sounder.init(speaker, config.SOUNDER_VOLUME)
log_boot("tone generation took " .. (util.time_ms() - sounder_start) .. "ms")
log_sys("annunciator alarm configured")
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
----------------------------------------
-- main event loop
----------------------------------------
-- create connection watchdog
local conn_watchdog = util.new_watchdog(config.SV_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_CTL_LISTEN,
config.SCADA_API_LISTEN, config.TRUSTED_RANGE, conn_watchdog)
log.debug("startup> comms init")
log_comms("comms initialized")
-- start connection watchdog
conn_watchdog.feed()
log.debug("boot> conn watchdog started")
-- base loop clock (2Hz, 10 ticks)
local MAIN_CLOCK = 0.5
local loop_clock = util.new_clock(MAIN_CLOCK)
log_sys("system started successfully")
----------------------------------------
-- connect to the supervisor
----------------------------------------
-- event loop
-- ui_ok will never change in this loop, same as while true or exit if UI start failed
while ui_ok do
local event, param1, param2, param3, param4, param5 = util.pull_event()
-- 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)
-- handle event
if event == "peripheral_detach" then
local type, device = ppm.handle_unmount(param1)
-- attempt to establish a connection with the supervisory computer
if not coord_comms.sv_connect(60, tick_waiting, task_done) then
log_sys("supervisor connection failed, shutting down...")
log.fatal("failed to connect to supervisor")
return false
end
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!")
log.error("comms modem disconnected!")
return true
end
-- close out UI
renderer.close_ui()
if not init_connect_sv() then
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...")
else
log_sys("non-comms modem disconnected")
log.warning("non-comms modem disconnected")
end
elseif type == "monitor" then
if renderer.is_monitor_used(device) then
-- "halt and catch fire" style handling
log_sys("lost a configured monitor, system will now exit")
break
else
log_sys("lost unused monitor, ignoring")
----------------------------------------
-- start the UI
----------------------------------------
-- start up the UI
---@return boolean ui_ok started ok
local function init_start_ui()
log_graphics("starting UI...")
local draw_start = util.time_ms()
local ui_ok, message = pcall(renderer.start_ui)
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
elseif event == "peripheral" then
local type, device = ppm.mount(param1)
elseif event == "peripheral" then
local type, device = ppm.mount(param1)
if type ~= nil and device ~= nil then
if type == "modem" then
if device.isWireless() then
-- reconnected modem
no_modem = false
modem = device
coord_comms.reconnect_modem(modem)
if type ~= nil and device ~= nil then
if type == "modem" then
if device.isWireless() then
-- reconnected modem
no_modem = false
modem = device
coord_comms.reconnect_modem(modem)
log_sys("comms modem reconnected")
println_ts("wireless modem reconnected.")
log_sys("comms 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
-- iterate sessions
apisessions.iterate_all()
-- 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
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
else
-- a non-clock/main watchdog timer event
-- check API watchdogs
apisessions.check_all_watchdogs(param1)
-- notify timer callback dispatcher
tcallbackdsp.handle(param1)
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_mouse(core.events.touch(param1, param2, param3))
elseif event == "speaker_audio_empty" then
-- handle speaker buffer emptied
sounder.continue()
end
elseif event == "timer" then
if loop_clock.is_clock(param1) then
-- main loop tick
-- free any closed sessions
--apisessions.free_all_closed()
loop_clock.start()
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
-- check for termination request
if event == "terminate" or ppm.should_terminate() then
println_ts("terminate requested, closing connections...")
log_comms("terminate requested, closing supervisor connection...")
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
else
-- a non-clock/main watchdog timer event
--check API watchdogs
--apisessions.check_all_watchdogs(param1)
-- notify timer callback dispatcher
tcallbackdsp.handle(param1)
log_comms("supervisor connection closed")
log_comms("closing api sessions...")
apisessions.close_all()
log_comms("api sessions closed")
break
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
-- check for termination request
if event == "terminate" or ppm.should_terminate() then
println_ts("terminate requested, closing connections...")
log_comms("terminate requested, closing supervisor connection...")
coord_comms.close()
log_comms("supervisor connection closed")
log_comms("closing api sessions...")
apisessions.close_all()
log_comms("api sessions closed")
break
end
renderer.close_ui()
sounder.stop()
log_sys("system shutdown")
println_ts("exited")
log.info("exited")
end
renderer.close_ui()
log_sys("system shutdown")
println_ts("exited")
log.info("exited")
if not xpcall(main, crash.handler) then
pcall(renderer.close_ui)
pcall(sounder.stop)
crash.exit()
else
log.close()
end

View File

@@ -1,11 +1,12 @@
local style = require("coordinator.ui.style")
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 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 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 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 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 core = require("graphics.core")
local types = require("scada-common.types")
local style = require("coordinator.ui.style")
local HorizontalBar = require("graphics.elements.indicators.hbar")
local DataIndicator = require("graphics.elements.indicators.data")
local StateIndicator = require("graphics.elements.indicators.state")
local core = require("graphics.core")
local Rectangle = require("graphics.elements.rectangle")
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 border = core.graphics.border
@@ -19,17 +18,16 @@ local border = core.graphics.border
---@param root graphics_element parent
---@param x integer top left x
---@param y integer top left y
---@param data reactor_db reactor data
---@param ps psil ps interface
local function new_view(root, x, y, data, ps)
local function new_view(root, x, y, ps)
local reactor = Rectangle{parent=root,border=border(1, colors.gray, true),width=30,height=7,x=x,y=y}
local text_fg_bg = cpair(colors.black, colors.lightGray)
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 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}
ps.subscribe("computed_status", status.update)
@@ -44,16 +42,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="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 ccool = HorizontalBar{parent=reactor_fills,x=8,y=2,show_percent=true,bar_fg_bg=ccool_color,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 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=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}
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("ccool_fill", ccool.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 DataIndicator = require("graphics.elements.indicators.data")
local StateIndicator = require("graphics.elements.indicators.state")
local core = require("graphics.core")
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
@@ -21,17 +26,22 @@ local function new_view(root, x, y, ps)
local text_fg_bg = cpair(colors.black, colors.lightGray)
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 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 status = StateIndicator{parent=turbine,x=7,y=1,states=style.turbine.states,value=1,min_width=12}
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}
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)
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("energy_fill", energy.update)
end
return new_view

View File

@@ -2,8 +2,6 @@
-- Reactor Unit SCADA Coordinator GUI
--
local tcallbackdsp = require("scada-common.tcallbackdsp")
local iocontrol = require("coordinator.iocontrol")
local style = require("coordinator.ui.style")
@@ -11,30 +9,60 @@ 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 ColorMap = require("graphics.elements.colormap")
local AlarmLight = require("graphics.elements.indicators.alight")
local CoreMap = require("graphics.elements.indicators.coremap")
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 VerticalBar = require("graphics.elements.indicators.vbar")
local HazardButton = require("graphics.elements.controls.hazard_button")
local MultiButton = require("graphics.elements.controls.multi_button")
local PushButton = require("graphics.elements.controls.push_button")
local SCRAMButton = require("graphics.elements.controls.scram_button")
local StartButton = require("graphics.elements.controls.start_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
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
---@param parent graphics_element parent
---@param id integer
local function init(parent, id)
local unit = iocontrol.get_db().units[id] ---@type ioctl_entry
local r_ps = unit.reactor_ps
local unit = iocontrol.get_db().units[id] ---@type ioctl_unit
local f_ps = iocontrol.get_db().facility.ps
local u_ps = unit.unit_ps
local b_ps = unit.boiler_ps_tbl
local t_ps = unit.turbine_ps_tbl
@@ -42,246 +70,475 @@ local function init(parent, id)
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 lu_cpair = cpair(colors.gray, colors.gray)
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)
-----------------------------
-- 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}
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}
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}
r_ps.subscribe("temp", core_temp.update)
main.line_break()
TextBox{parent=main,x=12,y=25,text="Commanded Burn Rate",height=1,width=19,fg_bg=style.label}
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}
u_ps.subscribe("burn_rate", burn_r.update)
TextBox{parent=main,x=21,text="Burn Rate",height=1,width=12,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}
r_ps.subscribe("act_burn_rate", act_burn_r.update)
main.line_break()
TextBox{parent=main,text="F",x=2,y=22,width=1,height=1,fg_bg=style.label}
TextBox{parent=main,text="C",x=4,y=22,width=1,height=1,fg_bg=style.label}
TextBox{parent=main,text="\x1a",x=6,y=24,width=1,height=1,fg_bg=style.label}
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 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}
r_ps.subscribe("burn_rate", burn_r.update)
main.line_break()
local fuel = VerticalBar{parent=main,x=2,y=23,fg_bg=cpair(colors.black,colors.gray),height=4,width=1}
local ccool = VerticalBar{parent=main,x=4,y=23,fg_bg=cpair(colors.blue,colors.gray),height=4,width=1}
local hcool = VerticalBar{parent=main,x=8,y=23,fg_bg=cpair(colors.white,colors.gray),height=4,width=1}
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}
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}
r_ps.subscribe("heating_rate", heating_r.update)
main.line_break()
u_ps.subscribe("fuel_fill", fuel.update)
u_ps.subscribe("ccool_fill", ccool.update)
u_ps.subscribe("hcool_fill", hcool.update)
u_ps.subscribe("waste_fill", waste.update)
TextBox{parent=main,x=21,text="Containment Integrity",height=2,width=12,fg_bg=style.label}
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}
r_ps.subscribe("damage", function (x) integ.update(100.0 - x) end)
main.line_break()
u_ps.subscribe("ccool_type", function (type)
if type == "mekanism:sodium" then
ccool.recolor(cpair(colors.lightBlue, colors.gray))
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}
-- TextBox{parent=main,text="WS",x=24,y=19,height=1,width=2,fg_bg=style.label}
-- TextBox{parent=main,text="CL",x=28,y=19,height=1,width=2,fg_bg=style.label}
-- TextBox{parent=main,text="HC",x=31,y=19,height=1,width=2,fg_bg=style.label}
u_ps.subscribe("hcool_type", function (type)
if type == "mekanism:superheated_sodium" then
hcool.recolor(cpair(colors.orange, colors.gray))
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}
-- local waste = VerticalBar{parent=main,x=24,y=12,fg_bg=cpair(colors.brown,colors.gray),height=6,width=2}
-- local ccool = VerticalBar{parent=main,x=28,y=12,fg_bg=cpair(colors.lightBlue,colors.gray),height=6,width=2}
-- 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=22,text="Core Temp",height=1,width=9,fg_bg=style.label}
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}
u_ps.subscribe("temp", core_temp.update)
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 --
-----------------
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_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)}
---@todo auto control as info sent here
local r_auto = IndicatorLight{parent=annunciator,label="Auto Control",colors=cpair(colors.blue,colors.gray)}
local rad_mon = TriIndicatorLight{parent=annunciator,label="Radiation Monitor",c1=colors.gray,c2=colors.yellow,c3=colors.green}
r_ps.subscribe("PLCOnline", plc_online.update)
r_ps.subscribe("PLCHeartbeat", plc_hbeat.update)
r_ps.subscribe("status", r_active.update)
u_ps.subscribe("PLCOnline", plc_online.update)
u_ps.subscribe("PLCHeartbeat", plc_hbeat.update)
u_ps.subscribe("RadiationMonitor", rad_mon.update)
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_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_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_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_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)
r_ps.subscribe("ManualReactorSCRAM", r_mscrm.update)
r_ps.subscribe("RCPTrip", r_rtrip.update)
r_ps.subscribe("RCSFlowLow", r_cflow.update)
r_ps.subscribe("ReactorTempHigh", r_temp.update)
r_ps.subscribe("ReactorHighDeltaT", r_rhdt.update)
r_ps.subscribe("FuelInputRateLow", r_firl.update)
r_ps.subscribe("WasteLineOcclusion", r_wloc.update)
r_ps.subscribe("HighStartupRate", r_hsrt.update)
u_ps.subscribe("ReactorSCRAM", r_scram.update)
u_ps.subscribe("ManualReactorSCRAM", r_mscrm.update)
u_ps.subscribe("AutoReactorSCRAM", r_ascrm.update)
u_ps.subscribe("RadiationWarning", rad_wrn.update)
u_ps.subscribe("RCPTrip", r_rtrip.update)
u_ps.subscribe("RCSFlowLow", r_cflow.update)
u_ps.subscribe("CoolantLevelLow", r_clow.update)
u_ps.subscribe("ReactorTempHigh", r_temp.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
local rps_trp = IndicatorLight{parent=annunciator,label="RPS Trip",colors=cpair(colors.red,colors.gray)}
local rps_dmg = IndicatorLight{parent=annunciator,label="Damage Critical",colors=cpair(colors.yellow,colors.gray)}
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)}
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 = Rectangle{parent=main,border=border(1,colors.cyan,true),thin=true,width=33,height=12,x=46,y=9}
local rps_annunc = Div{parent=rps,width=31,height=10,x=2,y=1}
r_ps.subscribe("rps_tripped", rps_trp.update)
r_ps.subscribe("dmg_crit", rps_dmg.update)
r_ps.subscribe("ex_hcool", rps_exh.update)
r_ps.subscribe("ex_waste", rps_exw.update)
r_ps.subscribe("high_temp", rps_tmp.update)
r_ps.subscribe("no_fuel", rps_nof.update)
r_ps.subscribe("no_cool", rps_noc.update)
r_ps.subscribe("fault", rps_flt.update)
r_ps.subscribe("timeout", rps_tmo.update)
local rps_trp = IndicatorLight{parent=rps_annunc,label="RPS Trip",colors=cpair(colors.red,colors.gray),flash=true,period=period.BLINK_250_MS}
local rps_dmg = IndicatorLight{parent=rps_annunc,label="Damage Level High",colors=cpair(colors.red,colors.gray),flash=true,period=period.BLINK_250_MS}
local rps_exh = IndicatorLight{parent=rps_annunc,label="Excess Heated Coolant",colors=cpair(colors.yellow,colors.gray)}
local rps_exw = IndicatorLight{parent=rps_annunc,label="Excess Waste",colors=cpair(colors.yellow,colors.gray)}
local rps_tmp = IndicatorLight{parent=rps_annunc,label="Core Temperature High",colors=cpair(colors.red,colors.gray),flash=true,period=period.BLINK_250_MS}
local rps_nof = IndicatorLight{parent=rps_annunc,label="No Fuel",colors=cpair(colors.yellow,colors.gray)}
local rps_loc = IndicatorLight{parent=rps_annunc,label="Coolant Level Low Low",colors=cpair(colors.yellow,colors.gray)}
local rps_flt = IndicatorLight{parent=rps_annunc,label="PPM Fault",colors=cpair(colors.yellow,colors.gray),flash=true,period=period.BLINK_500_MS}
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
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)}
-- cooling annunciator panel
r_ps.subscribe("BoilRateMismatch", c_brm.update)
r_ps.subscribe("CoolantFeedMismatch", c_cfm.update)
r_ps.subscribe("SteamFeedMismatch", c_sfm.update)
r_ps.subscribe("MaxWaterReturnFeed", c_mwrf.update)
r_ps.subscribe("TurbineTrip", c_tbnt.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}
local rcs = Rectangle{parent=main,border=border(1,colors.blue,true),thin=true,width=33,height=24,x=46,y=23}
local rcs_annunc = Div{parent=rcs,width=27,height=22,x=3,y=1}
local rcs_tags = Div{parent=rcs,width=2,height=16,x=1,y=7}
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)}
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)
local available_space = 16 - (unit.num_boilers * 2 + unit.num_turbines * 4)
local function _add_space()
-- if we have some extra space, add padding
rcs_tags.line_break()
rcs_annunc.line_break()
end
-- boiler annunciator panel(s)
if available_space > 0 then _add_space() end
-- machine-specific indicators
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)}
local b1_hr = IndicatorLight{parent=annunciator,label="Heating Rate Low",colors=cpair(colors.yellow,colors.gray)}
TextBox{parent=rcs_tags,x=1,text="B1",width=2,height=1,fg_bg=bw_fg_bg}
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)
end
if unit.num_boilers > 1 then
TextBox{parent=main,x=32,text="B2",width=2,height=1,fg_bg=cpair(colors.black, colors.white)}
local b2_hr = IndicatorLight{parent=annunciator,label="Heating Rate Low",colors=cpair(colors.yellow,colors.gray)}
-- note, can't (shouldn't for sure...) have 0 turbines
if (available_space > 2 and unit.num_turbines == 1) or
(available_space > 3 and unit.num_turbines == 2) or
(available_space > 4) then
_add_space()
end
TextBox{parent=rcs_tags,text="B2",width=2,height=1,fg_bg=bw_fg_bg}
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)
end
if unit.num_boilers > 0 then
main.line_break()
annunciator.line_break()
end
-- turbine annunciator panels
TextBox{parent=main,x=32,text="T1",width=2,height=1,fg_bg=cpair(colors.black, colors.white)}
local t1_sdo = TriIndicatorLight{parent=annunciator,label="Steam Dump Open",c1=colors.gray,c2=colors.yellow,c3=colors.red}
if available_space > 1 then _add_space() end
TextBox{parent=rcs_tags,text="T1",width=2,height=1,fg_bg=bw_fg_bg}
local t1_sdo = TriIndicatorLight{parent=rcs_annunc,label="Steam Relief Valve Open",c1=colors.gray,c2=colors.yellow,c3=colors.red}
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)}
local t1_tos = IndicatorLight{parent=annunciator,label="Turbine Over Speed",colors=cpair(colors.red,colors.gray)}
TextBox{parent=rcs_tags,text="T1",width=2,height=1,fg_bg=bw_fg_bg}
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)
TextBox{parent=main,x=32,text="T1",width=2,height=1,fg_bg=cpair(colors.black, colors.white)}
local t1_trp = IndicatorLight{parent=annunciator,label="Turbine Trip",colors=cpair(colors.red,colors.gray)}
TextBox{parent=rcs_tags,text="T1",width=2,height=1,fg_bg=bw_fg_bg}
local t1_gtrp = IndicatorLight{parent=rcs_annunc,label="Generator Trip",colors=cpair(colors.yellow,colors.gray),flash=true,period=period.BLINK_250_MS}
t_ps[1].subscribe("GeneratorTrip", t1_gtrp.update)
TextBox{parent=rcs_tags,text="T1",width=2,height=1,fg_bg=bw_fg_bg}
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)
main.line_break()
annunciator.line_break()
if unit.num_turbines > 1 then
TextBox{parent=main,x=32,text="T2",width=2,height=1,fg_bg=cpair(colors.black, colors.white)}
local t2_sdo = TriIndicatorLight{parent=annunciator,label="Steam Dump Open",c1=colors.gray,c2=colors.yellow,c3=colors.red}
if (available_space > 2 and unit.num_turbines == 2) or available_space > 3 then
_add_space()
end
TextBox{parent=rcs_tags,text="T2",width=2,height=1,fg_bg=bw_fg_bg}
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)
TextBox{parent=main,x=32,text="T2",width=2,height=1,fg_bg=cpair(colors.black, colors.white)}
local t2_tos = IndicatorLight{parent=annunciator,label="Turbine Over Speed",colors=cpair(colors.red,colors.gray)}
TextBox{parent=rcs_tags,text="T2",width=2,height=1,fg_bg=bw_fg_bg}
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)
TextBox{parent=main,x=32,text="T2",width=2,height=1,fg_bg=cpair(colors.black, colors.white)}
local t2_trp = IndicatorLight{parent=annunciator,label="Turbine Trip",colors=cpair(colors.red,colors.gray)}
t_ps[2].subscribe("TurbineTrip", t2_trp.update)
TextBox{parent=rcs_tags,text="T2",width=2,height=1,fg_bg=bw_fg_bg}
local t2_gtrp = IndicatorLight{parent=rcs_annunc,label="Generator Trip",colors=cpair(colors.yellow,colors.gray),flash=true,period=period.BLINK_250_MS}
t_ps[2].subscribe("GeneratorTrip", t2_gtrp.update)
main.line_break()
annunciator.line_break()
TextBox{parent=rcs_tags,text="T2",width=2,height=1,fg_bg=bw_fg_bg}
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)
end
if unit.num_turbines > 2 then
TextBox{parent=main,x=32,text="T3",width=2,height=1,fg_bg=cpair(colors.black, colors.white)}
local t3_sdo = TriIndicatorLight{parent=annunciator,label="Steam Dump Open",c1=colors.gray,c2=colors.yellow,c3=colors.red}
if available_space > 3 then _add_space() end
TextBox{parent=rcs_tags,text="T3",width=2,height=1,fg_bg=bw_fg_bg}
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)
TextBox{parent=main,x=32,text="T3",width=2,height=1,fg_bg=cpair(colors.black, colors.white)}
local t3_tos = IndicatorLight{parent=annunciator,label="Turbine Over Speed",colors=cpair(colors.red,colors.gray)}
TextBox{parent=rcs_tags,text="T3",width=2,height=1,fg_bg=bw_fg_bg}
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)
TextBox{parent=main,x=32,text="T3",width=2,height=1,fg_bg=cpair(colors.black, colors.white)}
local t3_trp = IndicatorLight{parent=annunciator,label="Turbine Trip",colors=cpair(colors.red,colors.gray)}
t_ps[3].subscribe("TurbineTrip", t3_trp.update)
TextBox{parent=rcs_tags,text="T3",width=2,height=1,fg_bg=bw_fg_bg}
local t3_gtrp = IndicatorLight{parent=rcs_annunc,label="Generator Trip",colors=cpair(colors.yellow,colors.gray),flash=true,period=period.BLINK_250_MS}
t_ps[3].subscribe("GeneratorTrip", t3_gtrp.update)
annunciator.line_break()
TextBox{parent=rcs_tags,text="T3",width=2,height=1,fg_bg=bw_fg_bg}
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)
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 --
----------------------
StartButton{parent=main,x=12,y=44,callback=unit.start,fg_bg=scram_fg_bg}
SCRAMButton{parent=main,x=22,y=44,callback=unit.scram,fg_bg=scram_fg_bg}
local dis_colors = cpair(colors.white, colors.lightGray)
local burn_control = Div{parent=main,x=12,y=40,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_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,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"}
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 = {
{
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)
}
}
u_ps.subscribe("burn_rate", burn_rate.set_value)
u_ps.subscribe("max_burn", burn_rate.set_max)
---@todo waste selection
local waste_sel_f = function (s) print("waste: " .. s) end
local waste_sel = Div{parent=main,x=2,y=48,width=29,height=2,fg_bg=cpair(colors.black, colors.white)}
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 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 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)}
TextBox{parent=waste_sel,text="Waste Processing",alignment=TEXT_ALIGN.CENTER,x=1,y=1,height=1}
unit.start_ack = start.on_response
unit.scram_ack = scram.on_response
unit.reset_rps_ack = reset.on_response
unit.ack_alarms_ack = ack_a.on_response
---@fixme test code
main.line_break()
ColorMap{parent=main,x=2,y=51}
local function start_button_en_check()
if (unit.reactor_data ~= nil) and (unit.reactor_data.mek_status ~= nil) then
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
end

View File

@@ -2,56 +2,55 @@
-- 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 boiler_view = require("coordinator.ui.components.boiler")
local turbine_view = require("coordinator.ui.components.turbine")
local Div = require("graphics.elements.div")
local PipeNetwork = require("graphics.elements.pipenet")
local TextBox = require("graphics.elements.textbox")
local Div = require("graphics.elements.div")
local PipeNetwork = require("graphics.elements.pipenet")
local TextBox = require("graphics.elements.textbox")
local TEXT_ALIGN = core.graphics.TEXT_ALIGN
local cpair = core.graphics.cpair
local border = core.graphics.border
local pipe = core.graphics.pipe
-- make a new unit overview window
---@param parent graphics_element parent
---@param x integer top left x
---@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 height = 0
local num_boilers = #unit.boiler_data_tbl
local num_turbines = #unit.turbine_data_tbl
assert(num_boilers >= 0 and num_boilers <= 2, "minimum 0 boilers, maximum 2 boilers")
assert(num_turbines >= 1 and num_turbines <= 3, "minimum 1 turbine, maximum 3 turbines")
local height = 25
if num_boilers == 0 and num_turbines == 1 then
height = 9
elseif num_boilers == 1 and num_turbines <= 2 then
height = 17
else
height = 25
end
assert(parent.height() >= (y + height), "main display not of sufficient vertical resolution (add an additional row of monitors)")
-- bounding box div
local root = Div{parent=parent,x=x,y=y,width=80,height=height}
-- 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_view(root, 1, 3, unit.reactor_data, unit.reactor_ps)
reactor_view(root, 1, 3, unit.unit_ps)
if num_boilers > 0 then
local coolant_pipes = {}
@@ -101,16 +100,16 @@ local function make(parent, x, y, unit)
local steam_pipes_b = {}
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, 2, 3, 2, colors.blue)) -- water 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
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(2, 3, 3, 10, colors.blue)) -- water 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
end
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
end
else

View File

@@ -1,33 +0,0 @@
--
-- Reactor Unit SCADA Coordinator GUI
--
local style = require("coordinator.ui.style")
local core = require("graphics.core")
local Div = require("graphics.elements.div")
local TextBox = require("graphics.elements.textbox")
local WaitingAnim = require("graphics.elements.animations.waiting")
local TEXT_ALIGN = core.graphics.TEXT_ALIGN
local cpair = core.graphics.cpair
-- create a unit waiting view
---@param parent graphics_element parent
---@param y integer y offset
local function init(parent, y)
-- bounding box div
local root = Div{parent=parent,x=1,y=y,height=5}
local waiting_x = math.floor(parent.width() / 2) - 2
TextBox{parent=root,text="Waiting for status...",alignment=TEXT_ALIGN.CENTER,y=1,height=1,fg_bg=cpair(colors.black,style.root.bkg)}
WaitingAnim{parent=root,x=waiting_x,y=3,fg_bg=cpair(colors.blue,style.root.bkg)}
return root
end
return init

View File

@@ -1,14 +1,16 @@
local completion = require("cc.completion")
local util = require("scada-common.util")
local util = require("scada-common.util")
local print = util.print
local println = util.println
local print_ts = util.print_ts
local println_ts = util.println_ts
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)
print(question)
@@ -31,6 +33,11 @@ function dialog.ask_y_n(question, default)
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)
print("> ")
local response = read(nil, nil, function(text) return completion.choice(text, options) end)

View File

@@ -2,46 +2,88 @@
-- Main SCADA Coordinator GUI
--
local util = require("scada-common.util")
local iocontrol = require("coordinator.iocontrol")
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 core = require("graphics.core")
local DisplayBox = require("graphics.elements.displaybox")
local TextBox = require("graphics.elements.textbox")
local DataIndicator = require("graphics.elements.indicators.data")
local TEXT_ALIGN = core.graphics.TEXT_ALIGN
local cpair = core.graphics.cpair
-- create new main view
---@param monitor table main viewscreen
local function init(monitor)
local main = DisplayBox{window=monitor,fg_bg=style.root}
---@param main graphics_element main displaybox
local function init(main)
local facility = iocontrol.get_db().facility
local units = iocontrol.get_db().units
-- 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 cnc_y_start = 3
local row_1_height = 0
-- unit overviews
if db.facility.num_units >= 1 then uo_1 = unit_overview(main, 2, 3, db.units[1]) end
if db.facility.num_units >= 2 then uo_2 = unit_overview(main, 84, 3, db.units[2]) end
if facility.num_units >= 1 then
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
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])
if db.facility.num_units == 4 then uo_4 = unit_overview(main, 84, row_2_offset, db.units[4]) end
uo_3 = unit_overview(main, 2, row_2_offset, units[3])
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
-- command & control
return main
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])
end
return init

View File

@@ -2,25 +2,13 @@
-- Reactor Unit SCADA Coordinator GUI
--
local tcallbackdsp = require("scada-common.tcallbackdsp")
local iocontrol = require("coordinator.iocontrol")
local style = require("coordinator.ui.style")
local unit_detail = require("coordinator.ui.components.unit_detail")
local DisplayBox = require("graphics.elements.displaybox")
local unit_detail = require("coordinator.ui.components.unit_detail")
-- create a unit view
---@param monitor table
---@param main graphics_element main displaybox
---@param id integer
local function init(monitor, id)
local main = DisplayBox{window=monitor,fg_bg=style.root}
local function init(main, id)
unit_detail(main, id)
return main
end
return init

View File

@@ -1,3 +1,6 @@
--
-- Graphics Style Options
--
local core = require("graphics.core")
@@ -15,7 +18,7 @@ style.colors = {
{ c = colors.red, hex = 0xdf4949 },
{ c = colors.orange, hex = 0xffb659 },
{ c = colors.yellow, hex = 0xfffc79 },
{ c = colors.lime, hex = 0x64dd20 },
{ c = colors.lime, hex = 0x80ff80 },
{ c = colors.green, hex = 0x4aee8a },
{ c = colors.cyan, hex = 0x34bac8 },
{ c = colors.lightBlue, hex = 0x6cc0f2 },
@@ -39,6 +42,14 @@ style.reactor = {
color = cpair(colors.black, colors.yellow),
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),
text = "DISABLED"
@@ -52,8 +63,8 @@ style.reactor = {
text = "SCRAMMED"
},
{
color = cpair(colors.black, colors.orange),
text = "PLC FAULT"
color = cpair(colors.black, colors.red),
text = "FORCE DISABLED"
}
}
}
@@ -65,6 +76,14 @@ style.boiler = {
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.white, colors.gray),
text = "IDLE"
@@ -83,6 +102,14 @@ style.turbine = {
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.white, colors.gray),
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

View File

@@ -4,21 +4,82 @@
local core = {}
local flasher = require("graphics.flasher")
core.flasher = flasher
local events = {}
---@class monitor_touch
---@enum click_type
events.click_type = {
VIRTUAL = 0,
LEFT_BUTTON = 1,
RIGHT_BUTTON = 2,
MID_BUTTON = 3
}
---@class mouse_interaction
---@field monitor string
---@field button integer
---@field x integer
---@field y integer
-- create a new touch event definition
-- create a new monitor touch mouse interaction event
---@nodiscard
---@param monitor string
---@param x integer
---@param y integer
---@return monitor_touch
---@return mouse_interaction
function events.touch(monitor, x, y)
return {
monitor = monitor,
button = events.click_type.LEFT_BUTTON,
x = x,
y = y
}
end
-- create a new mouse click mouse interaction event
---@nodiscard
---@param button click_type
---@param x integer
---@param y integer
---@return mouse_interaction
function events.click(button, x, y)
return {
monitor = "terminal",
button = button,
x = x,
y = y
}
end
-- create a new transposed mouse interaction event using the event's monitor/button fields
---@nodiscard
---@param event mouse_interaction
---@param new_x integer
---@param new_y integer
---@return mouse_interaction
function events.mouse_transposed(event, new_x, new_y)
return {
monitor = event.monitor,
button = event.button,
x = new_x,
y = new_y
}
end
-- create a new generic mouse interaction event
---@nodiscard
---@param monitor string
---@param button click_type
---@param x integer
---@param y integer
---@return mouse_interaction
function events.mouse_generic(monitor, button, x, y)
return {
monitor = monitor,
button = button,
x = x,
y = y
}
@@ -28,7 +89,7 @@ core.events = events
local graphics = {}
---@alias TEXT_ALIGN integer
---@enum TEXT_ALIGN
graphics.TEXT_ALIGN = {
LEFT = 1,
CENTER = 2,
@@ -43,6 +104,7 @@ graphics.TEXT_ALIGN = {
---@alias element_id string|integer
-- create a new border definition
---@nodiscard
---@param width integer border width
---@param color color border color
---@param even? boolean whether to pad width extra to account for rectangular pixels, defaults to false
@@ -62,6 +124,7 @@ end
---@field h integer
-- create a new graphics frame definition
---@nodiscard
---@param x integer
---@param y integer
---@param w integer
@@ -87,6 +150,7 @@ end
---@field blit_bkg string
-- create a new color pair definition
---@nodiscard
---@param a color
---@param b color
---@return cpair
@@ -116,9 +180,9 @@ end
---@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
-- create a new pipe
--
-- create a new pipe<br>
-- note: pipe coordinate origin is (0, 0)
---@nodiscard
---@param x1 integer starting x, origin is 0
---@param y1 integer starting y, origin is 0
---@param x2 integer ending x, origin is 0

View File

@@ -19,8 +19,41 @@ local element = {}
---@field gframe? graphics_frame frame instead of x/y/width/height
---@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
---|sidebar_args
---|spinbox_args
---|switch_button_args
---|alarm_indicator_light
---|core_map_args
---|data_indicator_args
---|hbar_args
---|icon_indicator_args
---|indicator_led_args
---|indicator_led_pair_args
---|indicator_led_rgb_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
---|multipane_args
---|pipenet_args
---|rectangle_args
---|textbox_args
---|tiling_args
-- 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)
local self = {
id = -1,
@@ -37,6 +70,7 @@ function element.new(args)
---@class graphics_template
local protected = {
enabled = true,
value = nil, ---@type any
window = nil, ---@type table
fg_bg = core.graphics.cpair(colors.white, colors.black),
@@ -71,7 +105,13 @@ function element.new(args)
else
local w, h = self.p_window.getSize()
protected.frame.x = args.x or 1
protected.frame.y = args.y or next_y
if args.parent ~= nil then
protected.frame.y = args.y or (next_y - offset_y)
else
protected.frame.y = args.y or next_y
end
protected.frame.w = args.width or w
protected.frame.h = args.height or h
end
@@ -128,16 +168,25 @@ function element.new(args)
self.bounds.y2 = self.position.y + f.h - 1
end
-- handle a touch event
---@param event table monitor_touch event
function protected.handle_touch(event)
---@diagnostic disable: unused-local, unused-vararg
-- handle a mouse event
---@param event mouse_interaction mouse interaction event
function protected.handle_mouse(event)
end
-- handle data value changes
---@vararg any value(s)
function protected.on_update(...)
end
-- callback on control press responses
---@param result any
function protected.response_callback(result)
end
-- get value
---@nodiscard
function protected.get_value()
return protected.value
end
@@ -145,7 +194,24 @@ function element.new(args)
-- set value
---@param value any value to set
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
-- custom recolor command, varies by element if implemented
@@ -158,6 +224,8 @@ function element.new(args)
function protected.resize(...)
end
---@diagnostic enable: unused-local, unused-vararg
-- start animations
function protected.start_anim()
end
@@ -167,6 +235,7 @@ function element.new(args)
end
-- get public interface
---@nodiscard
---@return graphics_element element, element_id id
function protected.get() return public, self.id end
@@ -195,15 +264,22 @@ function element.new(args)
----------------------
-- get the window object
---@nodiscard
function public.window() return protected.window end
-- CHILD ELEMENTS --
-- add a child element
---@nodiscard
---@param key string|nil id
---@param child graphics_template
---@return integer|string key
function public.__add_child(key, child)
-- offset first automatic placement
if self.next_y <= self.child_offset.y then
self.next_y = self.child_offset.y + 1
end
child.prepare_template(self.child_offset.x, self.child_offset.y, self.next_y)
self.next_y = child.frame.y + child.frame.h
@@ -220,6 +296,7 @@ function element.new(args)
end
-- get a child element
---@nodiscard
---@return graphics_element
function public.get_child(key) return self.children[key] end
@@ -228,6 +305,7 @@ function element.new(args)
function public.remove(key) self.children[key] = nil end
-- attempt to get a child element by ID (does not include this element itself)
---@nodiscard
---@param id element_id
---@return graphics_element|nil element
function public.get_element_by_id(id)
@@ -246,27 +324,49 @@ function element.new(args)
-- AUTO-PLACEMENT --
-- 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 --
-- get the foreground/background colors
---@nodiscard
---@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
---@nodiscard
---@return integer width
function public.width()
return protected.frame.w
end
-- get element height
---@nodiscard
---@return integer height
function public.height()
return protected.frame.h
end
-- get the element value
---@nodiscard
---@return any value
function public.get_value()
return protected.get_value()
@@ -278,6 +378,36 @@ function element.new(args)
protected.set_value(value)
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
---@vararg number dimensions (element specific)
function public.resize(...)
@@ -286,20 +416,20 @@ function element.new(args)
-- FUNCTION CALLBACKS --
-- handle a monitor touch
---@param event monitor_touch monitor touch event
function public.handle_touch(event)
-- handle a monitor touch or mouse click
---@param event mouse_interaction mouse interaction event
function public.handle_mouse(event)
local in_x = event.x >= self.bounds.x1 and event.x <= self.bounds.x2
local in_y = event.y >= self.bounds.y1 and event.y <= self.bounds.y2
if in_x and in_y then
local event_T = core.events.touch(event.monitor, (event.x - self.position.x) + 1, (event.y - self.position.y) + 1)
local event_T = core.events.mouse_transposed(event, (event.x - self.position.x) + 1, (event.y - self.position.y) + 1)
-- handle the touch event, transformed into the window frame
protected.handle_touch(event_T)
protected.handle_mouse(event_T)
-- pass on touch event to children
for _, val in pairs(self.children) do val.handle_touch(event_T) end
for _, val in pairs(self.children) do val.handle_mouse(event_T) end
end
end
@@ -309,25 +439,25 @@ function element.new(args)
protected.on_update(...)
end
-- on a control request response
---@param result any
function public.on_response(result)
protected.response_callback(result)
end
-- VISIBILITY --
-- show the element
function public.show()
protected.window.setVisible(true)
protected.start_anim()
for i = 1, #self.children do
self.children[i].show()
end
for _, child in pairs(self.children) do child.show() end
end
-- hide the element
function public.hide()
protected.stop_anim()
for i = 1, #self.children do
self.children[i].hide()
end
for _, child in pairs(self.children) do child.hide() end
protected.window.setVisible(false)
end

View File

@@ -85,7 +85,7 @@ local function waiting(args)
if state >= 12 then state = 0 end
if run_animation then
tcd.dispatch(0.5, animate)
tcd.dispatch_unique(0.15, animate)
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 mouse interaction
---@param event mouse_interaction mouse event
---@diagnostic disable-next-line: unused-local
function e.handle_mouse(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_mouse(core.events.mouse_generic("", core.events.click_type.VIRTUAL, 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
local element = require("graphics.element")
-- Multi Button Graphics Element
local util = require("scada-common.util")
local element = require("graphics.element")
---@class button_option
---@field text string
---@field fg_bg cpair
@@ -15,7 +15,7 @@ local util = require("scada-common.util")
---@class multi_button_args
---@field options table button options
---@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 parent graphics_element
---@field id? string element id
@@ -29,10 +29,15 @@ local util = require("scada-common.util")
---@return graphics_element element, element_id id
local function multi_button(args)
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.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
args.height = 3
args.height = 1
-- determine widths
local max_width = 1
@@ -43,7 +48,7 @@ local function multi_button(args)
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
@@ -71,7 +76,7 @@ local function multi_button(args)
for i = 1, #args.options do
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
-- show as pressed
@@ -87,11 +92,12 @@ local function multi_button(args)
end
end
-- handle touch
---@param event monitor_touch monitor touch event
function e.handle_touch(event)
-- handle mouse interaction
---@param event mouse_interaction mouse event
---@diagnostic disable-next-line: unused-local
function e.handle_mouse(event)
-- determine what was pressed
if event.y == 2 then
if e.enabled and event.y == 1 then
for i = 1, #args.options do
local opt = args.options[i] ---@type button_option
@@ -109,7 +115,6 @@ local function multi_button(args)
function e.set_value(val)
e.value = val
draw()
args.callback(e.value)
end
-- initial draw

View File

@@ -8,8 +8,9 @@ local element = require("graphics.element")
---@class push_button_args
---@field text string button text
---@field callback function function to call on touch
---@field min_width? integer text length + 2 if omitted
---@field min_width? integer text length if omitted
---@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 id? string element id
---@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.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)
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
local e = element.new(args)
@@ -47,34 +47,58 @@ local function push_button(args)
e.window.write(args.text)
end
-- handle touch
---@param event monitor_touch monitor touch event
-- handle mouse interaction
---@param event mouse_interaction mouse event
---@diagnostic disable-next-line: unused-local
function e.handle_touch(event)
if args.active_fg_bg ~= nil then
-- show as pressed
e.value = true
e.window.setTextColor(args.active_fg_bg.fgd)
e.window.setBackgroundColor(args.active_fg_bg.bkg)
draw()
-- 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)
function e.handle_mouse(event)
if e.enabled then
if args.active_fg_bg ~= nil then
-- show as pressed
e.value = true
e.window.setTextColor(args.active_fg_bg.fgd)
e.window.setBackgroundColor(args.active_fg_bg.bkg)
draw()
end)
end
-- call the touch callback
args.callback()
-- show as unpressed in 0.25 seconds
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
-- set the value
-- 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
if val then e.handle_mouse(core.events.mouse_generic("", core.events.click_type.VIRTUAL, 1, 1)) 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

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 mouse interaction
---@param event mouse_interaction mouse event
function e.handle_mouse(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

@@ -0,0 +1,104 @@
-- Sidebar Graphics Element
local tcd = require("scada-common.tcallbackdsp")
local element = require("graphics.element")
---@class sidebar_tab
---@field char string character identifier
---@field color cpair tab colors (fg/bg)
---@class sidebar_args
---@field tabs table sidebar tab options
---@field callback function function to call on tab change
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
---@field y? integer 1 if omitted
---@field height? integer parent height if omitted
---@field fg_bg? cpair foreground/background colors
-- new sidebar tab selector
---@param args sidebar_args
---@return graphics_element element, element_id id
local function sidebar(args)
assert(type(args.tabs) == "table", "graphics.elements.controls.sidebar: tabs is a required field")
assert(#args.tabs > 0, "graphics.elements.controls.sidebar: at least one tab is required")
assert(type(args.callback) == "function", "graphics.elements.controls.sidebar: callback is a required field")
-- always 3 wide
args.width = 3
-- create new graphics element base object
local e = element.new(args)
assert(e.frame.h >= (#args.tabs * 3), "graphics.elements.controls.sidebar: height insufficent to display all tabs")
-- default to 1st tab
e.value = 1
-- show the button state
---@param pressed boolean if the currently selected tab should appear as actively pressed
local function draw(pressed)
for i = 1, #args.tabs do
local tab = args.tabs[i] ---@type sidebar_tab
local y = ((i - 1) * 3) + 1
e.window.setCursorPos(1, y)
if pressed and e.value == i then
e.window.setTextColor(e.fg_bg.fgd)
e.window.setBackgroundColor(e.fg_bg.bkg)
else
e.window.setTextColor(tab.color.fgd)
e.window.setBackgroundColor(tab.color.bkg)
end
e.window.write(" ")
e.window.setCursorPos(1, y + 1)
if e.value == i then
-- show as selected
e.window.write(" " .. tab.char .. "\x10")
else
-- show as unselected
e.window.write(" " .. tab.char .. " ")
end
e.window.setCursorPos(1, y + 2)
e.window.write(" ")
end
end
-- handle mouse interaction
---@param event mouse_interaction mouse event
function e.handle_mouse(event)
-- determine what was pressed
if e.enabled then
local idx = math.ceil(event.y / 3)
if args.tabs[idx] ~= nil then
e.value = idx
draw(true)
-- show as unpressed in 0.25 seconds
tcd.dispatch(0.25, function () draw(false) end)
args.callback(e.value)
end
end
end
-- set the value
---@param val integer new value
function e.set_value(val)
e.value = val
draw(false)
end
-- initial draw
draw(false)
return e.get()
end
return sidebar

View File

@@ -1,14 +1,17 @@
-- Spinbox Numeric Graphics Element
local element = require("graphics.element")
local util = require("scada-common.util")
local element = require("graphics.element")
---@class spinbox_args
---@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 fractional_precision integer number of fractional digits
---@field arrow_fg_bg cpair arrow foreground/background colors
---@field arrow_disable? color color when disabled (default light gray)
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
@@ -27,8 +30,16 @@ 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(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_init = "%0" .. (wn_prec + fr_prec + 1) .. "." .. fr_prec .. "f"
local fmt, fmt_init ---@type string, string
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
assert(type(args.arrow_fg_bg) == "table", "graphics.element.spinbox_numeric: arrow_fg_bg is a required field")
@@ -41,39 +52,33 @@ local function spinbox(args)
local e = element.new(args)
-- set initial value
e.value = args.default or 0.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)
e.value = args.default or 0
-- draw the arrows
e.window.setBackgroundColor(args.arrow_fg_bg.bkg)
e.window.setTextColor(args.arrow_fg_bg.fgd)
e.window.setCursorPos(1, 1)
e.window.write(util.strrep("\x1e", wn_prec))
e.window.setCursorPos(1, 3)
e.window.write(util.strrep("\x1f", wn_prec))
if fr_prec > 0 then
e.window.setCursorPos(1 + wn_prec, 1)
e.window.write(" " .. util.strrep("\x1e", fr_prec))
e.window.setCursorPos(1 + wn_prec, 3)
e.window.write(" " .. util.strrep("\x1f", fr_prec))
local function draw_arrows(color)
e.window.setBackgroundColor(args.arrow_fg_bg.bkg)
e.window.setTextColor(color)
e.window.setCursorPos(1, 1)
e.window.write(util.strrep("\x1e", wn_prec))
e.window.setCursorPos(1, 3)
e.window.write(util.strrep("\x1f", wn_prec))
if fr_prec > 0 then
e.window.setCursorPos(1 + wn_prec, 1)
e.window.write(" " .. util.strrep("\x1e", fr_prec))
e.window.setCursorPos(1 + wn_prec, 3)
e.window.write(" " .. util.strrep("\x1f", fr_prec))
end
end
-- zero the value
local function zero()
for i = 1, #digits do digits[i] = 0 end
e.value = 0
end
draw_arrows(args.arrow_fg_bg.fgd)
-- print out the current value
local function show_num()
e.window.setBackgroundColor(e.fg_bg.bkg)
e.window.setTextColor(e.fg_bg.fgd)
e.window.setCursorPos(1, 2)
e.window.write(util.sprintf(fmt, e.value))
-- populate digits from current value
local function set_digits()
local initial_str = util.sprintf(fmt_init, e.value)
digits = {}
---@diagnostic disable-next-line: discard-returns
initial_str:gsub("%d", function (char) table.insert(digits, char) end)
end
-- update the value per digits table
@@ -89,46 +94,56 @@ local function spinbox(args)
end
end
-- enforce numeric limits
local function enforce_limits()
-- min 0
if e.value < 0 then
zero()
-- max printable
elseif string.len(util.sprintf(fmt, e.value)) > args.width then
-- max out
for i = 1, #digits do digits[i] = 9 end
-- re-update value
update_value()
-- print out the current value
local function show_num()
-- enforce limits
if (type(args.min) == "number") and (e.value < args.min) then
e.value = args.min
set_digits()
elseif e.value < 0 then
e.value = 0
set_digits()
else
if string.len(util.sprintf(fmt, e.value)) > args.width then
-- 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
-- update value and show
local function parse_and_show()
update_value()
enforce_limits()
show_num()
-- draw
e.window.setBackgroundColor(e.fg_bg.bkg)
e.window.setTextColor(e.fg_bg.fgd)
e.window.setCursorPos(1, 2)
e.window.write(util.sprintf(fmt, e.value))
end
-- init with the default value
show_num()
-- handle touch
---@param event monitor_touch monitor touch event
function e.handle_touch(event)
-- handle mouse interaction
---@param event mouse_interaction mouse event
function e.handle_mouse(event)
-- 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)
if event.y == 1 then
-- increment
digits[idx] = digits[idx] + 1
elseif event.y == 3 then
-- decrement
digits[idx] = digits[idx] - 1
end
if digits[idx] ~= nil then
if event.y == 1 then
-- increment
digits[idx] = digits[idx] + 1
elseif event.y == 3 then
-- decrement
digits[idx] = digits[idx] - 1
end
parse_and_show()
update_value()
show_num()
end
end
end
@@ -136,9 +151,39 @@ local function spinbox(args)
---@param val number number to show
function e.set_value(val)
e.value = val
parse_and_show()
show_num()
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()
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)
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
-- show the button state
@@ -51,6 +51,9 @@ local function switch_button(args)
e.window.setBackgroundColor(e.fg_bg.bkg)
end
-- clear to redraw background
e.window.clear()
-- write the button text
e.window.setCursorPos(h_pad, v_pad)
e.window.write(args.text)
@@ -59,16 +62,18 @@ local function switch_button(args)
-- initial draw
draw_state()
-- handle touch
---@param event monitor_touch monitor touch event
-- handle mouse interaction
---@param event mouse_interaction mouse event
---@diagnostic disable-next-line: unused-local
function e.handle_touch(event)
-- toggle state
e.value = not e.value
draw_state()
function e.handle_mouse(event)
if e.enabled then
-- toggle state
e.value = not e.value
draw_state()
-- call the touch callback with state
args.callback(e.value)
-- call the touch callback with state
args.callback(e.value)
end
end
-- set the value
@@ -77,9 +82,6 @@ local function switch_button(args)
-- set state
e.value = val
draw_state()
-- call the touch callback with state
args.callback(e.value)
end
return e.get()

View File

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

View File

@@ -13,6 +13,7 @@ local element = require("graphics.element")
---@field fg_bg? cpair foreground/background colors
-- new div element
---@nodiscard
---@param args div_args
---@return graphics_element element, element_id id
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
-- new core map box
---@nodiscard
---@param args core_map_args
---@return graphics_element element, element_id id
local function core_map(args)
@@ -72,7 +73,7 @@ local function core_map(args)
local function draw_core(t)
local i = 1
local back_c = "F"
local text_c = "8"
local text_c ---@type string
-- determine fuel assembly coloring
if t <= 300 then
@@ -115,6 +116,9 @@ local function core_map(args)
if inner_width % 2 == 0 then alternator = not alternator end
end
-- reset alternator
alternator = true
end
-- on state change

View File

@@ -4,34 +4,11 @@ local util = require("scada-common.util")
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
---@field label string indicator label
---@field unit? string indicator unit
---@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 value any default value
---@field parent graphics_element
@@ -42,6 +19,7 @@ end
---@field fg_bg? cpair foreground/background colors
-- new data indicator
---@nodiscard
---@param args data_indicator_args
---@return graphics_element element, element_id id
local function data(args)
@@ -65,20 +43,30 @@ local function data(args)
e.window.setCursorPos(1, 1)
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
---@param value any new value
function e.on_update(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
local data_str = util.sprintf(args.format, value)
e.window.setCursorPos(data_start, 1)
e.window.setTextColor(e.fg_bg.fgd)
if args.commas then
e.window.write(comma_format(data_str))
e.window.write(util.comma_format(data_str))
else
e.window.write(data_str)
end

View File

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

View File

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

View File

@@ -0,0 +1,100 @@
-- Indicator "LED" Graphics Element
local util = require("scada-common.util")
local element = require("graphics.element")
local flasher = require("graphics.flasher")
---@class indicator_led_args
---@field label string indicator label
---@field colors cpair on/off colors (a/b respectively)
---@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 id? string element id
---@field x? integer 1 if omitted
---@field y? integer 1 if omitted
---@field fg_bg? cpair foreground/background colors
-- new indicator LED
---@nodiscard
---@param args indicator_led_args
---@return graphics_element element, element_id id
local function indicator_led(args)
assert(type(args.label) == "string", "graphics.elements.indicators.led: label is a required field")
assert(type(args.colors) == "table", "graphics.elements.indicators.led: colors is a required field")
if args.flash then
assert(util.is_int(args.period), "graphics.elements.indicators.led: 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 0, string.len(args.label)) + 2
-- flasher state
local flash_on = true
-- 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
e.window.blit("\x8c", args.colors.blit_a, e.fg_bg.blit_bkg)
else
e.window.blit("\x8c", 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("\x8c", 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("\x8c", args.colors.blit_b, e.fg_bg.blit_bkg)
end
-- on state change
---@param new_state boolean indicator state
function e.on_update(new_state)
e.value = new_state
if new_state then enable() else disable() end
end
-- set indicator state
---@param val boolean indicator state
function e.set_value(val) e.on_update(val) end
-- write label and initial indicator light
e.on_update(false)
if string.len(args.label) > 0 then
e.window.setCursorPos(3, 1)
e.window.write(args.label)
end
return e.get()
end
return indicator_led

View File

@@ -0,0 +1,114 @@
-- Indicator LED Pair Graphics Element (two LEDs provide: off, color_a, color_b)
local util = require("scada-common.util")
local element = require("graphics.element")
local flasher = require("graphics.flasher")
---@class indicator_led_pair_args
---@field label string indicator label
---@field off color color for off
---@field c1 color color for #1 on
---@field c2 color color for #2 on
---@field min_label_width? integer label length if omitted
---@field flash? boolean whether to flash when on 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 dual LED indicator light
---@nodiscard
---@param args indicator_led_pair_args
---@return graphics_element element, element_id id
local function indicator_led_pair(args)
assert(type(args.label) == "string", "graphics.elements.indicators.ledpair: label is a required field")
assert(type(args.off) == "number", "graphics.elements.indicators.ledpair: off is a required field")
assert(type(args.c1) == "number", "graphics.elements.indicators.ledpair: c1 is a required field")
assert(type(args.c2) == "number", "graphics.elements.indicators.ledpair: c2 is a required field")
if args.flash then
assert(util.is_int(args.period), "graphics.elements.indicators.ledpair: 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 0, string.len(args.label)) + 2
-- flasher state
local flash_on = true
-- blit translations
local co = colors.toBlit(args.off)
local c1 = colors.toBlit(args.c1)
local c2 = colors.toBlit(args.c2)
-- create new graphics element base object
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("\x8c", c1, e.fg_bg.blit_bkg)
elseif e.value == 3 then
e.window.blit("\x8c", c2, e.fg_bg.blit_bkg)
end
else
e.window.blit("\x8c", co, e.fg_bg.blit_bkg)
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 <= 1
e.value = new_state
e.window.setCursorPos(1, 1)
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("\x8c", co, e.fg_bg.blit_bkg)
end
elseif new_state == 2 then
e.window.blit("\x8c", c1, e.fg_bg.blit_bkg)
elseif new_state == 3 then
e.window.blit("\x8c", c2, e.fg_bg.blit_bkg)
else
e.window.blit("\x8c", co, 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)
if string.len(args.label) > 0 then
e.window.setCursorPos(3, 1)
e.window.write(args.label)
end
return e.get()
end
return indicator_led_pair

View File

@@ -0,0 +1,59 @@
-- Indicator RGB LED Graphics Element
local element = require("graphics.element")
---@class indicator_led_rgb_args
---@field label string indicator label
---@field colors table colors to use
---@field min_label_width? integer label length 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 RGB LED indicator light
---@nodiscard
---@param args indicator_led_rgb_args
---@return graphics_element element, element_id id
local function indicator_led_rgb(args)
assert(type(args.label) == "string", "graphics.elements.indicators.ledrgb: label is a required field")
assert(type(args.colors) == "table", "graphics.elements.indicators.ledrgb: colors is a required field")
-- single line
args.height = 1
-- determine width
args.width = math.max(args.min_label_width or 0, string.len(args.label)) + 2
-- create new graphics element base object
local e = element.new(args)
-- init value for initial check in on_update
e.value = 1
-- on state change
---@param new_state integer indicator state
function e.on_update(new_state)
e.value = new_state
e.window.setCursorPos(1, 1)
if type(args.colors[new_state]) == "number" then
e.window.blit("\x8c", colors.toBlit(args.colors[new_state]), 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)
if string.len(args.label) > 0 then
e.window.setCursorPos(3, 1)
e.window.write(args.label)
end
return e.get()
end
return indicator_led_rgb

View File

@@ -1,11 +1,16 @@
-- Indicator Light Graphics Element
local util = require("scada-common.util")
local element = require("graphics.element")
local flasher = require("graphics.flasher")
---@class indicator_light_args
---@field label string indicator label
---@field colors cpair on/off colors (a/b respectively)
---@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 id? string element id
---@field x? integer 1 if omitted
@@ -13,31 +18,69 @@ local element = require("graphics.element")
---@field fg_bg? cpair foreground/background colors
-- new indicator light
---@nodiscard
---@param args indicator_light_args
---@return graphics_element element, element_id id
local function indicator_light(args)
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")
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
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
-- 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
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
---@param new_state boolean indicator state
function e.on_update(new_state)
e.value = new_state
e.window.setCursorPos(1, 1)
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
if new_state then enable() else disable() end
end
-- set indicator state
@@ -46,6 +89,7 @@ local function indicator_light(args)
-- write label and initial indicator light
e.on_update(false)
e.window.setCursorPos(3, 1)
e.window.write(args.label)
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
-- new state indicator
---@nodiscard
---@param args state_indicator_args
---@return graphics_element element, element_id id
local function state_indicator(args)

View File

@@ -1,6 +1,9 @@
-- Tri-State Indicator Light Graphics Element
local util = require("scada-common.util")
local element = require("graphics.element")
local flasher = require("graphics.flasher")
---@class tristate_indicator_light_args
---@field label string indicator label
@@ -8,13 +11,16 @@ local element = require("graphics.element")
---@field c2 color color for state 2
---@field c3 color color for state 3
---@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 id? string element id
---@field x? integer 1 if omitted
---@field y? integer 1 if omitted
---@field fg_bg? cpair foreground/background colors
-- new indicator light
-- new tri-state indicator light
---@nodiscard
---@param args tristate_indicator_light_args
---@return graphics_element element, element_id id
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.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
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)
@@ -37,12 +50,45 @@ local function tristate_indicator_light(args)
-- create new graphics element base object
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
---@param new_state integer indicator state
function e.on_update(new_state)
local was_off = e.value <= 1
e.value = new_state
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)
elseif new_state == 3 then
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
-- write label and initial indicator light
e.on_update(0)
e.on_update(1)
e.window.write(args.label)
return e.get()

View File

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

View File

@@ -0,0 +1,42 @@
-- Multi-Pane Display Graphics Element
local element = require("graphics.element")
---@class multipane_args
---@field panes table panes to swap between
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
---@field y? integer 1 if omitted
---@field width? integer parent width if omitted
---@field height? integer parent height if omitted
---@field gframe? graphics_frame frame instead of x/y/width/height
---@field fg_bg? cpair foreground/background colors
-- new multipane element
---@nodiscard
---@param args multipane_args
---@return graphics_element element, element_id id
local function multipane(args)
assert(type(args.panes) == "table", "graphics.elements.multipane: panes is a required field")
-- create new graphics element base object
local e = element.new(args)
-- select which pane is shown
---@param value integer pane to show
function e.set_value(value)
if (e.value ~= value) and (value > 0) and (value <= #args.panes) then
e.value = value
for i = 1, #args.panes do args.panes[i].hide() end
args.panes[value].show()
end
end
e.set_value(1)
return e.get()
end
return multipane

View File

@@ -6,6 +6,8 @@ local element = require("graphics.element")
---@class rectangle_args
---@field border? graphics_border
---@field thin? boolean true to use extra thin even borders
---@field even_inner? boolean true to make the inner area of a border even
---@field parent graphics_element
---@field id? string element id
---@field x? integer 1 if omitted
@@ -19,6 +21,14 @@ local element = require("graphics.element")
---@param args rectangle_args
---@return graphics_element element, element_id id
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
if args.border ~= nil then
args.offset_x = args.border.width
@@ -52,22 +62,55 @@ local function rectangle(args)
-- form the basic line strings and top/bottom blit strings
local spaces = util.spaces(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_top_bot = util.strrep(border_blit, e.frame.w)
-- partial bars
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_a, p_b, p_s
if args.thin == true then
if args.even_inner == true then
p_a = "\x9c" .. util.strrep("\x8c", inner_width) .. "\x93"
p_b = "\x8d" .. util.strrep("\x8c", inner_width) .. "\x8e"
else
p_a = "\x97" .. util.strrep("\x83", inner_width) .. "\x94"
p_b = "\x8a" .. util.strrep("\x8f", inner_width) .. "\x85"
end
p_s = "\x95" .. util.spaces(inner_width) .. "\x95"
else
if args.even_inner == true then
p_a = util.strrep("\x83", inner_width + width_x2)
p_b = util.strrep("\x8f", inner_width + width_x2)
else
p_a = util.spaces(border_width) .. util.strrep("\x8f", inner_width) .. util.spaces(border_width)
p_b = util.spaces(border_width) .. util.strrep("\x83", inner_width) .. util.spaces(border_width)
end
p_s = spaces
end
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)
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)
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)
for x = 1, e.frame.w do
-- edges get border color, center gets normal
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
blit_bg_sides = blit_bg_sides .. e.fg_bg.blit_bkg
end
@@ -76,36 +119,60 @@ local function rectangle(args)
-- draw rectangle with borders
for y = 1, e.frame.h do
e.window.setCursorPos(1, y)
-- top border
if y <= border_height then
-- partial pixel fill
if args.border.even and y == border_height then
if width_x2 % 3 == 1 then
e.window.blit(p_b, p_inv_bg, p_inv_fg)
elseif width_x2 % 3 == 2 then
if args.thin == true then
e.window.blit(p_a, p_inv_bg, p_inv_fg)
else
-- skip line
e.window.blit(spaces, blit_fg, blit_bg_sides)
local _fg = util.trinary(args.even_inner == true, util.strrep(e.fg_bg.blit_bkg, e.frame.w), p_inv_bg)
local _bg = util.trinary(args.even_inner == true, blit_bg_top_bot, p_inv_fg)
if width_x2 % 3 == 1 then
e.window.blit(p_b, _fg, _bg)
elseif width_x2 % 3 == 2 then
e.window.blit(p_a, _fg, _bg)
else
-- skip line
e.window.blit(spaces, blit_fg, blit_bg_sides)
end
end
else
e.window.blit(spaces, blit_fg, blit_bg_top_bot)
end
-- bottom border
elseif y > (e.frame.h - border_width) then
-- partial pixel fill
if args.border.even and y == ((e.frame.h - border_width) + 1) then
if width_x2 % 3 == 1 then
e.window.blit(p_a, p_inv_fg, blit_bg_top_bot)
elseif width_x2 % 3 == 2 then
e.window.blit(p_b, p_inv_fg, blit_bg_top_bot)
if args.thin == true then
if args.even_inner == true then
e.window.blit(p_b, blit_bg_top_bot, util.strrep(e.fg_bg.blit_bkg, e.frame.w))
else
e.window.blit(p_b, util.strrep(e.fg_bg.blit_bkg, e.frame.w), blit_bg_top_bot)
end
else
-- skip line
e.window.blit(spaces, blit_fg, blit_bg_sides)
local _fg = util.trinary(args.even_inner == true, blit_bg_top_bot, p_inv_fg)
local _bg = util.trinary(args.even_inner == true, util.strrep(e.fg_bg.blit_bkg, e.frame.w), blit_bg_top_bot)
if width_x2 % 3 == 1 then
e.window.blit(p_a, _fg, _bg)
elseif width_x2 % 3 == 2 then
e.window.blit(p_b, _fg, _bg)
else
-- skip line
e.window.blit(spaces, blit_fg, blit_bg_sides)
end
end
else
e.window.blit(spaces, blit_fg, blit_bg_top_bot)
end
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

View File

@@ -60,7 +60,7 @@ local function tiling(args)
-- create pattern
for y = start_y, inner_height + (start_y - 1) do
e.window.setCursorPos(start_x, y)
for x = 1, inner_width do
for _ = 1, inner_width do
if alternator then
if even then
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

130
imgen.py Normal file
View File

@@ -0,0 +1,130 @@
import json
import os
import sys
# 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).replace('\\','/'))
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", "graphics" ],
"rtu" : [ "system", "common", "graphics" ],
"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")
final_manifest = make_manifest(manifest_size)
# calculate file size then regenerate with embedded size
f = open("install_manifest.json", "w")
json.dump(final_manifest, f)
f.close()
if len(sys.argv) > 1 and sys.argv[1] == "shields":
# write all the JSON files for shields.io
for key, version in final_manifest["versions"].items():
f = open("./shields/" + key + ".json", "w")
if version.find("alpha") >= 0:
color = "yellow"
elif version.find("beta") >= 0:
color = "orange"
else:
color = "blue"
json.dump({
"schemaVersion": 1,
"label": key,
"message": "" + version,
"color": color
}, f)
f.close()

1
install_manifest.json Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,21 @@
local config = {}
-- port of the SCADA supervisor
config.SCADA_SV_PORT = 16100
-- port for SCADA coordinator API access
config.SCADA_API_PORT = 16200
-- port to listen to incoming packets FROM servers
config.LISTEN_PORT = 16201
-- 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
config.LOG_PATH = "/log.txt"
-- log mode
-- 0 = APPEND (adds to existing file on start)
-- 1 = NEW (replaces existing file on start)
config.LOG_MODE = 0
return config

35
pocket/coreio.lua Normal file
View File

@@ -0,0 +1,35 @@
--
-- Core I/O - Pocket Central I/O Management
--
local psil = require("scada-common.psil")
local coreio = {}
---@class pocket_core_io
local io = {
ps = psil.create()
}
---@enum POCKET_LINK_STATE
local LINK_STATE = {
UNLINKED = 0,
SV_LINK_ONLY = 1,
API_LINK_ONLY = 2,
LINKED = 3
}
coreio.LINK_STATE = LINK_STATE
-- get the core PSIL
function coreio.core_ps()
return io.ps
end
-- set network link state
---@param state POCKET_LINK_STATE
function coreio.report_link_state(state)
io.ps.publish("link_state", state)
end
return coreio

408
pocket/pocket.lua Normal file
View File

@@ -0,0 +1,408 @@
local comms = require("scada-common.comms")
local log = require("scada-common.log")
local util = require("scada-common.util")
local coreio = require("pocket.coreio")
local PROTOCOL = comms.PROTOCOL
local DEVICE_TYPE = comms.DEVICE_TYPE
local ESTABLISH_ACK = comms.ESTABLISH_ACK
local SCADA_MGMT_TYPE = comms.SCADA_MGMT_TYPE
-- local CAPI_TYPE = comms.CAPI_TYPE
local LINK_STATE = coreio.LINK_STATE
local pocket = {}
-- pocket coordinator + supervisor communications
---@nodiscard
---@param version string pocket version
---@param modem table modem device
---@param local_port integer local pocket port
---@param sv_port integer port of supervisor
---@param api_port integer port of coordinator API
---@param range integer trusted device connection range
---@param sv_watchdog watchdog
---@param api_watchdog watchdog
function pocket.comms(version, modem, local_port, sv_port, api_port, range, sv_watchdog, api_watchdog)
local self = {
sv = {
linked = false,
seq_num = 0,
r_seq_num = nil, ---@type nil|integer
last_est_ack = ESTABLISH_ACK.ALLOW
},
api = {
linked = false,
seq_num = 0,
r_seq_num = nil, ---@type nil|integer
last_est_ack = ESTABLISH_ACK.ALLOW
},
establish_delay_counter = 0
}
comms.set_trusted_range(range)
-- PRIVATE FUNCTIONS --
-- configure modem channels
local function _conf_channels()
modem.closeAll()
modem.open(local_port)
end
_conf_channels()
-- send a management packet to the supervisor
---@param msg_type SCADA_MGMT_TYPE
---@param msg table
local function _send_sv(msg_type, msg)
local s_pkt = comms.scada_packet()
local pkt = comms.mgmt_packet()
pkt.make(msg_type, msg)
s_pkt.make(self.sv.seq_num, PROTOCOL.SCADA_MGMT, pkt.raw_sendable())
modem.transmit(sv_port, local_port, s_pkt.raw_sendable())
self.sv.seq_num = self.sv.seq_num + 1
end
-- send a management packet to the coordinator
---@param msg_type SCADA_MGMT_TYPE
---@param msg table
local function _send_crd(msg_type, msg)
local s_pkt = comms.scada_packet()
local pkt = comms.mgmt_packet()
pkt.make(msg_type, msg)
s_pkt.make(self.api.seq_num, PROTOCOL.SCADA_MGMT, pkt.raw_sendable())
modem.transmit(api_port, local_port, s_pkt.raw_sendable())
self.api.seq_num = self.api.seq_num + 1
end
-- send a packet to the coordinator API
-----@param msg_type CAPI_TYPE
-----@param msg table
-- local function _send_api(msg_type, msg)
-- local s_pkt = comms.scada_packet()
-- local pkt = comms.capi_packet()
-- pkt.make(msg_type, msg)
-- s_pkt.make(self.api.seq_num, PROTOCOL.COORD_API, pkt.raw_sendable())
-- modem.transmit(api_port, local_port, s_pkt.raw_sendable())
-- self.api.seq_num = self.api.seq_num + 1
-- end
-- attempt supervisor connection establishment
local function _send_sv_establish()
_send_sv(SCADA_MGMT_TYPE.ESTABLISH, { comms.version, version, DEVICE_TYPE.PKT })
end
-- attempt coordinator API connection establishment
local function _send_api_establish()
_send_crd(SCADA_MGMT_TYPE.ESTABLISH, { comms.version, version, DEVICE_TYPE.PKT })
end
-- keep alive ack to supervisor
---@param srv_time integer
local function _send_sv_keep_alive_ack(srv_time)
_send_sv(SCADA_MGMT_TYPE.KEEP_ALIVE, { srv_time, util.time() })
end
-- keep alive ack to coordinator
---@param srv_time integer
local function _send_api_keep_alive_ack(srv_time)
_send_crd(SCADA_MGMT_TYPE.KEEP_ALIVE, { srv_time, util.time() })
end
-- PUBLIC FUNCTIONS --
---@class pocket_comms
local public = {}
-- reconnect a newly connected modem
---@param new_modem table
function public.reconnect_modem(new_modem)
modem = new_modem
_conf_channels()
end
-- close connection to the supervisor
function public.close_sv()
sv_watchdog.cancel()
self.sv.linked = false
_send_sv(SCADA_MGMT_TYPE.CLOSE, {})
end
-- close connection to coordinator API server
function public.close_api()
api_watchdog.cancel()
self.api.linked = false
_send_crd(SCADA_MGMT_TYPE.CLOSE, {})
end
-- close the connections to the servers
function public.close()
public.close_sv()
public.close_api()
end
-- attempt to re-link if any of the dependent links aren't active
function public.link_update()
if not self.sv.linked then
coreio.report_link_state(util.trinary(self.api.linked, LINK_STATE.API_LINK_ONLY, LINK_STATE.UNLINKED))
if self.establish_delay_counter <= 0 then
_send_sv_establish()
self.establish_delay_counter = 4
else
self.establish_delay_counter = self.establish_delay_counter - 1
end
elseif not self.api.linked then
coreio.report_link_state(LINK_STATE.SV_LINK_ONLY)
if self.establish_delay_counter <= 0 then
_send_api_establish()
self.establish_delay_counter = 4
else
self.establish_delay_counter = self.establish_delay_counter - 1
end
else
-- linked, all good!
coreio.report_link_state(LINK_STATE.LINKED)
end
end
-- parse a packet
---@param side string
---@param sender integer
---@param reply_to integer
---@param message any
---@param distance integer
---@return mgmt_frame|capi_frame|nil packet
function public.parse_packet(side, sender, reply_to, message, distance)
local pkt = nil
local s_pkt = comms.scada_packet()
-- parse packet as generic SCADA packet
s_pkt.receive(side, sender, reply_to, message, distance)
if s_pkt.is_valid() then
-- get as SCADA management packet
if s_pkt.protocol() == PROTOCOL.SCADA_MGMT then
local mgmt_pkt = comms.mgmt_packet()
if mgmt_pkt.decode(s_pkt) then
pkt = mgmt_pkt.get()
end
-- get as coordinator API packet
elseif s_pkt.protocol() == PROTOCOL.COORD_API then
local capi_pkt = comms.capi_packet()
if capi_pkt.decode(s_pkt) then
pkt = capi_pkt.get()
end
else
log.debug("attempted parse of illegal packet type " .. s_pkt.protocol(), true)
end
end
return pkt
end
-- handle a packet
---@param packet mgmt_frame|capi_frame|nil
function public.handle_packet(packet)
if packet ~= nil then
local l_port = packet.scada_frame.local_port()
local r_port = packet.scada_frame.remote_port()
local protocol = packet.scada_frame.protocol()
if l_port ~= local_port then
log.debug("received packet on unconfigured channel " .. l_port, true)
elseif r_port == api_port then
-- check sequence number
if self.api.r_seq_num == nil then
self.api.r_seq_num = packet.scada_frame.seq_num()
elseif self.connected and ((self.api.r_seq_num + 1) ~= packet.scada_frame.seq_num()) then
log.warning("sequence out-of-order: last = " .. self.api.r_seq_num .. ", new = " .. packet.scada_frame.seq_num())
return
else
self.api.r_seq_num = packet.scada_frame.seq_num()
end
-- feed watchdog on valid sequence number
api_watchdog.feed()
if protocol == PROTOCOL.COORD_API then
---@cast packet capi_frame
elseif protocol == PROTOCOL.SCADA_MGMT then
---@cast packet mgmt_frame
if packet.type == SCADA_MGMT_TYPE.ESTABLISH then
-- connection with coordinator established
if packet.length == 1 then
local est_ack = packet.data[1]
if est_ack == ESTABLISH_ACK.ALLOW then
log.info("coordinator connection established")
self.establish_delay_counter = 0
self.api.linked = true
if self.sv.linked then
coreio.report_link_state(LINK_STATE.LINKED)
else
coreio.report_link_state(LINK_STATE.API_LINK_ONLY)
end
elseif est_ack == ESTABLISH_ACK.DENY then
if self.api.last_est_ack ~= est_ack then
log.info("coordinator connection denied")
end
elseif est_ack == ESTABLISH_ACK.COLLISION then
if self.api.last_est_ack ~= est_ack then
log.info("coordinator connection denied due to collision")
end
elseif est_ack == ESTABLISH_ACK.BAD_VERSION then
if self.api.last_est_ack ~= est_ack then
log.info("coordinator comms version mismatch")
end
else
log.debug("coordinator SCADA_MGMT establish packet reply unsupported")
end
self.api.last_est_ack = est_ack
else
log.debug("coordinator SCADA_MGMT establish packet length mismatch")
end
elseif self.api.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("pocket coordinator KEEP_ALIVE trip time > 750ms (" .. trip_time .. "ms)")
end
-- log.debug("pocket coordinator RTT = " .. trip_time .. "ms")
_send_api_keep_alive_ack(timestamp)
else
log.debug("coordinator SCADA keep alive packet length mismatch")
end
elseif packet.type == SCADA_MGMT_TYPE.CLOSE then
-- handle session close
api_watchdog.cancel()
self.api.linked = false
log.info("coordinator server connection closed by remote host")
else
log.debug("received unknown SCADA_MGMT packet type " .. packet.type .. " from coordinator")
end
else
log.debug("discarding coordinator non-link SCADA_MGMT packet before linked")
end
else
log.debug("illegal packet type " .. protocol .. " from coordinator", true)
end
elseif r_port == sv_port then
-- check sequence number
if self.sv.r_seq_num == nil then
self.sv.r_seq_num = packet.scada_frame.seq_num()
elseif self.connected and ((self.sv.r_seq_num + 1) ~= packet.scada_frame.seq_num()) then
log.warning("sequence out-of-order: last = " .. self.sv.r_seq_num .. ", new = " .. packet.scada_frame.seq_num())
return
else
self.sv.r_seq_num = packet.scada_frame.seq_num()
end
-- feed watchdog on valid sequence number
sv_watchdog.feed()
-- handle packet
if protocol == PROTOCOL.SCADA_MGMT then
---@cast packet mgmt_frame
if packet.type == SCADA_MGMT_TYPE.ESTABLISH then
-- connection with supervisor established
if packet.length == 1 then
local est_ack = packet.data[1]
if est_ack == ESTABLISH_ACK.ALLOW then
log.info("supervisor connection established")
self.establish_delay_counter = 0
self.sv.linked = true
if self.api.linked then
coreio.report_link_state(LINK_STATE.LINKED)
else
coreio.report_link_state(LINK_STATE.SV_LINK_ONLY)
end
elseif est_ack == ESTABLISH_ACK.DENY then
if self.sv.last_est_ack ~= est_ack then
log.info("supervisor connection denied")
end
elseif est_ack == ESTABLISH_ACK.COLLISION then
if self.sv.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.sv.last_est_ack ~= est_ack then
log.info("supervisor comms version mismatch")
end
else
log.debug("supervisor SCADA_MGMT establish packet reply unsupported")
end
self.sv.last_est_ack = est_ack
else
log.debug("supervisor 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("pocket supervisor KEEP_ALIVE trip time > 750ms (" .. trip_time .. "ms)")
end
-- log.debug("pocket supervisor RTT = " .. trip_time .. "ms")
_send_sv_keep_alive_ack(timestamp)
else
log.debug("supervisor SCADA keep alive packet length mismatch")
end
elseif packet.type == SCADA_MGMT_TYPE.CLOSE then
-- handle session close
sv_watchdog.cancel()
self.sv.linked = false
log.info("supervisor server connection closed by remote host")
else
log.debug("received unknown SCADA_MGMT packet type " .. packet.type .. " from supervisor")
end
else
log.debug("discarding supervisor non-link SCADA_MGMT packet before linked")
end
else
log.debug("illegal packet type " .. protocol .. " from supervisor", true)
end
else
log.debug("received packet from unconfigured channel " .. r_port, true)
end
end
end
-- check if we are still linked with the supervisor
---@nodiscard
function public.is_sv_linked() return self.sv.linked end
-- check if we are still linked with the coordinator
---@nodiscard
function public.is_api_linked() return self.api.linked end
return public
end
return pocket

80
pocket/renderer.lua Normal file
View File

@@ -0,0 +1,80 @@
--
-- Graphics Rendering Control
--
local main_view = require("pocket.ui.main")
local style = require("pocket.ui.style")
local flasher = require("graphics.flasher")
local DisplayBox = require("graphics.elements.displaybox")
local renderer = {}
local ui = {
display = nil
}
-- start the pocket GUI
function renderer.start_ui()
if ui.display == nil then
-- reset screen
term.setTextColor(colors.white)
term.setBackgroundColor(colors.black)
term.clear()
term.setCursorPos(1, 1)
-- set overridden colors
for i = 1, #style.colors do
term.setPaletteColor(style.colors[i].c, style.colors[i].hex)
end
-- init front panel view
ui.display = DisplayBox{window=term.current(),fg_bg=style.root}
main_view(ui.display)
-- start flasher callback task
flasher.run()
end
end
-- close out the UI
function renderer.close_ui()
if ui.display ~= nil then
-- stop blinking indicators
flasher.clear()
-- hide to stop animation callbacks
ui.display.hide()
-- clear root UI elements
ui.display = nil
-- restore colors
for i = 1, #style.colors do
local r, g, b = term.nativePaletteColor(style.colors[i].c)
term.setPaletteColor(style.colors[i].c, r, g, b)
end
-- reset terminal
term.setTextColor(colors.white)
term.setBackgroundColor(colors.black)
term.clear()
term.setCursorPos(1, 1)
end
end
-- is the UI ready?
---@nodiscard
---@return boolean ready
function renderer.ui_ready() return ui.display ~= nil end
-- handle a mouse event
---@param event mouse_interaction
function renderer.handle_mouse(event)
if ui.display ~= nil then
ui.display.handle_mouse(event)
end
end
return renderer

View File

@@ -1,5 +1,180 @@
--
-- SCADA Coordinator Access on a Pocket Computer
-- SCADA System Access on a Pocket Computer
--
require("/initenv").init_env()
local crash = require("scada-common.crash")
local log = require("scada-common.log")
local ppm = require("scada-common.ppm")
local tcallbackdsp = require("scada-common.tcallbackdsp")
local util = require("scada-common.util")
local core = require("graphics.core")
local config = require("pocket.config")
local coreio = require("pocket.coreio")
local pocket = require("pocket.pocket")
local renderer = require("pocket.renderer")
local POCKET_VERSION = "alpha-v0.2.6"
local println = util.println
local println_ts = util.println_ts
----------------------------------------
-- config validation
----------------------------------------
local cfv = util.new_validator()
cfv.assert_port(config.SCADA_SV_PORT)
cfv.assert_port(config.SCADA_API_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_int(config.LOG_MODE)
assert(cfv.valid(), "bad config file: missing/invalid fields")
----------------------------------------
-- log init
----------------------------------------
log.init(config.LOG_PATH, config.LOG_MODE)
log.info("========================================")
log.info("BOOTING pocket.startup " .. POCKET_VERSION)
log.info("========================================")
crash.set_env("pocket", POCKET_VERSION)
----------------------------------------
-- main application
----------------------------------------
local function main()
----------------------------------------
-- system startup
----------------------------------------
-- mount connected devices
ppm.mount_all()
----------------------------------------
-- setup communications & clocks
----------------------------------------
coreio.report_link_state(coreio.LINK_STATE.UNLINKED)
-- get the communications modem
local modem = ppm.get_wireless_modem()
if modem == nil then
println("startup> wireless modem not found: please craft the pocket computer with a wireless modem")
log.fatal("startup> no wireless modem on startup")
return
end
-- create connection watchdogs
local conn_wd = {
sv = util.new_watchdog(config.COMMS_TIMEOUT),
api = util.new_watchdog(config.COMMS_TIMEOUT)
}
conn_wd.sv.cancel()
conn_wd.api.cancel()
log.debug("startup> conn watchdogs created")
-- start comms, open all channels
local pocket_comms = pocket.comms(POCKET_VERSION, modem, config.LISTEN_PORT, config.SCADA_SV_PORT,
config.SCADA_API_PORT, config.TRUSTED_RANGE, conn_wd.sv, conn_wd.api)
log.debug("startup> comms init")
-- base loop clock (2Hz, 10 ticks)
local MAIN_CLOCK = 0.5
local loop_clock = util.new_clock(MAIN_CLOCK)
----------------------------------------
-- start the UI
----------------------------------------
local ui_ok, message = pcall(renderer.start_ui)
if not ui_ok then
renderer.close_ui()
println(util.c("UI error: ", message))
log.error(util.c("startup> GUI crashed with error ", message))
else
-- start clock
loop_clock.start()
end
----------------------------------------
-- main event loop
----------------------------------------
if ui_ok then
-- start connection watchdogs
conn_wd.sv.feed()
conn_wd.api.feed()
log.debug("startup> conn watchdog started")
end
-- main event loop
while ui_ok do
local event, param1, param2, param3, param4, param5 = util.pull_event()
-- handle event
if event == "timer" then
if loop_clock.is_clock(param1) then
-- main loop tick
-- relink if necessary
pocket_comms.link_update()
loop_clock.start()
elseif conn_wd.sv.is_timer(param1) then
-- supervisor watchdog timeout
log.info("supervisor server timeout")
pocket_comms.close_sv()
elseif conn_wd.api.is_timer(param1) then
-- coordinator watchdog timeout
log.info("coordinator api server timeout")
pocket_comms.close_api()
else
-- a non-clock/main watchdog timer event
-- notify timer callback dispatcher
tcallbackdsp.handle(param1)
end
elseif event == "modem_message" then
-- got a packet
local packet = pocket_comms.parse_packet(param1, param2, param3, param4, param5)
pocket_comms.handle_packet(packet)
elseif event == "mouse_click" then
-- handle a monitor touch event
renderer.handle_mouse(core.events.touch(param1, param2, param3))
end
-- check for termination request
if event == "terminate" or ppm.should_terminate() then
log.info("terminate requested, closing server connections...")
pocket_comms.close()
log.info("connections closed")
break
end
end
renderer.close_ui()
println_ts("exited")
log.info("exited")
end
if not xpcall(main, crash.handler) then
pcall(renderer.close_ui)
crash.exit()
else
log.close()
end

View File

@@ -0,0 +1,22 @@
-- local style = require("pocket.ui.style")
local core = require("graphics.core")
local Div = require("graphics.elements.div")
local TextBox = require("graphics.elements.textbox")
-- local cpair = core.graphics.cpair
local TEXT_ALIGN = core.graphics.TEXT_ALIGN
-- new boiler page view
---@param root graphics_element parent
local function new_view(root)
local main = Div{parent=root,x=1,y=1}
TextBox{parent=main,text="BOILERS",x=1,y=1,height=1,alignment=TEXT_ALIGN.CENTER}
return main
end
return new_view

View File

@@ -0,0 +1,41 @@
--
-- Connection Waiting Spinner
--
local style = require("pocket.ui.style")
local core = require("graphics.core")
local Div = require("graphics.elements.div")
local TextBox = require("graphics.elements.textbox")
local WaitingAnim = require("graphics.elements.animations.waiting")
local TEXT_ALIGN = core.graphics.TEXT_ALIGN
local cpair = core.graphics.cpair
-- create a waiting view
---@param parent graphics_element parent
---@param y integer y offset
local function init(parent, y, is_api)
-- root div
local root = Div{parent=parent,x=1,y=1}
-- bounding box div
local box = Div{parent=root,x=1,y=y,height=5}
local waiting_x = math.floor(parent.width() / 2) - 1
if is_api then
WaitingAnim{parent=box,x=waiting_x,y=1,fg_bg=cpair(colors.blue,style.root.bkg)}
TextBox{parent=box,text="Connecting to API",alignment=TEXT_ALIGN.CENTER,y=5,height=1,fg_bg=cpair(colors.white,style.root.bkg)}
else
WaitingAnim{parent=box,x=waiting_x,y=1,fg_bg=cpair(colors.green,style.root.bkg)}
TextBox{parent=box,text="Connecting to Supervisor",alignment=TEXT_ALIGN.CENTER,y=5,height=1,fg_bg=cpair(colors.white,style.root.bkg)}
end
return root
end
return init

View File

@@ -0,0 +1,22 @@
-- local style = require("pocket.ui.style")
local core = require("graphics.core")
local Div = require("graphics.elements.div")
local TextBox = require("graphics.elements.textbox")
-- local cpair = core.graphics.cpair
local TEXT_ALIGN = core.graphics.TEXT_ALIGN
-- new home page view
---@param root graphics_element parent
local function new_view(root)
local main = Div{parent=root,x=1,y=1}
TextBox{parent=main,text="HOME",x=1,y=1,height=1,alignment=TEXT_ALIGN.CENTER}
return main
end
return new_view

View File

@@ -0,0 +1,22 @@
-- local style = require("pocket.ui.style")
local core = require("graphics.core")
local Div = require("graphics.elements.div")
local TextBox = require("graphics.elements.textbox")
-- local cpair = core.graphics.cpair
local TEXT_ALIGN = core.graphics.TEXT_ALIGN
-- new reactor page view
---@param root graphics_element parent
local function new_view(root)
local main = Div{parent=root,x=1,y=1}
TextBox{parent=main,text="REACTOR",x=1,y=1,height=1,alignment=TEXT_ALIGN.CENTER}
return main
end
return new_view

View File

@@ -0,0 +1,22 @@
-- local style = require("pocket.ui.style")
local core = require("graphics.core")
local Div = require("graphics.elements.div")
local TextBox = require("graphics.elements.textbox")
-- local cpair = core.graphics.cpair
local TEXT_ALIGN = core.graphics.TEXT_ALIGN
-- new turbine page view
---@param root graphics_element parent
local function new_view(root)
local main = Div{parent=root,x=1,y=1}
TextBox{parent=main,text="TURBINES",x=1,y=1,height=1,alignment=TEXT_ALIGN.CENTER}
return main
end
return new_view

View File

@@ -0,0 +1,22 @@
-- local style = require("pocket.ui.style")
local core = require("graphics.core")
local Div = require("graphics.elements.div")
local TextBox = require("graphics.elements.textbox")
-- local cpair = core.graphics.cpair
local TEXT_ALIGN = core.graphics.TEXT_ALIGN
-- new unit page view
---@param root graphics_element parent
local function new_view(root)
local main = Div{parent=root,x=1,y=1}
TextBox{parent=main,text="UNITS",x=1,y=1,height=1,alignment=TEXT_ALIGN.CENTER}
return main
end
return new_view

99
pocket/ui/main.lua Normal file
View File

@@ -0,0 +1,99 @@
--
-- Pocket GUI Root
--
local coreio = require("pocket.coreio")
local style = require("pocket.ui.style")
local conn_waiting = require("pocket.ui.components.conn_waiting")
local home_page = require("pocket.ui.components.home_page")
local unit_page = require("pocket.ui.components.unit_page")
local reactor_page = require("pocket.ui.components.reactor_page")
local boiler_page = require("pocket.ui.components.boiler_page")
local turbine_page = require("pocket.ui.components.turbine_page")
local core = require("graphics.core")
local Div = require("graphics.elements.div")
local MultiPane = require("graphics.elements.multipane")
local TextBox = require("graphics.elements.textbox")
local Sidebar = require("graphics.elements.controls.sidebar")
local TEXT_ALIGN = core.graphics.TEXT_ALIGN
local cpair = core.graphics.cpair
-- create new main view
---@param main graphics_element main displaybox
local function init(main)
-- window header message
TextBox{parent=main,y=1,text="",alignment=TEXT_ALIGN.LEFT,height=1,fg_bg=style.header}
--
-- root panel panes (connection screens + main screen)
--
local root_pane_div = Div{parent=main,x=1,y=2}
local conn_sv_wait = conn_waiting(root_pane_div, 6, false)
local conn_api_wait = conn_waiting(root_pane_div, 6, true)
local main_pane = Div{parent=main,x=1,y=2}
local root_panes = { conn_sv_wait, conn_api_wait, main_pane }
local root_pane = MultiPane{parent=root_pane_div,x=1,y=1,panes=root_panes}
coreio.core_ps().subscribe("link_state", function (state)
if state == coreio.LINK_STATE.UNLINKED or state == coreio.LINK_STATE.API_LINK_ONLY then
root_pane.set_value(1)
elseif state == coreio.LINK_STATE.SV_LINK_ONLY then
root_pane.set_value(2)
else
root_pane.set_value(3)
end
end)
--
-- main page panel panes & sidebar
--
local page_div = Div{parent=main_pane,x=4,y=1}
local sidebar_tabs = {
{
char = "#",
color = cpair(colors.black,colors.green)
},
{
char = "U",
color = cpair(colors.black,colors.yellow)
},
{
char = "R",
color = cpair(colors.black,colors.cyan)
},
{
char = "B",
color = cpair(colors.black,colors.lightGray)
},
{
char = "T",
color = cpair(colors.black,colors.white)
}
}
local pane_1 = home_page(page_div)
local pane_2 = unit_page(page_div)
local pane_3 = reactor_page(page_div)
local pane_4 = boiler_page(page_div)
local pane_5 = turbine_page(page_div)
local panes = { pane_1, pane_2, pane_3, pane_4, pane_5 }
local page_pane = MultiPane{parent=page_div,x=1,y=1,panes=panes}
Sidebar{parent=main_pane,x=1,y=1,tabs=sidebar_tabs,fg_bg=cpair(colors.white,colors.gray),callback=page_pane.set_value}
end
return init

158
pocket/ui/style.lua Normal file
View File

@@ -0,0 +1,158 @@
--
-- Graphics Style Options
--
local core = require("graphics.core")
local style = {}
local cpair = core.graphics.cpair
-- GLOBAL --
style.root = cpair(colors.white, colors.black)
style.header = cpair(colors.white, colors.gray)
style.label = cpair(colors.gray, colors.lightGray)
style.colors = {
{ c = colors.red, hex = 0xdf4949 },
{ c = colors.orange, hex = 0xffb659 },
{ c = colors.yellow, hex = 0xfffc79 },
{ c = colors.lime, hex = 0x80ff80 },
{ c = colors.green, hex = 0x4aee8a },
{ c = colors.cyan, hex = 0x34bac8 },
{ c = colors.lightBlue, hex = 0x6cc0f2 },
{ c = colors.blue, hex = 0x0096ff },
{ c = colors.purple, hex = 0xb156ee },
{ c = colors.pink, hex = 0xf26ba2 },
{ c = colors.magenta, hex = 0xf9488a },
-- { c = colors.white, hex = 0xf0f0f0 },
{ c = colors.lightGray, hex = 0xcacaca },
{ c = colors.gray, hex = 0x575757 },
-- { c = colors.black, hex = 0x191919 },
-- { c = colors.brown, hex = 0x7f664c }
}
-- MAIN LAYOUT --
style.reactor = {
-- reactor states
states = {
{
color = cpair(colors.black, colors.yellow),
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),
text = "DISABLED"
},
{
color = cpair(colors.black, colors.green),
text = "ACTIVE"
},
{
color = cpair(colors.black, colors.red),
text = "SCRAMMED"
},
{
color = cpair(colors.black, colors.red),
text = "FORCE DISABLED"
}
}
}
style.boiler = {
-- boiler 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.white, colors.gray),
text = "IDLE"
},
{
color = cpair(colors.black, colors.green),
text = "ACTIVE"
}
}
}
style.turbine = {
-- turbine 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.white, colors.gray),
text = "IDLE"
},
{
color = cpair(colors.black, colors.green),
text = "ACTIVE"
},
{
color = cpair(colors.black, colors.red),
text = "TRIP"
}
}
}
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

View File

@@ -4,10 +4,20 @@ local config = {}
config.NETWORKED = true
-- unique reactor ID
config.REACTOR_ID = 1
-- for offline mode, this redstone interface will turn off (open a valve)
-- when emergency coolant is needed due to low coolant
-- config.EMERGENCY_COOL = { side = "right", color = nil }
-- port to send packets TO server
config.SERVER_PORT = 16000
-- port to listen to incoming packets FROM server
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
config.LOG_PATH = "/log.txt"
-- log mode

103
reactor-plc/databus.lua Normal file
View File

@@ -0,0 +1,103 @@
--
-- Data Bus - Central Communication Linking for PLC Front Panel
--
local log = require("scada-common.log")
local psil = require("scada-common.psil")
local util = require("scada-common.util")
local databus = {}
local dbus_iface = {
ps = psil.create(),
rps_scram = function () log.debug("DBUS: unset rps_scram() called") end,
rps_reset = function () log.debug("DBUS: unset rps_reset() called") end
}
-- call to toggle heartbeat signal
function databus.heartbeat() dbus_iface.ps.toggle("heartbeat") end
-- link RPS command functions
---@param scram function reactor SCRAM function
---@param reset function RPS reset function
function databus.link_rps(scram, reset)
dbus_iface.rps_scram = scram
dbus_iface.rps_reset = reset
end
-- transmit a command to the RPS to SCRAM
function databus.rps_scram() dbus_iface.rps_scram() end
-- transmit a command to the RPS to reset
function databus.rps_reset() dbus_iface.rps_reset() end
-- transmit firmware versions across the bus
---@param plc_v string PLC version
---@param comms_v string comms version
function databus.tx_versions(plc_v, comms_v)
dbus_iface.ps.publish("version", plc_v)
dbus_iface.ps.publish("comms_version", comms_v)
end
-- transmit unit ID across the bus
---@param id integer unit ID
function databus.tx_id(id)
dbus_iface.ps.publish("unit_id", id)
end
-- transmit hardware status across the bus
---@param plc_state plc_state
function databus.tx_hw_status(plc_state)
dbus_iface.ps.publish("reactor_dev_state", util.trinary(plc_state.no_reactor, 1, util.trinary(plc_state.reactor_formed, 3, 2)))
dbus_iface.ps.publish("has_modem", not plc_state.no_modem)
dbus_iface.ps.publish("degraded", plc_state.degraded)
dbus_iface.ps.publish("init_ok", plc_state.init_ok)
end
-- transmit thread (routine) statuses
---@param thread string thread name
---@param ok boolean thread state
function databus.tx_rt_status(thread, ok)
dbus_iface.ps.publish(util.c("routine__", thread), ok)
end
-- transmit supervisor link state across the bus
---@param state integer
function databus.tx_link_state(state)
dbus_iface.ps.publish("link_state", state)
end
-- transmit reactor enable state across the bus
---@param active boolean reactor active
function databus.tx_reactor_state(active)
dbus_iface.ps.publish("reactor_active", active)
end
-- transmit RPS data across the bus
---@param tripped boolean RPS tripped
---@param status table RPS status
---@param emer_cool_active boolean RPS activated the emergency coolant
function databus.tx_rps(tripped, status, emer_cool_active)
dbus_iface.ps.publish("rps_scram", tripped)
dbus_iface.ps.publish("rps_damage", status[1])
dbus_iface.ps.publish("rps_high_temp", status[2])
dbus_iface.ps.publish("rps_low_ccool", status[3])
dbus_iface.ps.publish("rps_high_waste", status[4])
dbus_iface.ps.publish("rps_high_hcool", status[5])
dbus_iface.ps.publish("rps_no_fuel", status[6])
dbus_iface.ps.publish("rps_fault", status[7])
dbus_iface.ps.publish("rps_timeout", status[8])
dbus_iface.ps.publish("rps_manual", status[9])
dbus_iface.ps.publish("rps_automatic", status[10])
dbus_iface.ps.publish("rps_sysfail", status[11])
dbus_iface.ps.publish("emer_cool", emer_cool_active)
end
-- link a function to receive data from the bus
---@param field string field name
---@param func function function to link
function databus.rx_field(field, func)
dbus_iface.ps.subscribe(field, func)
end
return databus

View File

@@ -0,0 +1,142 @@
--
-- Main SCADA Coordinator GUI
--
local util = require("scada-common.util")
local config = require("reactor-plc.config")
local databus = require("reactor-plc.databus")
local style = require("reactor-plc.panel.style")
local core = require("graphics.core")
local flasher = require("graphics.flasher")
local Div = require("graphics.elements.div")
local Rectangle = require("graphics.elements.rectangle")
local TextBox = require("graphics.elements.textbox")
local PushButton = require("graphics.elements.controls.push_button")
local LED = require("graphics.elements.indicators.led")
local LEDPair = require("graphics.elements.indicators.ledpair")
local RGBLED = require("graphics.elements.indicators.ledrgb")
local TEXT_ALIGN = core.graphics.TEXT_ALIGN
local cpair = core.graphics.cpair
local border = core.graphics.border
-- create new main view
---@param panel graphics_element main displaybox
local function init(panel)
local header = TextBox{parent=panel,y=1,text="REACTOR PLC - UNIT ?",alignment=TEXT_ALIGN.CENTER,height=1,fg_bg=style.header}
databus.rx_field("unit_id", function (id) header.set_value(util.c("REACTOR PLC - UNIT ", id)) end)
--
-- system indicators
--
local system = Div{parent=panel,width=14,height=18,x=2,y=3}
local init_ok = LED{parent=system,label="STATUS",colors=cpair(colors.green,colors.red)}
local heartbeat = LED{parent=system,label="HEARTBEAT",colors=cpair(colors.green,colors.green_off)}
system.line_break()
databus.rx_field("init_ok", init_ok.update)
databus.rx_field("heartbeat", heartbeat.update)
local reactor = LEDPair{parent=system,label="REACTOR",off=colors.red,c1=colors.yellow,c2=colors.green}
local modem = LED{parent=system,label="MODEM",colors=cpair(colors.green,colors.green_off)}
local network = RGBLED{parent=system,label="NETWORK",colors={colors.green,colors.red,colors.orange,colors.yellow,colors.gray}}
network.update(5)
system.line_break()
databus.rx_field("reactor_dev_state", reactor.update)
databus.rx_field("has_modem", modem.update)
databus.rx_field("link_state", network.update)
local rt_main = LED{parent=system,label="RT MAIN",colors=cpair(colors.green,colors.green_off)}
local rt_rps = LED{parent=system,label="RT RPS",colors=cpair(colors.green,colors.green_off)}
local rt_cmtx = LED{parent=system,label="RT COMMS TX",colors=cpair(colors.green,colors.green_off)}
local rt_cmrx = LED{parent=system,label="RT COMMS RX",colors=cpair(colors.green,colors.green_off)}
local rt_sctl = LED{parent=system,label="RT SPCTL",colors=cpair(colors.green,colors.green_off)}
system.line_break()
databus.rx_field("routine__main", rt_main.update)
databus.rx_field("routine__rps", rt_rps.update)
databus.rx_field("routine__comms_tx", rt_cmtx.update)
databus.rx_field("routine__comms_rx", rt_cmrx.update)
databus.rx_field("routine__spctl", rt_sctl.update)
--
-- status & controls
--
local status = Div{parent=panel,width=19,height=18,x=17,y=3}
local active = LED{parent=status,x=2,width=12,label="RCT ACTIVE",colors=cpair(colors.green,colors.green_off)}
-- only show emergency coolant LED if emergency coolant is configured for this device
if type(config.EMERGENCY_COOL) == "table" then
local emer_cool = LED{parent=status,x=2,width=14,label="EMER COOLANT",colors=cpair(colors.yellow,colors.yellow_off)}
databus.rx_field("emer_cool", emer_cool.update)
end
local status_trip_rct = Rectangle{parent=status,width=20,height=3,x=1,border=border(1,colors.lightGray,true),even_inner=true,fg_bg=cpair(colors.black,colors.ivory)}
local status_trip = Div{parent=status_trip_rct,width=18,height=1,fg_bg=cpair(colors.black,colors.lightGray)}
local scram = LED{parent=status_trip,width=10,label="RPS TRIP",colors=cpair(colors.red,colors.red_off),flash=true,period=flasher.PERIOD.BLINK_250_MS}
local controls_rct = Rectangle{parent=status,width=17,height=3,x=1,border=border(1,colors.white,true),even_inner=true,fg_bg=cpair(colors.black,colors.ivory)}
local controls = Div{parent=controls_rct,width=15,height=1,fg_bg=cpair(colors.black,colors.white)}
PushButton{parent=controls,x=1,y=1,min_width=7,text="SCRAM",callback=databus.rps_scram,fg_bg=cpair(colors.black,colors.red),active_fg_bg=cpair(colors.black,colors.red_off)}
PushButton{parent=controls,x=9,y=1,min_width=7,text="RESET",callback=databus.rps_reset,fg_bg=cpair(colors.black,colors.yellow),active_fg_bg=cpair(colors.black,colors.yellow_off)}
databus.rx_field("reactor_active", active.update)
databus.rx_field("rps_scram", scram.update)
--
-- about footer
--
local about = Rectangle{parent=panel,width=32,height=3,x=2,y=16,border=border(1,colors.ivory),thin=true,fg_bg=cpair(colors.black,colors.white)}
local fw_v = TextBox{parent=about,x=2,y=1,text="FW: v00.00.00",alignment=TEXT_ALIGN.LEFT,height=1}
local comms_v = TextBox{parent=about,x=17,y=1,text="NT: v00.00.00",alignment=TEXT_ALIGN.LEFT,height=1}
databus.rx_field("version", function (version) fw_v.set_value(util.c("FW: ", version)) end)
databus.rx_field("comms_version", function (version) comms_v.set_value(util.c("NT: v", version)) end)
--
-- rps list
--
local rps = Rectangle{parent=panel,width=16,height=16,x=36,y=3,border=border(1,colors.lightGray),thin=true,fg_bg=cpair(colors.black,colors.lightGray)}
local rps_man = LED{parent=rps,label="MANUAL",colors=cpair(colors.red,colors.red_off)}
local rps_auto = LED{parent=rps,label="AUTOMATIC",colors=cpair(colors.red,colors.red_off)}
local rps_tmo = LED{parent=rps,label="TIMEOUT",colors=cpair(colors.red,colors.red_off)}
local rps_flt = LED{parent=rps,label="PLC FAULT",colors=cpair(colors.red,colors.red_off)}
local rps_fail = LED{parent=rps,label="RCT FAULT",colors=cpair(colors.red,colors.red_off)}
rps.line_break()
local rps_dmg = LED{parent=rps,label="HI DAMAGE",colors=cpair(colors.red,colors.red_off)}
local rps_tmp = LED{parent=rps,label="HI TEMP",colors=cpair(colors.red,colors.red_off)}
rps.line_break()
local rps_nof = LED{parent=rps,label="LO FUEL",colors=cpair(colors.red,colors.red_off)}
local rps_wst = LED{parent=rps,label="HI WASTE",colors=cpair(colors.red,colors.red_off)}
rps.line_break()
local rps_ccl = LED{parent=rps,label="LO CCOOLANT",colors=cpair(colors.red,colors.red_off)}
local rps_hcl = LED{parent=rps,label="HI HCOOLANT",colors=cpair(colors.red,colors.red_off)}
databus.rx_field("rps_manual", rps_man.update)
databus.rx_field("rps_automatic", rps_auto.update)
databus.rx_field("rps_timeout", rps_tmo.update)
databus.rx_field("rps_fault", rps_flt.update)
databus.rx_field("rps_sysfail", rps_fail.update)
databus.rx_field("rps_damage", rps_dmg.update)
databus.rx_field("rps_high_temp", rps_tmp.update)
databus.rx_field("rps_no_fuel", rps_nof.update)
databus.rx_field("rps_high_waste", rps_wst.update)
databus.rx_field("rps_low_ccool", rps_ccl.update)
databus.rx_field("rps_high_hcool", rps_hcl.update)
end
return init

View File

@@ -0,0 +1,41 @@
--
-- Graphics Style Options
--
local core = require("graphics.core")
local style = {}
local cpair = core.graphics.cpair
-- GLOBAL --
-- remap global colors
colors.ivory = colors.pink
colors.red_off = colors.brown
colors.yellow_off = colors.magenta
colors.green_off = colors.lime
style.root = cpair(colors.black, colors.ivory)
style.header = cpair(colors.black, colors.lightGray)
style.colors = {
{ c = colors.red, hex = 0xdf4949 }, -- RED ON
{ c = colors.orange, hex = 0xffb659 },
{ c = colors.yellow, hex = 0xf9fb53 }, -- YELLOW ON
{ c = colors.lime, hex = 0x16665a }, -- GREEN OFF
{ c = colors.green, hex = 0x6be551 }, -- GREEN ON
{ c = colors.cyan, hex = 0x34bac8 },
{ c = colors.lightBlue, hex = 0x6cc0f2 },
{ c = colors.blue, hex = 0x0096ff },
{ c = colors.purple, hex = 0xb156ee },
{ c = colors.pink, hex = 0xdcd9ca }, -- IVORY
{ c = colors.magenta, hex = 0x85862c }, -- YELLOW OFF
-- { c = colors.white, hex = 0xdcd9ca },
{ c = colors.lightGray, hex = 0xb1b8b3 },
{ c = colors.gray, hex = 0x575757 },
-- { c = colors.black, hex = 0x191919 },
{ c = colors.brown, hex = 0x672223 } -- RED OFF
}
return style

File diff suppressed because it is too large Load Diff

80
reactor-plc/renderer.lua Normal file
View File

@@ -0,0 +1,80 @@
--
-- Graphics Rendering Control
--
local panel_view = require("reactor-plc.panel.front_panel")
local style = require("reactor-plc.panel.style")
local flasher = require("graphics.flasher")
local DisplayBox = require("graphics.elements.displaybox")
local renderer = {}
local ui = {
display = nil
}
-- start the UI
function renderer.start_ui()
if ui.display == nil then
-- reset terminal
term.setTextColor(colors.white)
term.setBackgroundColor(colors.black)
term.clear()
term.setCursorPos(1, 1)
-- set overridden colors
for i = 1, #style.colors do
term.setPaletteColor(style.colors[i].c, style.colors[i].hex)
end
-- init front panel view
ui.display = DisplayBox{window=term.current(),fg_bg=style.root}
panel_view(ui.display)
-- start flasher callback task
flasher.run()
end
end
-- close out the UI
function renderer.close_ui()
if ui.display ~= nil then
-- stop blinking indicators
flasher.clear()
-- hide to stop animation callbacks
ui.display.hide()
-- clear root UI elements
ui.display = nil
-- restore colors
for i = 1, #style.colors do
local r, g, b = term.nativePaletteColor(style.colors[i].c)
term.setPaletteColor(style.colors[i].c, r, g, b)
end
-- reset terminal
term.setTextColor(colors.white)
term.setBackgroundColor(colors.black)
term.clear()
term.setCursorPos(1, 1)
end
end
-- is the UI ready?
---@nodiscard
---@return boolean ready
function renderer.ui_ready() return ui.display ~= nil end
-- handle a mouse event
---@param event mouse_interaction
function renderer.handle_mouse(event)
if ui.display ~= nil then
ui.display.handle_mouse(event)
end
end
return renderer

View File

@@ -4,20 +4,23 @@
require("/initenv").init_env()
local log = require("scada-common.log")
local mqueue = require("scada-common.mqueue")
local ppm = require("scada-common.ppm")
local util = require("scada-common.util")
local comms = require("scada-common.comms")
local crash = require("scada-common.crash")
local log = require("scada-common.log")
local mqueue = require("scada-common.mqueue")
local ppm = require("scada-common.ppm")
local rsio = require("scada-common.rsio")
local util = require("scada-common.util")
local config = require("reactor-plc.config")
local plc = require("reactor-plc.plc")
local threads = require("reactor-plc.threads")
local config = require("reactor-plc.config")
local databus = require("reactor-plc.databus")
local plc = require("reactor-plc.plc")
local renderer = require("reactor-plc.renderer")
local threads = require("reactor-plc.threads")
local R_PLC_VERSION = "beta-v0.8.2"
local R_PLC_VERSION = "v1.1.17"
local print = util.print
local println = util.println
local print_ts = util.print_ts
local println_ts = util.println_ts
----------------------------------------
@@ -30,10 +33,23 @@ cfv.assert_type_bool(config.NETWORKED)
cfv.assert_type_int(config.REACTOR_ID)
cfv.assert_port(config.SERVER_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_int(config.LOG_MODE)
assert(cfv.valid(), "bad config file: missing/invalid fields")
-- check emergency coolant configuration
if type(config.EMERGENCY_COOL) == "table" then
if not rsio.is_valid_side(config.EMERGENCY_COOL.side) then
assert(false, "bad config file: emergency coolant side unrecognized")
elseif config.EMERGENCY_COOL.color ~= nil and not rsio.is_color(config.EMERGENCY_COOL.color) then
assert(false, "bad config file: emergency coolant invalid redstone channel color provided")
end
end
----------------------------------------
-- log init
----------------------------------------
@@ -45,153 +61,212 @@ log.info("BOOTING reactor-plc.startup " .. R_PLC_VERSION)
log.info("========================================")
println(">> Reactor PLC " .. R_PLC_VERSION .. " <<")
crash.set_env("plc", R_PLC_VERSION)
----------------------------------------
-- startup
-- main application
----------------------------------------
-- mount connected devices
ppm.mount_all()
local function main()
----------------------------------------
-- startup
----------------------------------------
-- shared memory across threads
---@class plc_shared_memory
local __shared_memory = {
-- networked setting
networked = config.NETWORKED, ---@type boolean
-- record firmware versions and ID
databus.tx_versions(R_PLC_VERSION, comms.version)
databus.tx_id(config.REACTOR_ID)
-- PLC system state flags
---@class plc_state
plc_state = {
init_ok = true,
shutdown = false,
degraded = false,
no_reactor = false,
no_modem = false
},
-- mount connected devices
ppm.mount_all()
-- control setpoints
---@class setpoints
setpoints = {
burn_rate_en = false,
burn_rate = 0.0
},
-- shared memory across threads
---@class plc_shared_memory
local __shared_memory = {
-- networked setting
networked = config.NETWORKED, ---@type boolean
-- core PLC devices
plc_dev = {
reactor = ppm.get_fission_reactor(),
modem = ppm.get_wireless_modem()
},
-- PLC system state flags
---@class plc_state
plc_state = {
init_ok = true,
fp_ok = false,
shutdown = false,
degraded = false,
reactor_formed = true,
no_reactor = false,
no_modem = false
},
-- system objects
plc_sys = {
rps = nil, ---@type rps
plc_comms = nil, ---@type plc_comms
conn_watchdog = nil ---@type watchdog
},
-- control setpoints
---@class setpoints
setpoints = {
burn_rate_en = false,
burn_rate = 0.0
},
-- message queues
q = {
mq_rps = mqueue.new(),
mq_comms_tx = mqueue.new(),
mq_comms_rx = mqueue.new()
-- core PLC devices
plc_dev = {
reactor = ppm.get_fission_reactor(),
modem = ppm.get_wireless_modem()
},
-- system objects
plc_sys = {
rps = nil, ---@type rps
plc_comms = nil, ---@type plc_comms
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_sys = __shared_memory.plc_sys
local smem_dev = __shared_memory.plc_dev
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
if smem_dev.reactor == nil then
println("boot> fission reactor not found");
log.warning("no reactor on startup")
-- we need a reactor, can at least do some things even if it isn't formed though
if smem_dev.reactor == nil then
println("init> fission reactor not found");
log.warning("init> no reactor on startup")
plc_state.init_ok = false
plc_state.degraded = true
plc_state.no_reactor = true
end
if __shared_memory.networked and smem_dev.modem == nil then
println("boot> wireless modem not found")
log.warning("no wireless modem on startup")
plc_state.init_ok = false
plc_state.degraded = true
plc_state.no_reactor = true
elseif not smem_dev.reactor.isFormed() then
println("init> fission reactor not formed");
log.warning("init> reactor logic adapter present, but reactor is not formed")
if smem_dev.reactor ~= nil then
smem_dev.reactor.scram()
plc_state.degraded = true
plc_state.reactor_formed = false
end
plc_state.init_ok = false
plc_state.degraded = true
plc_state.no_modem = true
end
-- modem is required if networked
if __shared_memory.networked and smem_dev.modem == nil then
println("init> wireless modem not found")
log.warning("init> no wireless modem on startup")
-- PLC init
local function init()
if plc_state.init_ok then
-- 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")
-- scram reactor if present and enabled
if (smem_dev.reactor ~= nil) and plc_state.reactor_formed and smem_dev.reactor.getStatus() then
smem_dev.reactor.scram()
end
---@diagnostic disable-next-line: undefined-field
os.queueEvent("clock_start")
plc_state.init_ok = false
plc_state.degraded = true
plc_state.no_modem = true
end
println("boot> completed");
log.debug("init> boot completed")
-- print a log message to the terminal as long as the UI isn't running
local function _println_no_fp(message) if not plc_state.fp_ok then println(message) end end
-- PLC init<br>
--- EVENT_CONSUMER: this function consumes events
local function init()
-- just booting up, no fission allowed (neutrons stay put thanks)
if (not plc_state.no_reactor) and plc_state.reactor_formed and smem_dev.reactor.getStatus() then
smem_dev.reactor.scram()
end
-- front panel time!
if not renderer.ui_ready() then
local message
plc_state.fp_ok, message = pcall(renderer.start_ui)
if not plc_state.fp_ok then
renderer.close_ui()
println_ts(util.c("UI error: ", message))
println("init> running without front panel")
log.error(util.c("GUI crashed with error ", message))
log.info("init> running in headless mode without front panel")
end
end
if plc_state.init_ok then
-- init reactor protection system
smem_sys.rps = plc.rps_init(smem_dev.reactor, plc_state.reactor_formed, config.EMERGENCY_COOL)
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_no_fp("init> starting in offline mode")
log.info("init> running without networking")
end
-- notify user of emergency coolant configuration status
if config.EMERGENCY_COOL ~= nil then
println("init> emergency coolant control ready")
log.info("init> running with emergency coolant control available")
end
util.push_event("clock_start")
_println_no_fp("init> completed")
log.info("init> startup completed")
else
_println_no_fp("init> system in degraded state, awaiting devices...")
log.warning("init> started in a degraded state, awaiting peripheral connections...")
end
databus.tx_hw_status(plc_state)
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
println("boot> system in degraded state, awaiting devices...")
log.warning("init> booted in a degraded state, awaiting peripheral connections...")
-- run threads, excluding comms
parallel.waitForAll(main_thread.p_exec, rps_thread.p_exec)
end
renderer.close_ui()
println_ts("exited")
log.info("exited")
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
if not xpcall(main, crash.handler) then
pcall(renderer.close_ui)
crash.exit()
else
-- run threads, excluding comms
parallel.waitForAll(main_thread.p_exec, rps_thread.p_exec)
log.close()
end
println_ts("exited")
log.info("exited")

View File

@@ -1,16 +1,17 @@
local log = require("scada-common.log")
local mqueue = require("scada-common.mqueue")
local ppm = require("scada-common.ppm")
local util = require("scada-common.util")
local log = require("scada-common.log")
local mqueue = require("scada-common.mqueue")
local ppm = require("scada-common.ppm")
local tcallbackdsp = require("scada-common.tcallbackdsp")
local util = require("scada-common.util")
local databus = require("reactor-plc.databus")
local renderer = require("reactor-plc.renderer")
local core = require("graphics.core")
local threads = {}
local print = util.print
local println = util.println
local print_ts = util.print_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 COMMS_SLEEP = 150 -- (150ms, 3 ticks)
local SP_CTRL_SLEEP = 250 -- (250ms, 5 ticks)
@@ -28,25 +29,31 @@ local MQ__COMM_CMD = {
}
-- main thread
---@nodiscard
---@param smem plc_shared_memory
---@param init function
function threads.thread__main(smem, init)
local public = {} ---@class thread
-- print a log message to the terminal as long as the UI isn't running
local function println_ts(message) if not smem.plc_state.fp_ok then util.println_ts(message) end end
---@class parallel_thread
local public = {}
-- execute thread
function public.exec()
databus.tx_rt_status("main", true)
log.debug("main thread init, clock inactive")
-- send status updates at 1Hz (every 20 server ticks) (every loop tick)
-- send link requests at 0.5Hz (every 40 server ticks) (every 4 loop ticks)
local LINK_TICKS = 4
-- send status updates at 2Hz (every 10 server ticks) (every loop tick)
-- send link requests at 0.5Hz (every 40 server ticks) (every 8 loop ticks)
local LINK_TICKS = 8
local ticks_to_update = 0
local loop_clock = util.new_clock(MAIN_CLOCK)
-- load in from shared memory
local networked = smem.networked
local plc_state = smem.plc_state
local plc_dev = smem.plc_dev
local networked = smem.networked
local plc_state = smem.plc_state
local plc_dev = smem.plc_dev
-- event loop
while true do
@@ -59,6 +66,9 @@ function threads.thread__main(smem, init)
-- handle event
if event == "timer" and loop_clock.is_clock(param1) then
-- blink heartbeat indicator
databus.heartbeat()
-- core clock tick
if networked then
-- start next clock timer
@@ -78,6 +88,62 @@ function threads.thread__main(smem, init)
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
-- update indicators
databus.tx_hw_status(plc_state)
elseif event == "modem_message" and networked and plc_state.init_ok and not plc_state.no_modem then
-- got a packet
local packet = plc_comms.parse_packet(param1, param2, param3, param4, param5)
@@ -89,21 +155,26 @@ function threads.thread__main(smem, init)
-- haven't heard from server recently? shutdown reactor
plc_comms.unlink()
smem.q.mq_rps.push_command(MQ__RPS_CMD.TRIP_TIMEOUT)
elseif event == "timer" then
-- notify timer callback dispatcher if no other timer case claimed this event
tcallbackdsp.handle(param1)
elseif event == "peripheral_detach" then
-- peripheral disconnect
local type, device = ppm.handle_unmount(param1)
if type ~= nil and device ~= nil then
if type == "fissionReactor" then
if type == "fissionReactorLogicAdapter" then
println_ts("reactor disconnected!")
log.error("reactor disconnected!")
log.error("reactor logic adapter disconnected")
plc_state.no_reactor = true
plc_state.degraded = true
elseif networked and type == "modem" then
-- we only care if this is our wireless modem
if device == plc_dev.modem then
println_ts("wireless modem disconnected!")
log.error("comms modem disconnected!")
println_ts("comms modem disconnected!")
log.error("comms modem disconnected")
plc_state.no_modem = true
if plc_state.init_ok then
@@ -117,31 +188,43 @@ function threads.thread__main(smem, init)
end
end
end
-- update indicators
databus.tx_hw_status(plc_state)
elseif event == "peripheral" then
-- peripheral connect
local type, device = ppm.mount(param1)
if type ~= nil and device ~= nil then
if type == "fissionReactor" then
if type == "fissionReactorLogicAdapter" then
-- reconnected reactor
plc_dev.reactor = device
smem.q.mq_rps.push_command(MQ__RPS_CMD.SCRAM)
println_ts("reactor reconnected.")
log.info("reactor reconnected")
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
smem.q.mq_rps.push_command(MQ__RPS_CMD.SCRAM)
rps.reconnect_reactor(plc_dev.reactor)
if networked then
plc_comms.reconnect_reactor(plc_dev.reactor)
end
end
-- determine if we are still in a degraded state
if not networked or not plc_state.no_modem then
plc_state.degraded = false
-- 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()
end
elseif networked and type == "modem" then
if device.isWireless() then
@@ -171,6 +254,12 @@ function threads.thread__main(smem, init)
plc_state.init_ok = true
init()
end
-- update indicators
databus.tx_hw_status(plc_state)
elseif event == "mouse_click" then
-- handle a monitor touch event
renderer.handle_mouse(core.events.click(param1, param2, param3))
elseif event == "clock_start" then
-- start loop clock
loop_clock.start()
@@ -194,17 +283,17 @@ function threads.thread__main(smem, init)
while not plc_state.shutdown do
local status, result = pcall(public.exec)
if status == false then
log.fatal(result)
log.fatal(util.strval(result))
end
databus.tx_rt_status("main", false)
-- if status is true, then we are probably exiting, so this won't matter
-- if not, we need to restart the clock
-- this thread cannot be slept because it will miss events (namely "terminate" otherwise)
if not plc_state.shutdown then
log.info("main thread restarting now...")
---@diagnostic disable-next-line: undefined-field
os.queueEvent("clock_start")
util.push_event("clock_start")
end
end
end
@@ -213,12 +302,18 @@ function threads.thread__main(smem, init)
end
-- RPS operation thread
---@nodiscard
---@param smem plc_shared_memory
function threads.thread__rps(smem)
local public = {} ---@class thread
-- print a log message to the terminal as long as the UI isn't running
local function println_ts(message) if not smem.plc_state.fp_ok then util.println_ts(message) end end
---@class parallel_thread
local public = {}
-- execute thread
function public.exec()
databus.tx_rt_status("rps", true)
log.debug("rps thread start")
-- load in from shared memory
@@ -234,10 +329,10 @@ function threads.thread__rps(smem)
-- thread loop
while true do
-- get plc_sys fields (may have been set late due to degraded boot)
local rps = smem.plc_sys.rps
local plc_comms = smem.plc_sys.plc_comms
local rps = smem.plc_sys.rps
local plc_comms = smem.plc_sys.plc_comms
-- get reactor, may have changed do to disconnect/reconnect
local reactor = plc_dev.reactor
local reactor = plc_dev.reactor
-- RPS checks
if plc_state.init_ok then
@@ -248,20 +343,25 @@ function threads.thread__rps(smem)
rps.trip_timeout()
end
else
-- would do elseif not networked but there is no reason to do that extra operation
was_linked = true
end
-- 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)
if (not plc_state.no_reactor) and rps.is_formed() then
-- check reactor status
---@diagnostic disable-next-line: need-check-nil
if not plc_state.no_reactor and rps.is_tripped() and reactor.getStatus() then
rps.scram()
local reactor_status = reactor.getStatus()
databus.tx_reactor_state(reactor_status)
-- 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)
if rps.is_tripped() and reactor_status then
rps.scram()
end
end
-- 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
if not networked then rps.reset() end
if not networked then rps.reset(true) end
-- check safety (SCRAM occurs if tripped)
if not plc_state.no_reactor then
@@ -337,9 +437,11 @@ function threads.thread__rps(smem)
while not plc_state.shutdown do
local status, result = pcall(public.exec)
if status == false then
log.fatal(result)
log.fatal(util.strval(result))
end
databus.tx_rt_status("rps", false)
if not plc_state.shutdown then
if plc_state.init_ok then smem.plc_sys.rps.scram() end
log.info("rps thread restarting in 5 seconds...")
@@ -352,12 +454,15 @@ function threads.thread__rps(smem)
end
-- communications sender thread
---@nodiscard
---@param smem plc_shared_memory
function threads.thread__comms_tx(smem)
local public = {} ---@class thread
---@class parallel_thread
local public = {}
-- execute thread
function public.exec()
databus.tx_rt_status("comms_tx", true)
log.debug("comms tx thread start")
-- load in from shared memory
@@ -380,7 +485,7 @@ function threads.thread__comms_tx(smem)
-- received a command
if msg.message == MQ__COMM_CMD.SEND_STATUS then
-- 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()
end
elseif msg.qtype == mqueue.TYPE.DATA then
@@ -412,9 +517,11 @@ function threads.thread__comms_tx(smem)
while not plc_state.shutdown do
local status, result = pcall(public.exec)
if status == false then
log.fatal(result)
log.fatal(util.strval(result))
end
databus.tx_rt_status("comms_tx", false)
if not plc_state.shutdown then
log.info("comms tx thread restarting in 5 seconds...")
util.psleep(5)
@@ -426,12 +533,15 @@ function threads.thread__comms_tx(smem)
end
-- communications handler thread
---@nodiscard
---@param smem plc_shared_memory
function threads.thread__comms_rx(smem)
local public = {} ---@class thread
---@class parallel_thread
local public = {}
-- execute thread
function public.exec()
databus.tx_rt_status("comms_rx", true)
log.debug("comms rx thread start")
-- load in from shared memory
@@ -460,7 +570,7 @@ function threads.thread__comms_rx(smem)
-- received a packet
-- handle the packet (setpoints passed to update burn rate setpoint)
-- (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
@@ -486,9 +596,11 @@ function threads.thread__comms_rx(smem)
while not plc_state.shutdown do
local status, result = pcall(public.exec)
if status == false then
log.fatal(result)
log.fatal(util.strval(result))
end
databus.tx_rt_status("comms_rx", false)
if not plc_state.shutdown then
log.info("comms rx thread restarting in 5 seconds...")
util.psleep(5)
@@ -499,13 +611,16 @@ function threads.thread__comms_rx(smem)
return public
end
-- apply setpoints
-- ramp control outputs to desired setpoints
---@nodiscard
---@param smem plc_shared_memory
function threads.thread__setpoint_control(smem)
local public = {} ---@class thread
---@class parallel_thread
local public = {}
-- execute thread
function public.exec()
databus.tx_rt_status("spctl", true)
log.debug("setpoint control thread start")
-- load in from shared memory
@@ -516,7 +631,7 @@ function threads.thread__setpoint_control(smem)
local last_update = util.time()
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
-- we do not want to have big jumps as that is what we are trying to avoid in the first place
@@ -529,23 +644,25 @@ function threads.thread__setpoint_control(smem)
-- get reactor, may have changed do to disconnect/reconnect
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
if setpoints.burn_rate_en and setpoints.burn_rate ~= last_sp_burn then
if rps.is_active() then
if math.abs(setpoints.burn_rate - last_sp_burn) <= 5 then
-- update without ramp if <= 5 mB/t change
log.debug("setting burn rate directly to " .. setpoints.burn_rate .. "mB/t")
if setpoints.burn_rate_en and (setpoints.burn_rate ~= last_burn_sp) then
---@diagnostic disable-next-line: need-check-nil
local cur_burn_rate = reactor.getBurnRate()
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
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
last_sp_burn = setpoints.burn_rate
else
last_sp_burn = 0.0
end
end
@@ -561,34 +678,39 @@ function threads.thread__setpoint_control(smem)
local current_burn_rate = reactor.getBurnRate()
-- 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
local new_burn_rate = current_burn_rate
local new_burn_rate ---@type number
if setpoints.burn_rate > current_burn_rate then
-- need to ramp up
local new_burn_rate = current_burn_rate + (BURN_RATE_RAMP_mB_s * min_elapsed_s)
if new_burn_rate > setpoints.burn_rate then
new_burn_rate = setpoints.burn_rate
end
new_burn_rate = current_burn_rate + (BURN_RATE_RAMP_mB_s * min_elapsed_s)
if new_burn_rate > setpoints.burn_rate then new_burn_rate = setpoints.burn_rate end
else
-- need to ramp down
local new_burn_rate = current_burn_rate - (BURN_RATE_RAMP_mB_s * min_elapsed_s)
if new_burn_rate < setpoints.burn_rate then
new_burn_rate = setpoints.burn_rate
end
new_burn_rate = current_burn_rate - (BURN_RATE_RAMP_mB_s * min_elapsed_s)
if new_burn_rate < setpoints.burn_rate then new_burn_rate = setpoints.burn_rate end
end
running = running or (new_burn_rate ~= setpoints.burn_rate)
-- set the burn rate
---@diagnostic disable-next-line: need-check-nil
reactor.setBurnRate(new_burn_rate)
running = running or (new_burn_rate ~= setpoints.burn_rate)
end
else
last_sp_burn = 0.0
log.debug("SPCTL: ramping aborted (reactor inactive)")
setpoints.burn_rate_en = false
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
@@ -610,9 +732,11 @@ function threads.thread__setpoint_control(smem)
while not plc_state.shutdown do
local status, result = pcall(public.exec)
if status == false then
log.fatal(result)
log.fatal(util.strval(result))
end
databus.tx_rt_status("spctl", false)
if not plc_state.shutdown then
log.info("setpoint control thread restarting in 5 seconds...")
util.psleep(5)

View File

@@ -6,12 +6,18 @@ local config = {}
config.SERVER_PORT = 16000
-- port to listen to incoming packets FROM server
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
config.LOG_PATH = "/log.txt"
-- log mode
-- 0 = APPEND (adds to existing file on start)
-- 1 = NEW (replaces existing file on start)
config.LOG_MODE = 0
-- RTU peripheral devices (named: side/network device name)
config.RTU_DEVICES = {
{
@@ -27,26 +33,31 @@ config.RTU_DEVICES = {
}
-- RTU redstone interface definitions
config.RTU_REDSTONE = {
{
for_reactor = 1,
io = {
{
channel = rsio.IO.WASTE_PO,
side = "top",
bundled_color = colors.blue
},
{
channel = rsio.IO.WASTE_PU,
side = "top",
bundled_color = colors.cyan
},
{
channel = rsio.IO.WASTE_AM,
side = "top",
bundled_color = colors.purple
}
}
}
-- {
-- for_reactor = 1,
-- io = {
-- {
-- port = rsio.IO.WASTE_PO,
-- side = "top",
-- bundled_color = colors.red
-- },
-- {
-- port = rsio.IO.WASTE_PU,
-- side = "top",
-- bundled_color = colors.orange
-- },
-- {
-- port = rsio.IO.WASTE_POPL,
-- side = "top",
-- bundled_color = colors.yellow
-- },
-- {
-- port = rsio.IO.WASTE_AM,
-- side = "top",
-- bundled_color = colors.lime
-- }
-- }
-- }
}
return config

75
rtu/databus.lua Normal file
View File

@@ -0,0 +1,75 @@
--
-- Data Bus - Central Communication Linking for RTU Front Panel
--
local psil = require("scada-common.psil")
local util = require("scada-common.util")
local databus = {}
local dbus_iface = {
ps = psil.create()
}
---@enum RTU_UNIT_HW_STATE
local RTU_UNIT_HW_STATE = {
OFFLINE = 1,
FAULTED = 2,
UNFORMED = 3,
OK = 4
}
databus.RTU_UNIT_HW_STATE = RTU_UNIT_HW_STATE
-- call to toggle heartbeat signal
function databus.heartbeat() dbus_iface.ps.toggle("heartbeat") end
-- transmit firmware versions across the bus
---@param rtu_v string RTU version
---@param comms_v string comms version
function databus.tx_versions(rtu_v, comms_v)
dbus_iface.ps.publish("version", rtu_v)
dbus_iface.ps.publish("comms_version", comms_v)
end
-- transmit hardware status for modem connection state
---@param has_modem boolean
function databus.tx_hw_modem(has_modem)
dbus_iface.ps.publish("has_modem", has_modem)
end
-- transmit unit hardware type across the bus
---@param uid integer unit ID
---@param type RTU_UNIT_TYPE
function databus.tx_unit_hw_type(uid, type)
dbus_iface.ps.publish("unit_type_" .. uid, type)
end
-- transmit unit hardware status across the bus
---@param uid integer unit ID
---@param status RTU_UNIT_HW_STATE
function databus.tx_unit_hw_status(uid, status)
dbus_iface.ps.publish("unit_hw_" .. uid, status)
end
-- transmit thread (routine) statuses
---@param thread string thread name
---@param ok boolean thread state
function databus.tx_rt_status(thread, ok)
dbus_iface.ps.publish(util.c("routine__", thread), ok)
end
-- transmit supervisor link state across the bus
---@param state integer
function databus.tx_link_state(state)
dbus_iface.ps.publish("link_state", state)
end
-- link a function to receive data from the bus
---@param field string field name
---@param func function function to link
function databus.rx_field(field, func)
dbus_iface.ps.subscribe(field, func)
end
return databus

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,10 +3,15 @@ local rtu = require("rtu.rtu")
local boilerv_rtu = {}
-- create new boiler (mek 10.1+) device
---@nodiscard
---@param boiler table
---@return rtu_device interface, boolean faulted
function boilerv_rtu.new(boiler)
local unit = rtu.init_unit()
-- disable auto fault clearing
boiler.__p_disable_afc()
-- discrete inputs --
unit.connect_di(boiler.isFormed)
@@ -28,10 +33,10 @@ function boilerv_rtu.new(boiler)
unit.connect_input_reg(boiler.getCooledCoolantCapacity)
unit.connect_input_reg(boiler.getSuperheaters)
unit.connect_input_reg(boiler.getMaxBoilRate)
unit.connect_input_reg(boiler.getEnvironmentalLoss)
-- current state
unit.connect_input_reg(boiler.getTemperature)
unit.connect_input_reg(boiler.getBoilRate)
unit.connect_input_reg(boiler.getEnvironmentalLoss)
-- tanks
unit.connect_input_reg(boiler.getSteam)
unit.connect_input_reg(boiler.getSteamNeeded)
@@ -49,7 +54,12 @@ function boilerv_rtu.new(boiler)
-- holding registers --
-- none
return unit.interface()
-- check if any calls faulted
local faulted = boiler.__p_is_faulted()
boiler.__p_clear_fault()
boiler.__p_enable_afc()
return unit.interface(), faulted
end
return boilerv_rtu

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,10 +3,15 @@ local rtu = require("rtu.rtu")
local envd_rtu = {}
-- create new environment detector device
---@nodiscard
---@param envd table
---@return rtu_device interface, boolean faulted
function envd_rtu.new(envd)
local unit = rtu.init_unit()
-- disable auto fault clearing
envd.__p_disable_afc()
-- discrete inputs --
-- none
@@ -20,7 +25,12 @@ function envd_rtu.new(envd)
-- holding registers --
-- none
return unit.interface()
-- check if any calls faulted
local faulted = envd.__p_is_faulted()
envd.__p_clear_fault()
envd.__p_enable_afc()
return unit.interface(), faulted
end
return envd_rtu

View File

@@ -3,10 +3,15 @@ local rtu = require("rtu.rtu")
local imatrix_rtu = {}
-- create new induction matrix (mek 10.1+) device
---@nodiscard
---@param imatrix table
---@return rtu_device interface, boolean faulted
function imatrix_rtu.new(imatrix)
local unit = rtu.init_unit()
-- disable auto fault clearing
imatrix.__p_disable_afc()
-- discrete inputs --
unit.connect_di(imatrix.isFormed)
@@ -36,7 +41,12 @@ function imatrix_rtu.new(imatrix)
-- holding registers --
-- none
return unit.interface()
-- check if any calls faulted
local faulted = imatrix.__p_is_faulted()
imatrix.__p_clear_fault()
imatrix.__p_enable_afc()
return unit.interface(), faulted
end
return imatrix_rtu

View File

@@ -1,21 +1,25 @@
local rtu = require("rtu.rtu")
local rsio = require("scada-common.rsio")
local rtu = require("rtu.rtu")
local redstone_rtu = {}
local IO_LVL = rsio.IO_LVL
local digital_read = rsio.digital_read
local digital_write = rsio.digital_write
local digital_is_active = rsio.digital_is_active
-- create new redstone device
---@nodiscard
---@return rtu_rs_device interface, boolean faulted
function redstone_rtu.new()
local unit = rtu.init_unit()
-- get RTU interface
local interface = unit.interface()
-- extends rtu_device; fields added manually to please Lua diagnostics
---@class rtu_rs_device
--- extends rtu_device; fields added manually to please Lua diagnostics
local public = {
io_count = interface.io_count,
read_coil = interface.read_coil,
@@ -30,7 +34,7 @@ function redstone_rtu.new()
---@param side string
---@param color integer
function public.link_di(side, color)
local f_read = nil
local f_read ---@type function
if color then
f_read = function ()
@@ -46,12 +50,11 @@ function redstone_rtu.new()
end
-- link digital output
---@param channel RS_IO
---@param side string
---@param color integer
function public.link_do(channel, side, color)
local f_read = nil
local f_write = nil
function public.link_do(side, color)
local f_read ---@type function
local f_write ---@type function
if color then
f_read = function ()
@@ -59,15 +62,17 @@ function redstone_rtu.new()
end
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
output = colors.combine(output, color)
else
output = colors.subtract(output, color)
if digital_write(level) then
output = colors.combine(output, color)
else
output = colors.subtract(output, color)
end
rs.setBundledOutput(side, output)
end
rs.setBundledOutput(side, output)
end
else
f_read = function ()
@@ -75,7 +80,9 @@ function redstone_rtu.new()
end
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
@@ -105,7 +112,7 @@ function redstone_rtu.new()
)
end
return public
return public, false
end
return redstone_rtu

View File

@@ -2,11 +2,16 @@ local rtu = require("rtu.rtu")
local sna_rtu = {}
-- create new solar neutron activator (sna) device
-- create new solar neutron activator (SNA) device
---@nodiscard
---@param sna table
---@return rtu_device interface, boolean faulted
function sna_rtu.new(sna)
local unit = rtu.init_unit()
-- disable auto fault clearing
sna.__p_disable_afc()
-- discrete inputs --
-- none
@@ -31,7 +36,12 @@ function sna_rtu.new(sna)
-- holding registers --
-- none
return unit.interface()
-- check if any calls faulted
local faulted = sna.__p_is_faulted()
sna.__p_clear_fault()
sna.__p_enable_afc()
return unit.interface(), faulted
end
return sna_rtu

View File

@@ -2,11 +2,16 @@ local rtu = require("rtu.rtu")
local sps_rtu = {}
-- create new super-critical phase shifter (sps) device
-- create new super-critical phase shifter (SPS) device
---@nodiscard
---@param sps table
---@return rtu_device interface, boolean faulted
function sps_rtu.new(sps)
local unit = rtu.init_unit()
-- disable auto fault clearing
sps.__p_disable_afc()
-- discrete inputs --
unit.connect_di(sps.isFormed)
@@ -41,7 +46,12 @@ function sps_rtu.new(sps)
-- holding registers --
-- none
return unit.interface()
-- check if any calls faulted
local faulted = sps.__p_is_faulted()
sps.__p_clear_fault()
sps.__p_enable_afc()
return unit.interface(), faulted
end
return sps_rtu

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,10 +3,15 @@ local rtu = require("rtu.rtu")
local turbinev_rtu = {}
-- create new turbine (mek 10.1+) device
---@nodiscard
---@param turbine table
---@return rtu_device interface, boolean faulted
function turbinev_rtu.new(turbine)
local unit = rtu.init_unit()
-- disable auto fault clearing
turbine.__p_disable_afc()
-- discrete inputs --
unit.connect_di(turbine.isFormed)
@@ -46,9 +51,14 @@ function turbinev_rtu.new(turbine)
unit.connect_input_reg(turbine.getEnergyFilledPercentage)
-- holding registers --
unit.connect_holding_reg(turbine.setDumpingMode, turbine.getDumpingMode)
unit.connect_holding_reg(turbine.getDumpingMode, turbine.setDumpingMode)
return unit.interface()
-- check if any calls faulted
local faulted = turbine.__p_is_faulted()
turbine.__p_clear_fault()
turbine.__p_enable_afc()
return unit.interface(), faulted
end
return turbinev_rtu

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