Compare commits

..

234 Commits

Author SHA1 Message Date
Mikayla
e21c7d92fe Merge pull request #60 from MikaylaFischler/devel
Alpha PLC, RTU, and Supervisor Release
2022-05-27 18:43:01 -04:00
Mikayla Fischler
706bf4d3ba #9 turbine RTU tester 2022-05-27 18:18:12 -04:00
Mikayla Fischler
4d16d64cdc log bugfix 2022-05-27 18:17:52 -04:00
Mikayla Fischler
6df0a1d149 #9 MODBUS test code; fixed rtu, modbus, redstone_rtu, and rsio bugs 2022-05-27 18:10:06 -04:00
Mikayla Fischler
51111f707f more descriptive comments 2022-05-26 19:37:19 -04:00
Mikayla Fischler
214f2d9028 fixed supervisor clock not starting 2022-05-26 17:49:53 -04:00
Mikayla Fischler
78ddd4d782 #58 fixed bug with KEEP_ALIVE being sent as a LINK_REQ due to wrong protocol 2022-05-26 17:49:43 -04:00
Mikayla Fischler
7d7eecaa5e log use strval 2022-05-25 23:24:15 -04:00
Mikayla Fischler
4d7d3be93b power related utility functions put under util table 2022-05-24 22:58:42 -04:00
Mikayla Fischler
ffc997b84e removed redundant version tag 2022-05-24 22:56:41 -04:00
Mikayla Fischler
4b6a1c5902 fixed incorrect watchdog call 2022-05-24 22:55:27 -04:00
Mikayla Fischler
0cf81040fb moved string value to util and added sprtinf, adjusted prints to use tostring to prevent concatentation errors on some types 2022-05-24 22:48:31 -04:00
Mikayla Fischler
9fb6b7a880 #9 rsio test code, fixes per test results 2022-05-23 17:36:54 -04:00
Mikayla Fischler
a93f0a4452 #57 updates per safety pass, fixed plc_sys fields staying nil on degraded start, fixed repeated SCRAM messages when unlinked 2022-05-22 17:57:24 -04:00
Mikayla Fischler
26c6010ce0 #56 pcall threads and restart on crash (unless shutting down) 2022-05-21 13:56:14 -04:00
Mikayla Fischler
3b16d783d3 fixed bug with RSIO channel valid check 2022-05-21 13:55:22 -04:00
Mikayla Fischler
940ddf0d00 function for duplicate session search code 2022-05-21 12:30:14 -04:00
Mikayla Fischler
3f4fb63029 #52 basic reactor unit object 2022-05-21 12:24:43 -04:00
Mikayla Fischler
61965f295d added #29 to known issues 2022-05-19 10:49:17 -04:00
Mikayla Fischler
44d30ae583 #48 only log every 20 PPM faults (per function) 2022-05-19 10:35:56 -04:00
Mikayla Fischler
6a168c884d #23 device version reporting 2022-05-19 10:21:04 -04:00
Mikayla Fischler
dd553125d6 #54 don't trip RPS fault on terminate as it ends up being redundant with shutdown sequence 2022-05-19 09:19:51 -04:00
Mikayla Fischler
62d5490dc8 #53 RTU redstone parse checks 2022-05-18 14:30:48 -04:00
Mikayla Fischler
790571b6fc #55 correctly use device IDs vs unit IDs 2022-05-18 13:49:04 -04:00
Mikayla Fischler
cc856d4d80 redundant 'for_reactor' field removed from redstone RTU config 2022-05-18 13:32:44 -04:00
Mikayla Fischler
6184078c3f #52 work in progress on reactor units 2022-05-18 13:28:43 -04:00
Mikayla Fischler
9c034c366b #8 base class for RTU unit sessions, handle MODBUS error responses 2022-05-17 17:16:04 -04:00
Mikayla Fischler
31ede51c42 still queue packets if RTU is busy, determine busy state by queue length rather than flag 2022-05-17 10:35:55 -04:00
Mikayla Fischler
0eff8a3e6a #8 cleaned up closing logic, added connected flags to RTU unit sessions 2022-05-16 17:13:54 -04:00
Mikayla Fischler
136b09d7f2 util filter table 2022-05-16 17:11:46 -04:00
Mikayla Fischler
bdd8af1873 dmesg logging 2022-05-16 12:50:51 -04:00
Mikayla Fischler
11b89b679d #8 redstone RTU output commands 2022-05-16 11:54:34 -04:00
Mikayla Fischler
530a40b0aa changed DISCONNECT constant value to -1 to not conflict with redstone values 2022-05-16 11:52:03 -04:00
Mikayla Fischler
4834dbf781 changed redstone I/O capabilities, added analog read/write scaling functions 2022-05-16 10:38:47 -04:00
Mikayla Fischler
374bfb7a19 #8 handle redstone RTU MODBUS replies, bugfixes 2022-05-14 20:42:28 -04:00
Mikayla Fischler
94931ef5a2 #8 bugfixes, redstone RTU session 2022-05-14 20:27:06 -04:00
Mikayla Fischler
bc6453de2b RTU bugfixes, adjusted comms sleep timer 2022-05-14 20:07:26 -04:00
Mikayla Fischler
533d398f9d comment change 2022-05-14 13:34:51 -04:00
Mikayla Fischler
d3a926b25a fixed require issues caused by using bootloader 2022-05-14 13:32:42 -04:00
Mikayla Fischler
6b74db70bd #8 fixed RTU unit type check 2022-05-13 12:23:30 -04:00
Mikayla Fischler
282d3fcd99 added queue_data class 2022-05-13 12:23:01 -04:00
Mikayla Fischler
72da718015 optimized session lookup 2022-05-13 11:38:56 -04:00
Mikayla Fischler
bf0e92d6e4 refactoring 2022-05-13 11:08:22 -04:00
Mikayla Fischler
c53ddf1638 renamed RTU_ADVERT_TYPES to RTU_UNIT_TYPES 2022-05-13 10:27:57 -04:00
Mikayla Fischler
45f5843598 #8 renamed rtu_session_unit type to unit_session 2022-05-13 09:49:24 -04:00
Mikayla Fischler
fc39588b2e #8 RTU session for emachine and turbine, RTU session creation, adjusted sequence number logic in svsessions 2022-05-13 09:45:11 -04:00
Mikayla Fischler
635e7b7f59 RTU advertisement sends as basic array, re-ordered input registers on turbine RTU 2022-05-13 09:39:28 -04:00
Mikayla Fischler
13fcf265b7 updated types, added dumping mode and rtu_advertisement 2022-05-13 09:38:32 -04:00
Mikayla Fischler
8b43c81fc0 class definition in only comments 2022-05-13 09:38:10 -04:00
Mikayla Fischler
e624dd431b tank_fluid and coordinate table types 2022-05-12 15:37:42 -04:00
Mikayla Fischler
969abca95d RTU device changes, bugfixes, docs 2022-05-12 15:36:27 -04:00
Mikayla Fischler
9695e94608 plc session terminology change, changed number/integer types 2022-05-11 13:05:20 -04:00
Mikayla Fischler
b985362757 #8 RTU session for boiler, added transaction controller 2022-05-11 13:02:21 -04:00
Mikayla Fischler
0d090fe9e2 #47 supervisor luadoc, bugfixes 2022-05-11 12:31:19 -04:00
Mikayla Fischler
95c4d51e67 #47 RTU send should be table not any 2022-05-11 12:09:04 -04:00
Mikayla Fischler
c6987f6f67 #47 RTU luadoc, bugfixes 2022-05-11 12:03:15 -04:00
Mikayla Fischler
5ad14205f3 #47 not going to do file level diagnostic disables 2022-05-11 12:01:18 -04:00
Mikayla Fischler
02541184bd bootloader 2022-05-11 11:31:02 -04:00
Mikayla Fischler
bced8bf566 #47 packet frames 2022-05-10 21:51:04 -04:00
Mikayla Fischler
faac421b63 #47 reactor plc docs and bugfixes 2022-05-10 21:49:40 -04:00
Mikayla Fischler
22a6159520 updated globals list, fixed packet references that were linking to old controller mistakenly 2022-05-10 17:17:55 -04:00
Mikayla Fischler
f4e397ebb1 removed old controller code to not pollute workspace with globals 2022-05-10 17:16:34 -04:00
Mikayla Fischler
87de804a9e proper module format 2022-05-10 17:09:02 -04:00
Mikayla Fischler
e3a4ed5363 catch nil cases, supervisor use loop clock 2022-05-10 17:08:38 -04:00
Mikayla Fischler
3c688bfafa #47 scada-common doc comments 2022-05-10 17:06:27 -04:00
Mikayla Fischler
6e1ece8183 watchdog cleanup and loop clock object 2022-05-10 13:06:13 -04:00
Mikayla Fischler
168341db39 code cleanup and bugfixes 2022-05-10 12:01:56 -04:00
Mikayla Fischler
d7e38d6393 supression of warnings, added lua diagnostics global list 2022-05-10 11:41:49 -04:00
Mikayla Fischler
cd0d7aa5a3 cleanup/fixes of scada common code 2022-05-10 11:35:52 -04:00
Mikayla Fischler
25558df22d RTU/PLC code cleanup, #46 changed KEEP_ALIVE to scada message type and use it for the RTU too 2022-05-09 15:00:16 -04:00
Mikayla Fischler
679d98c8bf #8 work in progress on RTU sessions and added unit object 2022-05-09 09:35:39 -04:00
Mikayla Fischler
469ee29b5a cleanup of rtu comms 2022-05-09 09:34:26 -04:00
Mikayla Fischler
96e535fdc4 global scope optimizations 2022-05-07 13:39:12 -04:00
Mikayla Fischler
4aab75b842 rsio optimizations 2022-05-06 11:11:53 -04:00
Mikayla Fischler
17a46ae642 mqueue optimizations 2022-05-06 10:53:12 -04:00
Mikayla Fischler
d0b2820160 logging optimizations 2022-05-06 10:48:46 -04:00
Mikayla Fischler
b7e5ced2e8 PLC bugfixes 2022-05-06 09:10:50 -04:00
Mikayla Fischler
eaabe51537 Merge branch 'main' of github.com:MikaylaFischler/cc-mek-scada into devel 2022-05-05 23:10:56 -04:00
Mikayla Fischler
83fa41bbd0 #45 burn rate ramping is optional now 2022-05-05 16:00:49 -04:00
Mikayla Fischler
89be79192f #44 RPS optimizations, manual trip, RPS handles all reactor state control 2022-05-05 13:14:14 -04:00
Mikayla Fischler
c4df8eabf9 #43 rename ISS to RPS 2022-05-05 11:55:04 -04:00
Mikayla Fischler
b575899d46 #33 lua module/require architecture changeover 2022-05-04 13:37:01 -04:00
Mikayla Fischler
7bcb260712 #27 added getMaxEnergy for turbine 2022-05-04 12:03:07 -04:00
Mikayla Fischler
1cb5a0789e #27 mekanism 10.1+ RTU support 2022-05-04 11:23:45 -04:00
Mikayla Fischler
8b7ef47aad removed references to alarms and now sends status on shutdown 2022-05-04 10:00:21 -04:00
Mikayla Fischler
e253a7b4ff supervisor PLC session closing, re-requesting status cache if missing 2022-05-03 17:25:31 -04:00
Mikayla Fischler
fe5059dd51 debug print 2022-05-03 17:21:34 -04:00
Mikayla Fischler
25c6b311f5 clear status cache when connection is lost/reset, allow requesting of full status 2022-05-03 17:10:42 -04:00
Mikayla Fischler
665b33fa05 #42 parallel RTU reads 2022-05-03 11:39:03 -04:00
Mikayla Fischler
635c70cffd moved MODBUS file thanks to utilizing types file 2022-05-03 11:28:29 -04:00
Mikayla Fischler
dc1c1db5e6 MODBUS bugfixes, utilize new types file 2022-05-03 11:27:40 -04:00
Mikayla Fischler
e2f7318922 #27 induction matrix RTU split into two RTUs, supporting pre and post Mekansim 10.1 2022-05-03 10:49:14 -04:00
Mikayla Fischler
c76200b0e3 shared global types 2022-05-03 10:44:18 -04:00
Mikayla Fischler
62b4b63f4a supervisor PLC session bugfixes 2022-05-02 17:43:23 -04:00
Mikayla Fischler
574b85e177 PLC bugfixes and #37 optimized status packets and structure packets 2022-05-02 17:40:00 -04:00
Mikayla Fischler
e3e370d3ab fixed RTU mgmt_pkt reference 2022-05-02 17:34:57 -04:00
Mikayla Fischler
1ac4de65a9 added close to valid scada types and fixed length checks for packet decoders 2022-05-02 17:34:24 -04:00
Mikayla Fischler
c19a58380c fixed cancel not being exposed for watchdog functions 2022-05-02 17:33:54 -04:00
Mikayla Fischler
4bc50e4bad #41 RTU comms closing 2022-05-02 13:15:08 -04:00
Mikayla Fischler
5ce3f84dfa #41 PLC connection closing 2022-05-02 12:06:04 -04:00
Mikayla Fischler
b280201446 #41 cancel session watchdog timer 2022-05-02 11:44:10 -04:00
Mikayla Fischler
76c81395b7 #41 close session connections 2022-05-02 11:42:24 -04:00
Mikayla Fischler
7ff0e25711 #7 sending commands to PLCs, retrying failed sends until confirmed 2022-05-01 17:04:38 -04:00
Mikayla Fischler
cd46c69a66 defined push_data to be implemented 2022-05-01 15:35:07 -04:00
Mikayla Fischler
b76871aa07 fixed incorrect program type in startup message 2022-05-01 15:34:44 -04:00
Mikayla Fischler
479194b589 ISS alarm status packet adjustments 2022-05-01 13:26:02 -04:00
Mikayla Fischler
3fe47f99a9 PLC bugfix 2022-04-30 13:44:28 -04:00
Mikayla Fischler
aeda38fa01 #17 set burn rate right away if within range, reset last setpoint on SCRAM 2022-04-30 03:03:34 -04:00
Mikayla Fischler
10aa34a8e8 #17 PLC ramp burn rate to setpoint 2022-04-29 22:27:54 -04:00
Mikayla Fischler
e1135eac01 log init parameters in config files 2022-04-29 13:36:00 -04:00
Mikayla Fischler
c805b6e0c5 log init function to set path and write mode 2022-04-29 13:32:37 -04:00
Mikayla Fischler
3587352219 log exit notices as info messages not warnings 2022-04-29 13:20:56 -04:00
Mikayla Fischler
84e7ad43bc #39 RTU unit threads 2022-04-29 13:19:01 -04:00
Mikayla Fischler
e833176c65 #40 RTU sequence number verification 2022-04-29 10:19:05 -04:00
Mikayla Fischler
ef1fdc7f39 #34 RTU modem disconnect/reconnect handling, bugfix in comms thread 2022-04-29 09:27:05 -04:00
Mikayla Fischler
07e9101ac7 PLC modem disconnect bugfix 2022-04-29 09:25:08 -04:00
Mikayla Fischler
4d5cbcf475 PLC comms packet length checks 2022-04-29 09:07:29 -04:00
Mikayla Fischler
d688f9a1c6 supervisor code cleanup, adjusted prints 2022-04-28 22:41:08 -04:00
Mikayla Fischler
67ec8fbd91 rx and tx threads for PLC comms to maintain quick comms and #36 only feed watchdog on valid sequence numbers 2022-04-28 22:36:45 -04:00
Mikayla Fischler
aff166e27d added util adaptive_delay to replace repeated code 2022-04-27 19:06:01 -04:00
Mikayla Fischler
f14d715070 #7 PLC session comms link, accept statuses, functional keep-alives 2022-04-27 18:52:06 -04:00
Mikayla Fischler
7f0f423450 PLC bugfixes/optimizations, removed some debug prints 2022-04-27 18:49:54 -04:00
Mikayla Fischler
f067da31b4 #38 handle out of space when logging 2022-04-27 18:43:07 -04:00
Mikayla Fischler
fe3b8e6f88 fixed up worker loop delay logic 2022-04-27 17:59:25 -04:00
Mikayla Fischler
46a27a3f3a check shutdown flag in worker loops so they don't lock up the exit process 2022-04-27 16:38:41 -04:00
Mikayla Fischler
82726520b8 that was a stack not a queue, nice 2022-04-27 16:24:28 -04:00
Mikayla Fischler
d40937b467 this was supposed to be in that pr merge oops 2022-04-27 16:06:30 -04:00
Mikayla
f996b9414a Merge pull request #35 from MikaylaFischler/32-mek-api-parallel-exec
32 mek api parallel exec
2022-04-27 15:58:27 -04:00
Mikayla Fischler
146e0bf569 protected sleep call 2022-04-27 15:56:55 -04:00
Mikayla Fischler
67a93016c0 threaded RTU/PLC bugfixes 2022-04-27 15:52:34 -04:00
Mikayla Fischler
14377e7348 don't run PLC comms thread if not networked 2022-04-27 15:01:10 -04:00
Mikayla Fischler
8c4598e7a6 #32 new threaded RTU code 2022-04-27 12:46:04 -04:00
Mikayla Fischler
71be6aca1a cleanup and last_update bugfix for comms thread 2022-04-27 12:43:32 -04:00
Mikayla Fischler
ccf06956f9 fixed another typo 2022-04-27 12:37:28 -04:00
Mikayla Fischler
1ba5c7f828 fixed PLC mqueue typo and removed unused mq_main 2022-04-27 12:27:15 -04:00
Mikayla Fischler
68011d6734 #32 new threaded PLC code 2022-04-27 12:21:10 -04:00
Mikayla Fischler
f7f723829c #7 work on PLC session comms, bugfixes with comms, general supervisor bugfixes 2022-04-25 21:00:50 -04:00
Mikayla Fischler
19a4b3c0ef ticked up versions 2022-04-25 15:50:24 -04:00
Mikayla Fischler
3ef2902829 apparently I forgot how to spell receive a few more times 2022-04-25 15:49:04 -04:00
Mikayla Fischler
b861d3f668 removed debug print 2022-04-25 15:46:32 -04:00
Mikayla Fischler
e119c11204 removed debug print 2022-04-25 15:44:28 -04:00
Mikayla Fischler
b1998b61bc #32 parallel RTU execution of packet handler 2022-04-25 11:44:34 -04:00
Mikayla Fischler
0fc49d312d #32 parallel reactor PLC code 2022-04-25 11:40:53 -04:00
Mikayla Fischler
c46a7b2486 added time functions to util, also task_wait 2022-04-25 10:36:47 -04:00
Mikayla Fischler
1744527a41 ISS cleanup 2022-04-25 10:34:41 -04:00
Mikayla Fischler
074f6448e1 some supervisor bugfixes 2022-04-24 13:22:45 -04:00
Mikayla Fischler
74168707c6 PLC clock timing fix 2022-04-24 13:21:55 -04:00
Mikayla Fischler
86b0d155fa #31 PPM cannot assume that we will get a fault on failure, apparently sometimes we will only get a nil return so the system can no longer check ACCESS_OK, now each device has its own fault tracking 2022-04-24 12:04:31 -04:00
Mikayla Fischler
416255f41a PLC check sequence numbers, corrected trip time to ms 2022-04-23 21:10:25 -04:00
Mikayla Fischler
fa19af308d bugfix and use timestamp in packet 2022-04-23 20:46:01 -04:00
Mikayla Fischler
852161317d #7 initial PLC session supervisor code 2022-04-23 12:12:33 -04:00
Mikayla Fischler
3285f829f6 updated version for using epoch() 2022-04-23 11:54:52 -04:00
Mikayla Fischler
812d10f374 use epoch() instead of time() 2022-04-23 11:54:09 -04:00
Mikayla Fischler
cd289ffb1e #30 svsessions PLC comms code updated for new comms design 2022-04-22 21:55:26 -04:00
Mikayla Fischler
89ff502964 #30 supervisor comms code updated for new comms design 2022-04-22 21:44:33 -04:00
Mikayla Fischler
b25d95eeb7 #30 PLC comms code updated for new comms design 2022-04-22 21:39:03 -04:00
Mikayla Fischler
554f09c817 #30 RTU comms code updated for new comms design 2022-04-22 20:23:40 -04:00
Mikayla Fischler
912011bfed #30 modbus comms changes 2022-04-22 20:21:28 -04:00
Mikayla Fischler
78a1073e2a #30 comms rework 2022-04-22 15:43:25 -04:00
Mikayla Fischler
6daf6df2d0 active-backup supervisor setups are no longer planned 2022-04-22 11:15:16 -04:00
Mikayla Fischler
1bf0d352a1 supervisor sessions work in progress 2022-04-22 11:07:59 -04:00
Mikayla Fischler
17d0213d58 RTU/PPM bugfixes 2022-04-22 10:58:18 -04:00
Mikayla Fischler
f7c11febe5 check if interface exists before trying to get its device or type 2022-04-22 10:21:15 -04:00
Mikayla Fischler
991c855c11 message queue 2022-04-21 12:44:46 -04:00
Mikayla Fischler
b10a8d9479 send ISS status automatically along with PLC status 2022-04-21 12:40:21 -04:00
Mikayla Fischler
0c132f6e43 todo comment format 2022-04-21 10:44:43 -04:00
Mikayla Fischler
4842f9cb0d moved packet constructors and fixes to comms namespace references in plc comms code 2022-04-21 10:26:02 -04:00
Mikayla Fischler
04f8dc7d75 readme update about coordinator 2022-04-21 10:17:14 -04:00
Mikayla Fischler
3da7b74cfb initial base supervisor code 2022-04-18 11:07:16 -04:00
Mikayla Fischler
b89724ad59 version updates 2022-04-18 10:49:05 -04:00
Mikayla Fischler
a3920ec2d8 Merge pull request #16 from MikaylaFischler/devel
Alpha PLC & RTU Code
2022-04-18 10:35:15 -04:00
Mikayla Fischler
6a5e0243be catch terminations that are caught by PPM 2022-04-18 10:31:24 -04:00
Mikayla Fischler
91079eeb78 fixed RTU comms bad function calls, fixed loop clock, changed terminate logic/prints 2022-04-18 10:21:29 -04:00
Mikayla Fischler
2278469a8b refactored RTU devices 2022-04-18 10:09:44 -04:00
Mikayla Fischler
377cf8e6fc scope fixes, load comms api, debug prints 2022-04-18 09:35:08 -04:00
Mikayla Fischler
7d9a664d38 rsio bugfixes 2022-04-18 00:11:23 -04:00
Mikayla Fischler
a6e1134dc3 changed modbus init function name, fixed bugs with RTU startup, improved PPM debug prints 2022-04-18 00:10:47 -04:00
Mikayla Fischler
6d6953d795 RTU device inits now correctly use rtu.rtu_init() not rtu_init() 2022-04-17 22:37:09 -04:00
Mikayla Fischler
0c5eb77cba fixed some bugs with RTU startup referencing external data/functions 2022-04-17 22:36:18 -04:00
Mikayla Fischler
ba5975f29b RTU config fixed missing rsio reference 2022-04-17 22:34:49 -04:00
Mikayla Fischler
2a21d7d0be #14, #15 ppm access fault handling, report modbus exceptions, handle ppm faults in PLC/RTU code 2022-04-17 21:12:25 -04:00
Mikayla Fischler
945b761fc2 #2 RTU handle disconnects/reconnects 2022-04-11 17:27:57 -04:00
Mikayla Fischler
203d868aeb RTU print fixes, config fixes, comms init fixes and moved modem open 2022-04-11 11:08:46 -04:00
Mikayla Fischler
28b1c03e03 upped version 2022-04-07 11:45:01 -04:00
Mikayla Fischler
b085baf91b #12 specifically get wireless modems 2022-04-07 11:44:17 -04:00
Mikayla Fischler
03f9284f30 #13 ISS tolerant of failed PPM calls, added comments 2022-04-07 11:12:41 -04:00
Mikayla Fischler
7e7e98ff6b #11 standalone de-asserts SCRAM and resets ISS before check, added prints to ISS, fixed non-networked mode related bugs, cleaned up ISS check call in startup 2022-04-05 17:58:23 -04:00
Mikayla Fischler
ba1dd1b50e #4 PLC degraded start and reconnects appear to be working now, fixed prints, and bugfixes to PPM 2022-04-05 17:29:27 -04:00
Mikayla Fischler
895750ea14 print, println, println_ts 2022-04-05 17:28:19 -04:00
Mikayla Fischler
c47f45ea46 fixed bad function references in ISS 2022-04-05 17:25:56 -04:00
Mikayla Fischler
f24b214229 fixed bugs and removed goto as lua 5.1 does not have goto 2022-04-05 16:09:29 -04:00
Mikayla Fischler
5b32f83890 writeLine has newline of course.. 2022-04-05 16:08:55 -04:00
Mikayla Fischler
dbf7377c02 #11 configurable 'networked' setting for PLCs that allows for standalone ISS-only mode 2022-04-05 15:56:48 -04:00
Mikayla Fischler
13b0fcf65f PLC state code cleanup and bugfixes 2022-04-05 09:41:06 -04:00
Mikayla Fischler
02763c9cb3 #4 PLC peripheral disconnect handling and small bugfixes/cleanup 2022-04-03 12:08:22 -04:00
Mikayla Fischler
34fc625602 #5 finished implementing PLC packet handler, bugfixes 2022-04-02 14:43:36 -04:00
Mikayla Fischler
ed997d53e1 #6 PLC retry SCRAM until reactor confirms unpowered 2022-04-02 11:46:14 -04:00
Mikayla Fischler
7c2d89e70f allow suppressing of PPM errors 2022-04-02 11:45:43 -04:00
Mikayla Fischler
a77946ce2c #1 PLC does not shut down if failed link, repeatedly tries to maintain link as part of main loop 2022-04-02 11:22:44 -04:00
Mikayla Fischler
36fb4587a1 consistent packet constructors/receiving 2022-04-02 08:28:43 -04:00
Mikayla Fischler
013656bc4d supervisor code moved around 2022-03-25 12:18:33 -04:00
Mikayla Fischler
5eaeb50000 broke up comms file, setup base coordinator code 2022-03-25 12:17:46 -04:00
Mikayla Fischler
2ee503946c plc cleanup, removed old code 2022-03-25 11:50:03 -04:00
Mikayla Fischler
be73b17d46 RTU linking and requesting advertisement 2022-03-23 16:17:58 -04:00
Mikayla Fischler
60674ec95c RTU startup code and comms 2022-03-23 15:41:08 -04:00
Mikayla Fischler
74ae57324b redstone I/O rework 2022-03-23 15:36:14 -04:00
Mikayla Fischler
1e23a2fd67 work on RTU startup and comms 2022-03-15 12:02:31 -04:00
Mikayla Fischler
5642e3283d fixes to modbus_packet() 2022-03-15 11:58:52 -04:00
Mikayla Fischler
6e1e4c4685 ppm includes get_type function now 2022-03-15 11:58:22 -04:00
Mikayla Fischler
a9d4458103 redstone I/O constants defined, digital I/O functions with active high/low mappings added 2022-03-15 11:58:08 -04:00
Mikayla Fischler
17874c4658 cleanup/improvements to PLC comms 2022-03-14 14:19:21 -04:00
Mikayla Fischler
ac4ca3e56e reactor plc utilizes ppm and is now changed to use pullEventRaw 2022-03-10 14:23:14 -05:00
Mikayla Fischler
5cff346cb5 ppm function renames, edited log messages, and changed protected calls to return true if function has no return 2022-03-10 14:21:03 -05:00
Mikayla Fischler
a0b2c1f3e2 changed ppm to not wrap under ppm() function 2022-03-10 14:12:07 -05:00
Mikayla Fischler
ea84563bb4 added protected peripheral manager and file system logger 2022-03-10 14:09:21 -05:00
Mikayla Fischler
3c67ee08a8 redstone RTU I/O 2022-02-08 15:42:06 -05:00
Mikayla Fischler
1c6244d235 README formatting 2022-01-25 17:07:42 -05:00
Mikayla Fischler
9cd0079d9e updated README 2022-01-25 15:48:01 -05:00
Mikayla Fischler
d6a68ee3d9 rtu's for boiler, induction matrix, and turbine 2022-01-25 14:51:33 -05:00
Mikayla Fischler
8429cbfd6e scada alarms 2022-01-25 13:51:43 -05:00
Mikayla Fischler
14cb7f96fc supervisor comms init 2022-01-22 14:47:54 -05:00
Mikayla Fischler
ffca88845b work on PLC comms 2022-01-22 14:26:25 -05:00
Mikayla Fischler
c6722c4cbe updated README for repo rename 2022-01-14 16:34:40 -05:00
Mikayla Fischler
b3a2cfabc6 reactor plc reorganization and some comms updates 2022-01-14 16:33:09 -05:00
Mikayla Fischler
018b228976 some comms cleanup and added wrapper for generic packet 2022-01-14 16:32:20 -05:00
Mikayla Fischler
00a81ab4f0 modbus comms implementation 2022-01-14 12:42:11 -05:00
Mikayla Fischler
e47b4d7959 placeholders for pocket computer access in the future 2022-01-13 10:23:56 -05:00
Mikayla Fischler
4dfdb218e2 SCADA supervisor code started 2022-01-13 10:23:38 -05:00
Mikayla Fischler
78cbb9e67d RTU object and started modbus 2022-01-13 10:12:44 -05:00
Mikayla Fischler
c78db71b14 comms and util files 2022-01-13 10:11:42 -05:00
Mikayla Fischler
3b492ead92 changed to SCADA terminology, changed RCaSS to reactor PLC, maybe changed other things 2022-01-13 10:06:55 -05:00
Mikayla Fischler
ab49322fec archive old controller 2022-01-01 21:01:05 -05:00
Mikayla Fischler
26cce3a46a reactor control and safety system attempting server connection 2022-01-01 19:45:33 -05:00
Mikayla Fischler
0dac25d9e7 reorganization 2021-12-28 21:46:38 -05:00
58 changed files with 9310 additions and 1006 deletions

13
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,13 @@
{
"Lua.diagnostics.globals": [
"term",
"fs",
"peripheral",
"rs",
"bit",
"parallel",
"colors",
"textutils",
"shell"
]
}

View File

@@ -1,2 +1,61 @@
# cc-mek-reactor-controller
Configurable ComputerCraft multi-reactor control for Mekanism with a GUI, automatic safety features, waste processing control, and more!
# 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).
## [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.
![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)
SCADA and industrial automation terminology is used throughout the project, such as:
- Supervisory Computer: Gathers data and controls the process
- Coordinating Computer: Used as the HMI component, user requests high-level processing operations
- RTU: Remote Terminal Unit
- PLC: Programmable Logic Controller
## ComputerCraft Architecture
### Coordinator Server
There can only be one of these. This server acts as a hybrid of levels 3 & 4 in the SCADA diagram above. In addition to viewing status and controlling processes with advanced monitors, it can host access for one or more Pocket computers.
### Supervisory Computers
There should be one of these per facility system. Currently, that means only one. In the future, multiple supervisors would provide the capability of coordinating between multiple facilities (like a fission facility, fusion facility, etc).
### RTUs
RTUs are effectively basic connections between a device and the SCADA system with no internal logic providing the system with I/O capabilities. A single Advanced Computer can represent multiple RTUs as instead I am modeling an RTU as the wired modems connected to that computer rather than the computer itself. Each RTU is referenced separately with an identifier in the modbus communications (see Communications section), so a single computer can distribute instructions to multiple devices. This should save on having a pile of computers everywhere (but if you want to have that, no one's stopping you).
The RTU control code is relatively unique, as instead of having instructions be decoded simply, due to using modbus, I implemented a generalized RTU interface. To fulfill this, each type of I/O operation is linked to a function rather than implementing the logic itself. For example, to connect an input register to a turbine `getFlowRate()` call, the function reference itself is passed to the `connect_input_reg()` function. A call to `read_input_reg()` on that register address will call the `turbine.getFlowRate()` function and return the result.
### 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.
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.
## Communications
A vaguely-modbus [modbus](https://en.wikipedia.org/wiki/Modbus) communication protocol is used for communication with RTUs. Useful terminology for you to know:
- Discrete Inputs: Single Bit Read-Only (digital inputs)
- Coils: Single Bit Read/Write (digital I/O)
- Input Registers: Multi-Byte Read-Only (analog inputs)
- Holding Registers: Multi-Byte Read/Write (analog I/O)
### Security and Encryption
TBD, I am planning on AES symmetric encryption for security + HMAC to prevent replay attacks. This will be done utilizing this codebase: https://github.com/somesocks/lua-lockbox.
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.
## 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.

View File

@@ -0,0 +1,12 @@
local comms = require("scada-common.comms")
local coordinator = {}
-- coordinator communications
coordinator.coord_comms = function ()
local self = {
reactor_struct_cache = nil
}
end
return coordinator

37
coordinator/startup.lua Normal file
View File

@@ -0,0 +1,37 @@
--
-- Nuclear Generation Facility SCADA Coordinator
--
require("/initenv").init_env()
local log = require("scada-common.log")
local ppm = require("scada-common.ppm")
local util = require("scada-common.util")
local config = require("coordinator.config")
local coordinator = require("coordinator.coordinator")
local COORDINATOR_VERSION = "alpha-v0.1.2"
local print = util.print
local println = util.println
local print_ts = util.print_ts
local println_ts = util.println_ts
log.init("/log.txt", log.MODE.APPEND)
log.info("========================================")
log.info("BOOTING coordinator.startup " .. COORDINATOR_VERSION)
log.info("========================================")
println(">> SCADA Coordinator " .. COORDINATOR_VERSION .. " <<")
-- mount connected devices
ppm.mount_all()
local modem = ppm.get_wireless_modem()
-- we need a modem
if modem == nil then
println("please connect a wireless modem")
return
end

18
initenv.lua Normal file
View File

@@ -0,0 +1,18 @@
--
-- Initialize the Post-Boot Module Environment
--
-- initialize booted environment
local init_env = function ()
local _require = require("cc.require")
local _env = setmetatable({}, { __index = _ENV })
-- overwrite require/package globals
require, package = _require.make(_env, "/")
-- reset terminal
term.clear()
term.setCursorPos(1, 1)
end
return { init_env = init_env }

View File

@@ -1,135 +0,0 @@
-- mekanism reactor controller
-- monitors and regulates mekanism reactors
os.loadAPI("reactor.lua")
os.loadAPI("defs.lua")
os.loadAPI("log.lua")
os.loadAPI("render.lua")
os.loadAPI("server.lua")
os.loadAPI("regulator.lua")
-- constants, aliases, properties
local header = "MEKANISM REACTOR CONTROLLER - v" .. defs.CTRL_VERSION
local monitor_0 = peripheral.wrap(defs.MONITOR_0)
local monitor_1 = peripheral.wrap(defs.MONITOR_1)
local monitor_2 = peripheral.wrap(defs.MONITOR_2)
local monitor_3 = peripheral.wrap(defs.MONITOR_3)
monitor_0.setBackgroundColor(colors.black)
monitor_0.setTextColor(colors.white)
monitor_0.clear()
monitor_1.setBackgroundColor(colors.black)
monitor_1.setTextColor(colors.white)
monitor_1.clear()
monitor_2.setBackgroundColor(colors.black)
monitor_2.setTextColor(colors.white)
monitor_2.clear()
log.init(monitor_3)
local main_w, main_h = monitor_0.getSize()
local view = window.create(monitor_0, 1, 1, main_w, main_h)
view.setBackgroundColor(colors.black)
view.clear()
local stat_w, stat_h = monitor_1.getSize()
local stat_view = window.create(monitor_1, 1, 1, stat_w, stat_h)
stat_view.setBackgroundColor(colors.black)
stat_view.clear()
local reactors = {
reactor.create(1, view, stat_view, 62, 3, 63, 2),
reactor.create(2, view, stat_view, 42, 3, 43, 2),
reactor.create(3, view, stat_view, 22, 3, 23, 2),
reactor.create(4, view, stat_view, 2, 3, 3, 2)
}
print("[debug] reactor tables created")
server.init(reactors)
print("[debug] modem server started")
regulator.init(reactors)
print("[debug] regulator started")
-- header
view.setBackgroundColor(colors.white)
view.setTextColor(colors.black)
view.setCursorPos(1, 1)
local header_pad_x = (main_w - string.len(header)) / 2
view.write(string.rep(" ", header_pad_x) .. header .. string.rep(" ", header_pad_x))
-- inital draw of each reactor
for key, rctr in pairs(reactors) do
render.draw_reactor_system(rctr)
render.draw_reactor_status(rctr)
end
-- inital draw of clock
monitor_2.setTextScale(2)
monitor_2.setCursorPos(1, 1)
monitor_2.write(os.date("%Y/%m/%d %H:%M:%S"))
local clock_update_timer = os.startTimer(1)
while true do
event, param1, param2, param3, param4, param5 = os.pullEvent()
if event == "redstone" then
-- redstone state change
regulator.handle_redstone()
elseif event == "modem_message" then
-- received signal router packet
packet = {
side = param1,
sender = param2,
reply = param3,
message = param4,
distance = param5
}
server.handle_message(packet, reactors)
elseif event == "monitor_touch" then
if param1 == "monitor_5" then
local tap_x = param2
local tap_y = param3
for key, rctr in pairs(reactors) do
if tap_x >= rctr.render.stat_x and tap_x <= (rctr.render.stat_x + 15) then
local old_val = rctr.waste_production
-- width in range
if tap_y == (rctr.render.stat_y + 12) then
rctr.waste_production = "plutonium"
elseif tap_y == (rctr.render.stat_y + 14) then
rctr.waste_production = "polonium"
elseif tap_y == (rctr.render.stat_y + 16) then
rctr.waste_production = "antimatter"
end
-- notify reactor of changes
if old_val ~= rctr.waste_production then
server.send(rctr.id, rctr.waste_production)
end
end
end
end
elseif event == "timer" then
-- update the clock about every second
monitor_2.setCursorPos(1, 1)
monitor_2.write(os.date("%Y/%m/%d %H:%M:%S"))
clock_update_timer = os.startTimer(1)
-- send keep-alive
server.broadcast(1)
end
-- update reactor display
for key, rctr in pairs(reactors) do
render.draw_reactor_system(rctr)
render.draw_reactor_status(rctr)
end
-- update system status monitor
render.update_system_monitor(monitor_2, regulator.is_scrammed(), reactors)
end

View File

@@ -1,23 +0,0 @@
-- configuration definitions
CTRL_VERSION = "0.7"
-- monitors
MONITOR_0 = "monitor_6"
MONITOR_1 = "monitor_5"
MONITOR_2 = "monitor_7"
MONITOR_3 = "monitor_8"
-- modem server
LISTEN_PORT = 1000
-- regulator (should match the number of reactors present)
BUNDLE_DEF = { colors.red, colors.orange, colors.yellow, colors.lime }
-- stats calculation
REACTOR_MB_T = 39
TURBINE_MRF_T = 3.114
PLUTONIUM_PER_WASTE = 0.1
POLONIUM_PER_WASTE = 0.1
SPENT_PER_BYPRODUCT = 1
ANTIMATTER_PER_POLONIUM = 0.001

View File

@@ -1,52 +0,0 @@
os.loadAPI("defs.lua")
local out, out_w, out_h
local output_full = false
-- initialize the logger to the given monitor
-- monitor: monitor to write to (in addition to calling print())
function init(monitor)
out = monitor
out_w, out_h = out.getSize()
out.clear()
out.setTextColor(colors.white)
out.setBackgroundColor(colors.black)
out.setCursorPos(1, 1)
out.write("version " .. defs.CTRL_VERSION)
out.setCursorPos(1, 2)
out.write("system startup at " .. os.date("%Y/%m/%d %H:%M:%S"))
print("server v" .. defs.CTRL_VERSION .. " started at " .. os.date("%Y/%m/%d %H:%M:%S"))
end
-- write a log message to the log screen and console
-- msg: message to write
-- color: (optional) color to print in, defaults to white
function write(msg, color)
color = color or colors.white
local _x, _y = out.getCursorPos()
if output_full then
out.scroll(1)
out.setCursorPos(1, _y)
else
if _y == out_h then
output_full = true
out.scroll(1)
out.setCursorPos(1, _y)
else
out.setCursorPos(1, _y + 1)
end
end
-- output to screen
out.setTextColor(colors.lightGray)
out.write(os.date("[%H:%M:%S] "))
out.setTextColor(color)
out.write(msg)
-- output to console
print(os.date("[%H:%M:%S] ") .. msg)
end

View File

@@ -1,28 +0,0 @@
-- create a new reactor 'object'
-- reactor_id: the ID for this reactor
-- main_view: the parent window/monitor for the main display (components)
-- status_view: the parent window/monitor for the status display
-- main_x: where to create the main window, x coordinate
-- main_y: where to create the main window, y coordinate
-- status_x: where to create the status window, x coordinate
-- status_y: where to create the status window, y coordinate
function create(reactor_id, main_view, status_view, main_x, main_y, status_x, status_y)
return {
id = reactor_id,
render = {
win_main = window.create(main_view, main_x, main_y, 20, 60, true),
win_stat = window.create(status_view, status_x, status_y, 20, 20, true),
stat_x = status_x,
stat_y = status_y
},
control_state = false,
waste_production = "antimatter", -- "plutonium", "polonium", "antimatter"
state = {
run = false,
no_fuel = false,
full_waste = false,
high_temp = false,
damage_crit = false
}
}
end

View File

@@ -1,128 +0,0 @@
os.loadAPI("defs.lua")
os.loadAPI("log.lua")
os.loadAPI("server.lua")
local reactors
local scrammed
local auto_scram
-- initialize the system regulator which provides safety measures, SCRAM functionality, and handles redstone
-- _reactors: reactor table
function init(_reactors)
reactors = _reactors
scrammed = false
auto_scram = false
-- scram all reactors
server.broadcast(false, reactors)
-- check initial states
regulator.handle_redstone()
end
-- check if the system is scrammed
function is_scrammed()
return scrammed
end
-- handle redstone state changes
function handle_redstone()
-- check scram button
if not rs.getInput("right") then
if not scrammed then
log.write("user SCRAM", colors.red)
scram()
end
-- toggling scram will release auto scram state
auto_scram = false
else
scrammed = false
end
-- check individual control buttons
local input = rs.getBundledInput("left")
for key, rctr in pairs(reactors) do
if colors.test(input, defs.BUNDLE_DEF[key]) ~= rctr.control_state then
-- state changed
rctr.control_state = colors.test(input, defs.BUNDLE_DEF[key])
if not scrammed then
local safe = true
if rctr.control_state then
safe = check_enable_safety(reactors[key])
if safe then
log.write("reactor " .. reactors[key].id .. " enabled", colors.lime)
end
else
log.write("reactor " .. reactors[key].id .. " disabled", colors.cyan)
end
-- start/stop reactor
if safe then
server.send(rctr.id, rctr.control_state)
end
elseif colors.test(input, defs.BUNDLE_DEF[key]) then
log.write("scrammed: state locked off", colors.yellow)
end
end
end
end
-- make sure enabling the provided reactor is safe
-- reactor: reactor to check
function check_enable_safety(reactor)
if reactor.state.no_fuel or reactor.state.full_waste or reactor.state.high_temp or reactor.state.damage_crit then
log.write("RCT-" .. reactor.id .. ": unsafe enable denied", colors.yellow)
return false
else
return true
end
end
-- make sure no running reactors are in a bad state
function enforce_safeties()
for key, reactor in pairs(reactors) do
local overridden = false
local state = reactor.state
-- check for problems
if state.damage_crit and state.run then
reactor.control_state = false
log.write("RCT-" .. reactor.id .. ": shut down (damage)", colors.yellow)
-- scram all, so ignore setting overridden
log.write("auto SCRAM all reactors", colors.red)
auto_scram = true
scram()
elseif state.high_temp and state.run then
reactor.control_state = false
overridden = true
log.write("RCT-" .. reactor.id .. ": shut down (temp)", colors.yellow)
elseif state.full_waste and state.run then
reactor.control_state = false
overridden = true
log.write("RCT-" .. reactor.id .. ": shut down (waste)", colors.yellow)
elseif state.no_fuel and state.run then
reactor.control_state = false
overridden = true
log.write("RCT-" .. reactor.id .. ": shut down (fuel)", colors.yellow)
end
if overridden then
server.send(reactor.id, false)
end
end
end
-- shut down all reactors and prevent enabling them until the scram button is toggled/released
function scram()
scrammed = true
server.broadcast(false, reactors)
for key, rctr in pairs(reactors) do
if rctr.control_state then
log.write("reactor " .. reactors[key].id .. " disabled", colors.cyan)
end
end
end

View File

@@ -1,370 +0,0 @@
os.loadAPI("defs.lua")
-- draw pipes between machines
-- win: window to render in
-- x: starting x coord
-- y: starting y coord
-- spacing: spacing between the pipes
-- color_out: output pipe contents color
-- color_ret: return pipe contents color
-- tick: tick the pipes for an animation
function draw_pipe(win, x, y, spacing, color_out, color_ret, tick)
local _color
local _off
tick = tick or 0
for i = 0, 4, 1
do
_off = (i + tick) % 2 == 0 or (tick == 1 and i == 0) or (tick == 3 and i == 4)
if _off then
_color = colors.lightGray
else
_color = color_out
end
win.setBackgroundColor(_color)
win.setCursorPos(x, y + i)
win.write(" ")
if not _off then
_color = color_ret
end
win.setBackgroundColor(_color)
win.setCursorPos(x + spacing, y + i)
win.write(" ")
end
end
-- draw a reactor view consisting of the reactor, boiler, turbine, and pipes
-- data: reactor table
function draw_reactor_system(data)
local win = data.render.win_main
local win_w, win_h = win.getSize()
win.setBackgroundColor(colors.black)
win.setTextColor(colors.black)
win.clear()
win.setCursorPos(1, 1)
-- draw header --
local header = "REACTOR " .. data.id
local header_pad_x = (win_w - string.len(header) - 2) / 2
local header_color
if data.state.no_fuel then
if data.state.run then
header_color = colors.purple
else
header_color = colors.brown
end
elseif data.state.full_waste then
header_color = colors.yellow
elseif data.state.high_temp then
header_color = colors.orange
elseif data.state.damage_crit then
header_color = colors.red
elseif data.state.run then
header_color = colors.green
else
header_color = colors.lightGray
end
local running = data.state.run and not data.state.no_fuel
win.write(" ")
win.setBackgroundColor(header_color)
win.write(string.rep(" ", win_w - 2))
win.setBackgroundColor(colors.black)
win.write(" ")
win.setCursorPos(1, 2)
win.write(" ")
win.setBackgroundColor(header_color)
win.write(string.rep(" ", header_pad_x) .. header .. string.rep(" ", header_pad_x))
win.setBackgroundColor(colors.black)
win.write(" ")
-- create strings for use in blit
local line_text = string.rep(" ", 14)
local line_text_color = string.rep("0", 14)
-- draw components --
-- draw reactor
local rod = "88"
if data.state.high_temp then
rod = "11"
elseif running then
rod = "99"
end
win.setCursorPos(4, 4)
win.setBackgroundColor(colors.gray)
win.write(line_text)
win.setCursorPos(4, 5)
win.blit(line_text, line_text_color, "77" .. rod .. "77" .. rod .. "77" .. rod .. "77")
win.setCursorPos(4, 6)
win.blit(line_text, line_text_color, "7777" .. rod .. "77" .. rod .. "7777")
win.setCursorPos(4, 7)
win.blit(line_text, line_text_color, "77" .. rod .. "77" .. rod .. "77" .. rod .. "77")
win.setCursorPos(4, 8)
win.blit(line_text, line_text_color, "7777" .. rod .. "77" .. rod .. "7777")
win.setCursorPos(4, 9)
win.blit(line_text, line_text_color, "77" .. rod .. "77" .. rod .. "77" .. rod .. "77")
win.setCursorPos(4, 10)
win.write(line_text)
-- boiler
local steam = "ffffffffff"
if running then
steam = "0000000000"
end
win.setCursorPos(4, 16)
win.setBackgroundColor(colors.gray)
win.write(line_text)
win.setCursorPos(4, 17)
win.blit(line_text, line_text_color, "77" .. steam .. "77")
win.setCursorPos(4, 18)
win.blit(line_text, line_text_color, "77" .. steam .. "77")
win.setCursorPos(4, 19)
win.blit(line_text, line_text_color, "77888888888877")
win.setCursorPos(4, 20)
win.blit(line_text, line_text_color, "77bbbbbbbbbb77")
win.setCursorPos(4, 21)
win.blit(line_text, line_text_color, "77bbbbbbbbbb77")
win.setCursorPos(4, 22)
win.blit(line_text, line_text_color, "77bbbbbbbbbb77")
win.setCursorPos(4, 23)
win.setBackgroundColor(colors.gray)
win.write(line_text)
-- turbine
win.setCursorPos(4, 29)
win.setBackgroundColor(colors.gray)
win.write(line_text)
win.setCursorPos(4, 30)
if running then
win.blit(line_text, line_text_color, "77000000000077")
else
win.blit(line_text, line_text_color, "77ffffffffff77")
end
win.setCursorPos(4, 31)
if running then
win.blit(line_text, line_text_color, "77008000080077")
else
win.blit(line_text, line_text_color, "77ff8ffff8ff77")
end
win.setCursorPos(4, 32)
if running then
win.blit(line_text, line_text_color, "77000800800077")
else
win.blit(line_text, line_text_color, "77fff8ff8fff77")
end
win.setCursorPos(4, 33)
if running then
win.blit(line_text, line_text_color, "77000088000077")
else
win.blit(line_text, line_text_color, "77ffff88ffff77")
end
win.setCursorPos(4, 34)
if running then
win.blit(line_text, line_text_color, "77000800800077")
else
win.blit(line_text, line_text_color, "77fff8ff8fff77")
end
win.setCursorPos(4, 35)
if running then
win.blit(line_text, line_text_color, "77008000080077")
else
win.blit(line_text, line_text_color, "77ff8ffff8ff77")
end
win.setCursorPos(4, 36)
if running then
win.blit(line_text, line_text_color, "77000000000077")
else
win.blit(line_text, line_text_color, "77ffffffffff77")
end
win.setCursorPos(4, 37)
win.setBackgroundColor(colors.gray)
win.write(line_text)
-- draw reactor coolant pipes
draw_pipe(win, 7, 11, 6, colors.orange, colors.lightBlue)
-- draw turbine pipes
draw_pipe(win, 7, 24, 6, colors.white, colors.blue)
end
-- draw the reactor statuses on the status screen
-- data: reactor table
function draw_reactor_status(data)
local win = data.render.win_stat
win.setBackgroundColor(colors.black)
win.setTextColor(colors.white)
win.clear()
-- show control state
win.setCursorPos(1, 1)
if data.control_state then
win.blit(" + ENABLED", "00000000000", "dddffffffff")
else
win.blit(" - DISABLED", "000000000000", "eeefffffffff")
end
-- show run state
win.setCursorPos(1, 2)
if data.state.run then
win.blit(" + RUNNING", "00000000000", "dddffffffff")
else
win.blit(" - STOPPED", "00000000000", "888ffffffff")
end
-- show fuel state
win.setCursorPos(1, 4)
if data.state.no_fuel then
win.blit(" - NO FUEL", "00000000000", "eeeffffffff")
else
win.blit(" + FUEL OK", "00000000000", "999ffffffff")
end
-- show waste state
win.setCursorPos(1, 5)
if data.state.full_waste then
win.blit(" - WASTE FULL", "00000000000000", "eeefffffffffff")
else
win.blit(" + WASTE OK", "000000000000", "999fffffffff")
end
-- show high temp state
win.setCursorPos(1, 6)
if data.state.high_temp then
win.blit(" - HIGH TEMP", "0000000000000", "eeeffffffffff")
else
win.blit(" + TEMP OK", "00000000000", "999ffffffff")
end
-- show damage state
win.setCursorPos(1, 7)
if data.state.damage_crit then
win.blit(" - CRITICAL DAMAGE", "0000000000000000000", "eeeffffffffffffffff")
else
win.blit(" + CASING INTACT", "00000000000000000", "999ffffffffffffff")
end
-- waste processing options --
win.setTextColor(colors.black)
win.setBackgroundColor(colors.white)
win.setCursorPos(1, 10)
win.write(" ")
win.setCursorPos(1, 11)
win.write(" WASTE OUTPUT ")
win.setCursorPos(1, 13)
win.setBackgroundColor(colors.cyan)
if data.waste_production == "plutonium" then
win.write(" > plutonium ")
else
win.write(" plutonium ")
end
win.setCursorPos(1, 15)
win.setBackgroundColor(colors.green)
if data.waste_production == "polonium" then
win.write(" > polonium ")
else
win.write(" polonium ")
end
win.setCursorPos(1, 17)
win.setBackgroundColor(colors.purple)
if data.waste_production == "antimatter" then
win.write(" > antimatter ")
else
win.write(" antimatter ")
end
end
-- update the system monitor screen
-- mon: monitor to update
-- is_scrammed:
function update_system_monitor(mon, is_scrammed, reactors)
if is_scrammed then
-- display scram banner
mon.setTextColor(colors.white)
mon.setBackgroundColor(colors.black)
mon.setCursorPos(1, 2)
mon.clearLine()
mon.setBackgroundColor(colors.red)
mon.setCursorPos(1, 3)
mon.write(" ")
mon.setCursorPos(1, 4)
mon.write(" SCRAM ")
mon.setCursorPos(1, 5)
mon.write(" ")
mon.setBackgroundColor(colors.black)
mon.setCursorPos(1, 6)
mon.clearLine()
mon.setTextColor(colors.white)
else
-- clear where scram banner would be
mon.setCursorPos(1, 3)
mon.clearLine()
mon.setCursorPos(1, 4)
mon.clearLine()
mon.setCursorPos(1, 5)
mon.clearLine()
-- show production statistics--
local mrf_t = 0
local mb_t = 0
local plutonium = 0
local polonium = 0
local spent_waste = 0
local antimatter = 0
-- determine production values
for key, rctr in pairs(reactors) do
if rctr.state.run then
mrf_t = mrf_t + defs.TURBINE_MRF_T
mb_t = mb_t + defs.REACTOR_MB_T
if rctr.waste_production == "plutonium" then
plutonium = plutonium + (defs.REACTOR_MB_T * defs.PLUTONIUM_PER_WASTE)
spent_waste = spent_waste + (defs.REACTOR_MB_T * defs.PLUTONIUM_PER_WASTE * defs.SPENT_PER_BYPRODUCT)
elseif rctr.waste_production == "polonium" then
polonium = polonium + (defs.REACTOR_MB_T * defs.POLONIUM_PER_WASTE)
spent_waste = spent_waste + (defs.REACTOR_MB_T * defs.POLONIUM_PER_WASTE * defs.SPENT_PER_BYPRODUCT)
elseif rctr.waste_production == "antimatter" then
antimatter = antimatter + (defs.REACTOR_MB_T * defs.POLONIUM_PER_WASTE * defs.ANTIMATTER_PER_POLONIUM)
end
end
end
-- draw stats
mon.setTextColor(colors.lightGray)
mon.setCursorPos(1, 2)
mon.clearLine()
mon.write("ENERGY: " .. string.format("%0.2f", mrf_t) .. " MRF/t")
-- mon.setCursorPos(1, 3)
-- mon.clearLine()
-- mon.write("FUEL: " .. mb_t .. " mB/t")
mon.setCursorPos(1, 3)
mon.clearLine()
mon.write("Pu: " .. string.format("%0.2f", plutonium) .. " mB/t")
mon.setCursorPos(1, 4)
mon.clearLine()
mon.write("Po: " .. string.format("%0.2f", polonium) .. " mB/t")
mon.setCursorPos(1, 5)
mon.clearLine()
mon.write("SPENT: " .. string.format("%0.2f", spent_waste) .. " mB/t")
mon.setCursorPos(1, 6)
mon.clearLine()
mon.write("ANTI-M: " .. string.format("%0.2f", antimatter * 1000) .. " uB/t")
mon.setTextColor(colors.white)
end
end

View File

@@ -1,109 +0,0 @@
os.loadAPI("defs.lua")
os.loadAPI("log.lua")
os.loadAPI("regulator.lua")
local modem
local reactors
-- initalize the listener running on the wireless modem
-- _reactors: reactor table
function init(_reactors)
modem = peripheral.wrap("top")
reactors = _reactors
-- open listening port
if not modem.isOpen(defs.LISTEN_PORT) then
modem.open(defs.LISTEN_PORT)
end
-- send out a greeting to solicit responses for clients that are already running
broadcast(0, reactors)
end
-- handle an incoming message from the modem
-- packet: table containing message fields
function handle_message(packet)
if type(packet.message) == "number" then
-- this is a greeting
log.write("reactor " .. packet.message .. " connected", colors.green)
-- send current control command
for key, rctr in pairs(reactors) do
if rctr.id == packet.message then
send(rctr.id, rctr.control_state)
break
end
end
else
-- got reactor status
local eval_safety = false
for key, value in pairs(reactors) do
if value.id == packet.message.id then
local tag = "RCT-" .. value.id .. ": "
if value.state.run ~= packet.message.run then
value.state.run = packet.message.run
if value.state.run then
eval_safety = true
log.write(tag .. "running", colors.green)
end
end
if value.state.no_fuel ~= packet.message.no_fuel then
value.state.no_fuel = packet.message.no_fuel
if value.state.no_fuel then
eval_safety = true
log.write(tag .. "insufficient fuel", colors.gray)
end
end
if value.state.full_waste ~= packet.message.full_waste then
value.state.full_waste = packet.message.full_waste
if value.state.full_waste then
eval_safety = true
log.write(tag .. "waste tank full", colors.brown)
end
end
if value.state.high_temp ~= packet.message.high_temp then
value.state.high_temp = packet.message.high_temp
if value.state.high_temp then
eval_safety = true
log.write(tag .. "high temperature", colors.orange)
end
end
if value.state.damage_crit ~= packet.message.damage_crit then
value.state.damage_crit = packet.message.damage_crit
if value.state.damage_crit then
eval_safety = true
log.write(tag .. "critical damage", colors.red)
end
end
break
end
end
-- check to ensure safe operation
if eval_safety then
regulator.enforce_safeties()
end
end
end
-- send a message to a given reactor
-- dest: reactor ID
-- message: true or false for enable control or another value for other functionality, like 0 for greeting
function send(dest, message)
modem.transmit(dest + defs.LISTEN_PORT, defs.LISTEN_PORT, message)
end
-- broadcast a message to all reactors
-- message: true or false for enable control or another value for other functionality, like 0 for greeting
function broadcast(message)
for key, value in pairs(reactors) do
modem.transmit(value.id + defs.LISTEN_PORT, defs.LISTEN_PORT, message)
end
end

0
pocket/config.lua Normal file
View File

5
pocket/startup.lua Normal file
View File

@@ -0,0 +1,5 @@
--
-- SCADA Coordinator Access on a Pocket Computer
--
require("/initenv").init_env()

18
reactor-plc/config.lua Normal file
View File

@@ -0,0 +1,18 @@
local config = {}
-- set to false to run in offline mode (safety regulation only)
config.NETWORKED = true
-- unique reactor ID
config.REACTOR_ID = 1
-- port to send packets TO server
config.SERVER_PORT = 16000
-- port to listen to incoming packets FROM server
config.LISTEN_PORT = 14001
-- 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

769
reactor-plc/plc.lua Normal file
View File

@@ -0,0 +1,769 @@
local comms = require("scada-common.comms")
local log = require("scada-common.log")
local ppm = require("scada-common.ppm")
local types = require("scada-common.types")
local util = require("scada-common.util")
local plc = {}
local rps_status_t = types.rps_status_t
local PROTOCOLS = comms.PROTOCOLS
local RPLC_TYPES = comms.RPLC_TYPES
local RPLC_LINKING = comms.RPLC_LINKING
local SCADA_MGMT_TYPES = comms.SCADA_MGMT_TYPES
local print = util.print
local println = util.println
local print_ts = util.print_ts
local println_ts = util.println_ts
--- RPS: Reactor Protection System
---
--- identifies dangerous states and SCRAMs reactor if warranted
---
--- autonomous from main SCADA supervisor/coordinator control
plc.rps_init = function (reactor)
local state_keys = {
dmg_crit = 1,
high_temp = 2,
no_coolant = 3,
ex_waste = 4,
ex_hcoolant = 5,
no_fuel = 6,
fault = 7,
timeout = 8,
manual = 9
}
local self = {
reactor = reactor,
state = { false, false, false, false, false, false, false, false, false },
reactor_enabled = false,
tripped = false,
trip_cause = ""
}
---@class rps
local public = {}
-- PRIVATE FUNCTIONS --
-- set reactor access fault flag
local _set_fault = function ()
if self.reactor.__p_last_fault() ~= "Terminated" then
self.state[state_keys.fault] = true
end
end
-- clear reactor access fault flag
local _clear_fault = function ()
self.state[state_keys.fault] = false
end
-- check for critical damage
local _damage_critical = function ()
local damage_percent = self.reactor.getDamagePercent()
if damage_percent == ppm.ACCESS_FAULT then
-- lost the peripheral or terminated, handled later
log.error("RPS: failed to check reactor damage")
_set_fault()
self.state[state_keys.dmg_crit] = false
else
self.state[state_keys.dmg_crit] = damage_percent >= 100
end
end
-- check if the reactor is at a critically high temperature
local _high_temp = function ()
-- mekanism: MAX_DAMAGE_TEMPERATURE = 1_200
local temp = self.reactor.getTemperature()
if temp == ppm.ACCESS_FAULT then
-- lost the peripheral or terminated, handled later
log.error("RPS: failed to check reactor temperature")
_set_fault()
self.state[state_keys.high_temp] = false
else
self.state[state_keys.high_temp] = temp >= 1200
end
end
-- check if there is no coolant (<2% filled)
local _no_coolant = function ()
local coolant_filled = self.reactor.getCoolantFilledPercentage()
if coolant_filled == ppm.ACCESS_FAULT then
-- lost the peripheral or terminated, handled later
log.error("RPS: failed to check reactor coolant level")
_set_fault()
self.state[state_keys.no_coolant] = false
else
self.state[state_keys.no_coolant] = coolant_filled < 0.02
end
end
-- check for excess waste (>80% filled)
local _excess_waste = function ()
local w_filled = self.reactor.getWasteFilledPercentage()
if w_filled == ppm.ACCESS_FAULT then
-- lost the peripheral or terminated, handled later
log.error("RPS: failed to check reactor waste level")
_set_fault()
self.state[state_keys.ex_waste] = false
else
self.state[state_keys.ex_waste] = w_filled > 0.8
end
end
-- check for heated coolant backup (>95% filled)
local _excess_heated_coolant = function ()
local hc_filled = self.reactor.getHeatedCoolantFilledPercentage()
if hc_filled == ppm.ACCESS_FAULT then
-- lost the peripheral or terminated, handled later
log.error("RPS: failed to check reactor heated coolant level")
_set_fault()
self.state[state_keys.ex_hcoolant] = false
else
self.state[state_keys.ex_hcoolant] = hc_filled > 0.95
end
end
-- check if there is no fuel
local _insufficient_fuel = function ()
local fuel = self.reactor.getFuel()
if fuel == ppm.ACCESS_FAULT then
-- lost the peripheral or terminated, handled later
log.error("RPS: failed to check reactor fuel")
_set_fault()
self.state[state_keys.no_fuel] = false
else
self.state[state_keys.no_fuel] = fuel == 0
end
end
-- PUBLIC FUNCTIONS --
-- re-link a reactor after a peripheral re-connect
---@diagnostic disable-next-line: redefined-local
public.reconnect_reactor = function (reactor)
self.reactor = reactor
end
-- trip for lost peripheral
public.trip_fault = function ()
_set_fault()
end
-- trip for a PLC comms timeout
public.trip_timeout = function ()
self.state[state_keys.timeout] = true
end
-- manually SCRAM the reactor
public.trip_manual = function ()
self.state[state_keys.manual] = true
end
-- SCRAM the reactor now
---@return boolean success
public.scram = function ()
log.info("RPS: reactor SCRAM")
self.reactor.scram()
if self.reactor.__p_is_faulted() then
log.error("RPS: failed reactor SCRAM")
return false
else
self.reactor_enabled = false
return true
end
end
-- start the reactor
---@return boolean success
public.activate = function ()
if not self.tripped then
log.info("RPS: reactor start")
self.reactor.activate()
if self.reactor.__p_is_faulted() then
log.error("RPS: failed reactor start")
else
self.reactor_enabled = true
return true
end
end
return false
end
-- check all safety conditions
---@return boolean tripped, rps_status_t trip_status, boolean first_trip
public.check = function ()
local status = rps_status_t.ok
local was_tripped = self.tripped
local first_trip = false
-- update state
parallel.waitForAll(
_damage_critical,
_high_temp,
_no_coolant,
_excess_waste,
_excess_heated_coolant,
_insufficient_fuel
)
-- check system states in order of severity
if self.tripped then
status = self.trip_cause
elseif self.state[state_keys.dmg_crit] then
log.warning("RPS: damage critical")
status = rps_status_t.dmg_crit
elseif self.state[state_keys.high_temp] then
log.warning("RPS: high temperature")
status = rps_status_t.high_temp
elseif self.state[state_keys.no_coolant] then
log.warning("RPS: no coolant")
status = rps_status_t.no_coolant
elseif self.state[state_keys.ex_waste] then
log.warning("RPS: full waste")
status = rps_status_t.ex_waste
elseif self.state[state_keys.ex_hcoolant] then
log.warning("RPS: heated coolant backup")
status = rps_status_t.ex_hcoolant
elseif self.state[state_keys.no_fuel] then
log.warning("RPS: no fuel")
status = rps_status_t.no_fuel
elseif self.state[state_keys.fault] then
log.warning("RPS: reactor access fault")
status = rps_status_t.fault
elseif self.state[state_keys.timeout] then
log.warning("RPS: supervisor connection timeout")
status = rps_status_t.timeout
elseif self.state[state_keys.manual] then
log.warning("RPS: manual SCRAM requested")
status = rps_status_t.manual
else
self.tripped = false
end
-- if a new trip occured...
if (not was_tripped) and (status ~= rps_status_t.ok) then
first_trip = true
self.tripped = true
self.trip_cause = status
public.scram()
end
return self.tripped, status, first_trip
end
public.status = function () return self.state end
public.is_tripped = function () return self.tripped end
public.is_active = function () return self.reactor_enabled end
-- reset the RPS
public.reset = function ()
self.tripped = false
self.trip_cause = rps_status_t.ok
for i = 1, #self.state do
self.state[i] = false
end
end
return public
end
-- Reactor PLC Communications
---@param id integer
---@param version string
---@param modem table
---@param local_port integer
---@param server_port integer
---@param reactor table
---@param rps rps
---@param conn_watchdog watchdog
plc.comms = function (id, version, modem, local_port, server_port, reactor, rps, conn_watchdog)
local self = {
id = id,
version = version,
seq_num = 0,
r_seq_num = nil,
modem = modem,
s_port = server_port,
l_port = local_port,
reactor = reactor,
rps = rps,
conn_watchdog = conn_watchdog,
scrammed = false,
linked = false,
status_cache = nil,
max_burn_rate = nil
}
---@class plc_comms
local public = {}
-- open modem
if not self.modem.isOpen(self.l_port) then
self.modem.open(self.l_port)
end
-- PRIVATE FUNCTIONS --
-- send an RPLC packet
---@param msg_type RPLC_TYPES
---@param msg string
local _send = function (msg_type, msg)
local s_pkt = comms.scada_packet()
local r_pkt = comms.rplc_packet()
r_pkt.make(self.id, msg_type, msg)
s_pkt.make(self.seq_num, PROTOCOLS.RPLC, r_pkt.raw_sendable())
self.modem.transmit(self.s_port, self.l_port, s_pkt.raw_sendable())
self.seq_num = self.seq_num + 1
end
-- send a SCADA management packet
---@param msg_type SCADA_MGMT_TYPES
---@param msg string
local _send_mgmt = function (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, PROTOCOLS.SCADA_MGMT, m_pkt.raw_sendable())
self.modem.transmit(self.s_port, self.l_port, s_pkt.raw_sendable())
self.seq_num = self.seq_num + 1
end
-- variable reactor status information, excluding heating rate
---@return table data_table, boolean faulted
local _reactor_status = function ()
local coolant = nil
local hcoolant = nil
local data_table = {
false, -- getStatus
0, -- getBurnRate
0, -- getActualBurnRate
0, -- getTemperature
0, -- getDamagePercent
0, -- getBoilEfficiency
0, -- getEnvironmentalLoss
0, -- getFuel
0, -- getFuelFilledPercentage
0, -- getWaste
0, -- getWasteFilledPercentage
"", -- coolant_name
0, -- coolant_amnt
0, -- getCoolantFilledPercentage
"", -- hcoolant_name
0, -- hcoolant_amnt
0 -- getHeatedCoolantFilledPercentage
}
local tasks = {
function () data_table[1] = self.reactor.getStatus() end,
function () data_table[2] = self.reactor.getBurnRate() end,
function () data_table[3] = self.reactor.getActualBurnRate() end,
function () data_table[4] = self.reactor.getTemperature() end,
function () data_table[5] = self.reactor.getDamagePercent() end,
function () data_table[6] = self.reactor.getBoilEfficiency() end,
function () data_table[7] = self.reactor.getEnvironmentalLoss() end,
function () data_table[8] = self.reactor.getFuel() end,
function () data_table[9] = self.reactor.getFuelFilledPercentage() end,
function () data_table[10] = self.reactor.getWaste() end,
function () data_table[11] = self.reactor.getWasteFilledPercentage() end,
function () coolant = self.reactor.getCoolant() end,
function () data_table[14] = self.reactor.getCoolantFilledPercentage() end,
function () hcoolant = self.reactor.getHeatedCoolant() end,
function () data_table[17] = self.reactor.getHeatedCoolantFilledPercentage() end
}
parallel.waitForAll(table.unpack(tasks))
if coolant ~= nil then
data_table[12] = coolant.name
data_table[13] = coolant.amount
end
if hcoolant ~= nil then
data_table[15] = hcoolant.name
data_table[16] = hcoolant.amount
end
return data_table, self.reactor.__p_is_faulted()
end
-- update the status cache if changed
---@return boolean changed
local _update_status_cache = function ()
local status, faulted = _reactor_status()
local changed = false
if self.status_cache ~= nil then
if not faulted then
for i = 1, #status do
if status[i] ~= self.status_cache[i] then
changed = true
break
end
end
end
else
changed = true
end
if changed and not faulted then
self.status_cache = status
end
return changed
end
-- keep alive ack
---@param srv_time integer
local _send_keep_alive_ack = function (srv_time)
_send_mgmt(SCADA_MGMT_TYPES.KEEP_ALIVE, { srv_time, util.time() })
end
-- general ack
---@param msg_type RPLC_TYPES
---@param succeeded boolean
local _send_ack = function (msg_type, succeeded)
_send(msg_type, { succeeded })
end
-- send structure properties (these should not change, server will cache these)
local _send_struct = function ()
local mek_data = { 0, 0, 0, 0, 0, 0, 0, 0 }
local tasks = {
function () mek_data[1] = self.reactor.getHeatCapacity() end,
function () mek_data[2] = self.reactor.getFuelAssemblies() end,
function () mek_data[3] = self.reactor.getFuelSurfaceArea() end,
function () mek_data[4] = self.reactor.getFuelCapacity() end,
function () mek_data[5] = self.reactor.getWasteCapacity() end,
function () mek_data[6] = self.reactor.getCoolantCapacity() end,
function () mek_data[7] = self.reactor.getHeatedCoolantCapacity() end,
function () mek_data[8] = self.reactor.getMaxBurnRate() end
}
parallel.waitForAll(table.unpack(tasks))
if not self.reactor.__p_is_faulted() then
_send(RPLC_TYPES.MEK_STRUCT, mek_data)
else
log.error("failed to send structure: PPM fault")
end
end
-- PUBLIC FUNCTIONS --
-- reconnect a newly connected modem
---@param modem table
---@diagnostic disable-next-line: redefined-local
public.reconnect_modem = function (modem)
self.modem = modem
-- open modem
if not self.modem.isOpen(self.l_port) then
self.modem.open(self.l_port)
end
end
-- reconnect a newly connected reactor
---@param reactor table
---@diagnostic disable-next-line: redefined-local
public.reconnect_reactor = function (reactor)
self.reactor = reactor
self.status_cache = nil
end
-- unlink from the server
public.unlink = function ()
self.linked = false
self.r_seq_num = nil
self.status_cache = nil
end
-- close the connection to the server
public.close = function ()
self.conn_watchdog.cancel()
public.unlink()
_send_mgmt(SCADA_MGMT_TYPES.CLOSE, {})
end
-- attempt to establish link with supervisor
public.send_link_req = function ()
_send(RPLC_TYPES.LINK_REQ, { self.id, self.version })
end
-- send live status information
---@param degraded boolean
public.send_status = function (degraded)
if self.linked then
local mek_data = nil
if _update_status_cache() then
mek_data = self.status_cache
end
local sys_status = {
util.time(), -- timestamp
(not self.scrammed), -- requested control state
rps.is_tripped(), -- overridden
degraded, -- degraded
self.reactor.getHeatingRate(), -- heating rate
mek_data -- mekanism status data
}
if not self.reactor.__p_is_faulted() then
_send(RPLC_TYPES.STATUS, sys_status)
else
log.error("failed to send status: PPM fault")
end
end
end
-- send reactor protection system status
public.send_rps_status = function ()
if self.linked then
_send(RPLC_TYPES.RPS_STATUS, rps.status())
end
end
-- send reactor protection system alarm
---@param cause rps_status_t
public.send_rps_alarm = function (cause)
if self.linked then
local rps_alarm = {
cause,
table.unpack(rps.status())
}
_send(RPLC_TYPES.RPS_ALARM, rps_alarm)
end
end
-- parse an RPLC packet
---@param side string
---@param sender integer
---@param reply_to integer
---@param message any
---@param distance integer
---@return rplc_frame|mgmt_frame|nil packet
public.parse_packet = function(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 RPLC packet
if s_pkt.protocol() == PROTOCOLS.RPLC then
local rplc_pkt = comms.rplc_packet()
if rplc_pkt.decode(s_pkt) then
pkt = rplc_pkt.get()
end
-- get as SCADA management packet
elseif s_pkt.protocol() == PROTOCOLS.SCADA_MGMT then
local mgmt_pkt = comms.mgmt_packet()
if mgmt_pkt.decode(s_pkt) then
pkt = mgmt_pkt.get()
end
else
log.error("illegal packet type " .. s_pkt.protocol(), true)
end
end
return pkt
end
-- handle an RPLC packet
---@param packet rplc_frame|mgmt_frame
---@param plc_state plc_state
---@param setpoints setpoints
public.handle_packet = function (packet, plc_state, setpoints)
if packet ~= nil then
-- check sequence number
if self.r_seq_num == nil then
self.r_seq_num = packet.scada_frame.seq_num()
elseif self.linked and self.r_seq_num >= packet.scada_frame.seq_num() then
log.warning("sequence out-of-order: last = " .. self.r_seq_num .. ", new = " .. packet.scada_frame.seq_num())
return
else
self.r_seq_num = packet.scada_frame.seq_num()
end
-- feed the watchdog first so it doesn't uhh...eat our packets :)
self.conn_watchdog.feed()
local protocol = packet.scada_frame.protocol()
-- handle packet
if protocol == PROTOCOLS.RPLC then
if self.linked then
if packet.type == RPLC_TYPES.LINK_REQ then
-- link request confirmation
if packet.length == 1 then
log.debug("received unsolicited link request response")
local link_ack = packet.data[1]
if link_ack == RPLC_LINKING.ALLOW then
self.status_cache = nil
_send_struct()
public.send_status(plc_state.degraded)
log.debug("re-sent initial status data")
elseif link_ack == RPLC_LINKING.DENY then
println_ts("received unsolicited link denial, unlinking")
log.debug("unsolicited RPLC link request denied")
elseif link_ack == RPLC_LINKING.COLLISION then
println_ts("received unsolicited link collision, unlinking")
log.warning("unsolicited RPLC link request collision")
else
println_ts("invalid unsolicited link response")
log.error("unsolicited unknown RPLC link request response")
end
self.linked = link_ack == RPLC_LINKING.ALLOW
else
log.debug("RPLC link req packet length mismatch")
end
elseif packet.type == RPLC_TYPES.STATUS then
-- request of full status, clear cache first
self.status_cache = nil
public.send_status(plc_state.degraded)
log.debug("sent out status cache again, did supervisor miss it?")
elseif packet.type == RPLC_TYPES.MEK_STRUCT then
-- request for physical structure
_send_struct()
log.debug("sent out structure again, did supervisor miss it?")
elseif packet.type == RPLC_TYPES.MEK_BURN_RATE then
-- set the burn rate
if packet.length == 2 then
local success = false
local burn_rate = packet.data[1]
local ramp = packet.data[2]
-- if no known max burn rate, check again
if self.max_burn_rate == nil then
self.max_burn_rate = self.reactor.getMaxBurnRate()
end
-- if we know our max burn rate, update current burn rate setpoint if in range
if self.max_burn_rate ~= ppm.ACCESS_FAULT then
if burn_rate > 0 and burn_rate <= self.max_burn_rate then
if ramp then
setpoints.burn_rate_en = true
setpoints.burn_rate = burn_rate
success = true
else
self.reactor.setBurnRate(burn_rate)
success = not self.reactor.__p_is_faulted()
end
end
end
_send_ack(packet.type, success)
else
log.debug("RPLC set burn rate packet length mismatch")
end
elseif packet.type == RPLC_TYPES.RPS_ENABLE then
-- enable the reactor
self.scrammed = false
_send_ack(packet.type, self.rps.activate())
elseif packet.type == RPLC_TYPES.RPS_SCRAM then
-- disable the reactor
self.scrammed = true
self.rps.trip_manual()
_send_ack(packet.type, true)
elseif packet.type == RPLC_TYPES.RPS_RESET then
-- reset the RPS status
rps.reset()
_send_ack(packet.type, true)
else
log.warning("received unknown RPLC packet type " .. packet.type)
end
elseif packet.type == RPLC_TYPES.LINK_REQ then
-- link request confirmation
if packet.length == 1 then
local link_ack = packet.data[1]
if link_ack == RPLC_LINKING.ALLOW then
println_ts("linked!")
log.debug("RPLC link request approved")
-- reset remote sequence number and cache
self.r_seq_num = nil
self.status_cache = nil
_send_struct()
public.send_status(plc_state.degraded)
log.debug("sent initial status data")
elseif link_ack == RPLC_LINKING.DENY then
println_ts("link request denied, retrying...")
log.debug("RPLC link request denied")
elseif link_ack == RPLC_LINKING.COLLISION then
println_ts("reactor PLC ID collision (check config), retrying...")
log.warning("RPLC link request collision")
else
println_ts("invalid link response, bad channel? retrying...")
log.error("unknown RPLC link request response")
end
self.linked = link_ack == RPLC_LINKING.ALLOW
else
log.debug("RPLC link req packet length mismatch")
end
else
log.debug("discarding non-link packet before linked")
end
elseif protocol == PROTOCOLS.SCADA_MGMT then
if packet.type == SCADA_MGMT_TYPES.KEEP_ALIVE then
-- keep alive request received, echo back
if packet.length == 1 then
local timestamp = packet.data[1]
local trip_time = util.time() - timestamp
if trip_time > 500 then
log.warning("PLC KEEP_ALIVE trip time > 500ms (" .. trip_time .. "ms)")
end
-- log.debug("RPLC 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
self.conn_watchdog.cancel()
public.unlink()
println_ts("server connection closed by remote host")
log.warning("server connection closed by remote host")
else
log.warning("received unknown SCADA_MGMT packet type " .. packet.type)
end
else
-- should be unreachable assuming packet is from parse_packet()
log.error("illegal packet type " .. protocol, true)
end
end
end
public.is_scrammed = function () return self.scrammed end
public.is_linked = function () return self.linked end
return public
end
return plc

175
reactor-plc/startup.lua Normal file
View File

@@ -0,0 +1,175 @@
--
-- Reactor Programmable Logic Controller
--
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 config = require("reactor-plc.config")
local plc = require("reactor-plc.plc")
local threads = require("reactor-plc.threads")
local R_PLC_VERSION = "alpha-v0.7.2"
local print = util.print
local println = util.println
local print_ts = util.print_ts
local println_ts = util.println_ts
log.init(config.LOG_PATH, config.LOG_MODE)
log.info("========================================")
log.info("BOOTING reactor-plc.startup " .. R_PLC_VERSION)
log.info("========================================")
println(">> Reactor PLC " .. R_PLC_VERSION .. " <<")
-- mount connected devices
ppm.mount_all()
-- shared memory across threads
---@class plc_shared_memory
local __shared_memory = {
-- networked setting
networked = config.NETWORKED, ---@type boolean
-- PLC system state flags
---@class plc_state
plc_state = {
init_ok = true,
shutdown = false,
degraded = false,
no_reactor = false,
no_modem = false
},
-- control setpoints
---@class setpoints
setpoints = {
burn_rate_en = false,
burn_rate = 0.0
},
-- 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 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")
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")
if smem_dev.reactor ~= nil then
smem_dev.reactor.scram()
end
plc_state.init_ok = false
plc_state.degraded = true
plc_state.no_modem = true
end
-- PLC init
local init = function ()
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")
end
---@diagnostic disable-next-line: undefined-field
os.queueEvent("clock_start")
println("boot> completed");
log.debug("init> boot completed")
else
println("boot> system in degraded state, awaiting devices...")
log.warning("init> booted in a degraded state, awaiting peripheral connections...")
end
end
----------------------------------------
-- start system
----------------------------------------
-- initialize PLC
init()
-- init threads
local main_thread = threads.thread__main(__shared_memory, init)
local rps_thread = threads.thread__rps(__shared_memory)
if __shared_memory.networked then
-- init comms threads
local comms_thread_tx = threads.thread__comms_tx(__shared_memory)
local comms_thread_rx = threads.thread__comms_rx(__shared_memory)
-- setpoint control only needed when networked
local sp_ctrl_thread = threads.thread__setpoint_control(__shared_memory)
-- run threads
parallel.waitForAll(main_thread.p_exec, rps_thread.p_exec, comms_thread_tx.p_exec, comms_thread_rx.p_exec, sp_ctrl_thread.p_exec)
if plc_state.init_ok then
-- send status one last time after RPS shutdown
smem_sys.plc_comms.send_status(plc_state.degraded)
smem_sys.plc_comms.send_rps_status()
-- close connection
smem_sys.plc_comms.close()
end
else
-- run threads, excluding comms
parallel.waitForAll(main_thread.p_exec, rps_thread.p_exec)
end
println_ts("exited")
log.info("exited")

627
reactor-plc/threads.lua Normal file
View File

@@ -0,0 +1,627 @@
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 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 RPS_SLEEP = 250 -- (250ms, 5 ticks)
local COMMS_SLEEP = 150 -- (150ms, 3 ticks)
local SP_CTRL_SLEEP = 250 -- (250ms, 5 ticks)
local BURN_RATE_RAMP_mB_s = 5.0
local MQ__RPS_CMD = {
SCRAM = 1,
DEGRADED_SCRAM = 2,
TRIP_TIMEOUT = 3
}
local MQ__COMM_CMD = {
SEND_STATUS = 1
}
-- main thread
---@param smem plc_shared_memory
---@param init function
threads.thread__main = function (smem, init)
local public = {} ---@class thread
-- execute thread
public.exec = function ()
log.debug("main thread init, clock inactive")
-- send status updates at 2Hz (every 10 server ticks) (every loop tick)
-- send link requests at 0.5Hz (every 40 server ticks) (every 4 loop ticks)
local LINK_TICKS = 4
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
-- event 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 conn_watchdog = smem.plc_sys.conn_watchdog
---@diagnostic disable-next-line: undefined-field
local event, param1, param2, param3, param4, param5 = os.pullEventRaw()
-- handle event
if event == "timer" and loop_clock.is_clock(param1) then
-- core clock tick
if networked then
-- start next clock timer
loop_clock.start()
-- send updated data
if not plc_state.no_modem then
if plc_comms.is_linked() then
smem.q.mq_comms_tx.push_command(MQ__COMM_CMD.SEND_STATUS)
else
if ticks_to_update == 0 then
plc_comms.send_link_req()
ticks_to_update = LINK_TICKS
else
ticks_to_update = ticks_to_update - 1
end
end
end
end
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)
if packet ~= nil then
-- pass the packet onto the comms message queue
smem.q.mq_comms_rx.push_packet(packet)
end
elseif event == "timer" and networked and plc_state.init_ok and conn_watchdog.is_timer(param1) then
-- haven't heard from server recently? shutdown reactor
plc_comms.unlink()
smem.q.mq_rps.push_command(MQ__RPS_CMD.TRIP_TIMEOUT)
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
println_ts("reactor disconnected!")
log.error("reactor 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!")
plc_state.no_modem = true
if plc_state.init_ok then
-- try to scram reactor if it is still connected
smem.q.mq_rps.push_command(MQ__RPS_CMD.DEGRADED_SCRAM)
end
plc_state.degraded = true
else
log.warning("non-comms modem disconnected")
end
end
end
elseif event == "peripheral" then
-- peripheral connect
local type, device = ppm.mount(param1)
if type ~= nil and device ~= nil then
if type == "fissionReactor" 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
if plc_state.init_ok then
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
end
elseif networked and type == "modem" then
if device.isWireless() then
-- reconnected modem
plc_dev.modem = device
if plc_state.init_ok then
plc_comms.reconnect_modem(plc_dev.modem)
end
println_ts("wireless modem reconnected.")
log.info("comms modem reconnected")
plc_state.no_modem = false
-- determine if we are still in a degraded state
if not plc_state.no_reactor then
plc_state.degraded = false
end
else
log.info("wired modem reconnected")
end
end
end
-- if not init'd and no longer degraded, proceed to init
if not plc_state.init_ok and not plc_state.degraded then
plc_state.init_ok = true
init()
end
elseif event == "clock_start" then
-- start loop clock
loop_clock.start()
log.debug("main thread clock started")
end
-- check for termination request
if event == "terminate" or ppm.should_terminate() then
log.info("terminate requested, main thread exiting")
-- rps handles reactor shutdown
plc_state.shutdown = true
break
end
end
end
-- execute the thread in a protected mode, retrying it on return if not shutting down
public.p_exec = function ()
local plc_state = smem.plc_state
while not plc_state.shutdown do
local status, result = pcall(public.exec)
if status == false then
log.fatal(result)
end
-- 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")
end
end
end
return public
end
-- RPS operation thread
---@param smem plc_shared_memory
threads.thread__rps = function (smem)
local public = {} ---@class thread
-- execute thread
public.exec = function ()
log.debug("rps thread start")
-- load in from shared memory
local networked = smem.networked
local plc_state = smem.plc_state
local plc_dev = smem.plc_dev
local rps_queue = smem.q.mq_rps
local was_linked = false
local last_update = util.time()
-- 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
-- get reactor, may have changed do to disconnect/reconnect
local reactor = plc_dev.reactor
-- RPS checks
if plc_state.init_ok then
-- SCRAM if no open connection
if networked and not plc_comms.is_linked() then
if was_linked then
was_linked = false
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)
---@diagnostic disable-next-line: need-check-nil
if not plc_state.no_reactor and rps.is_tripped() and reactor.getStatus() then
rps.scram()
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
-- check safety (SCRAM occurs if tripped)
if not plc_state.no_reactor then
local rps_tripped, rps_status_string, rps_first = rps.check()
if rps_tripped and rps_first then
println_ts("[RPS] SCRAM! safety trip: " .. rps_status_string)
if networked and not plc_state.no_modem then
plc_comms.send_rps_alarm(rps_status_string)
end
end
end
end
-- check for messages in the message queue
while rps_queue.ready() and not plc_state.shutdown do
local msg = rps_queue.pop()
if msg ~= nil then
if msg.qtype == mqueue.TYPE.COMMAND then
-- received a command
if plc_state.init_ok then
if msg.message == MQ__RPS_CMD.SCRAM then
-- SCRAM
rps.scram()
elseif msg.message == MQ__RPS_CMD.DEGRADED_SCRAM then
-- lost peripheral(s)
rps.trip_fault()
elseif msg.message == MQ__RPS_CMD.TRIP_TIMEOUT then
-- watchdog tripped
rps.trip_timeout()
println_ts("server timeout")
log.warning("server timeout")
end
end
elseif msg.qtype == mqueue.TYPE.DATA then
-- received data
elseif msg.qtype == mqueue.TYPE.PACKET then
-- received a packet
end
end
-- quick yield
util.nop()
end
-- check for termination request
if plc_state.shutdown then
-- safe exit
log.info("rps thread shutdown initiated")
if plc_state.init_ok then
if rps.scram() then
println_ts("reactor disabled")
log.info("rps thread reactor SCRAM OK")
else
println_ts("exiting, reactor failed to disable")
log.error("rps thread failed to SCRAM reactor on exit")
end
end
log.info("rps thread exiting")
break
end
-- delay before next check
last_update = util.adaptive_delay(RPS_SLEEP, last_update)
end
end
-- execute the thread in a protected mode, retrying it on return if not shutting down
public.p_exec = function ()
local plc_state = smem.plc_state
while not plc_state.shutdown do
local status, result = pcall(public.exec)
if status == false then
log.fatal(result)
end
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...")
util.psleep(5)
end
end
end
return public
end
-- communications sender thread
---@param smem plc_shared_memory
threads.thread__comms_tx = function (smem)
local public = {} ---@class thread
-- execute thread
public.exec = function ()
log.debug("comms tx thread start")
-- load in from shared memory
local plc_state = smem.plc_state
local comms_queue = smem.q.mq_comms_tx
local last_update = util.time()
-- thread loop
while true do
-- get plc_sys fields (may have been set late due to degraded boot)
local plc_comms = smem.plc_sys.plc_comms
-- check for messages in the message queue
while comms_queue.ready() and not plc_state.shutdown do
local msg = comms_queue.pop()
if msg ~= nil and plc_state.init_ok then
if msg.qtype == mqueue.TYPE.COMMAND then
-- 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_rps_status()
end
elseif msg.qtype == mqueue.TYPE.DATA then
-- received data
elseif msg.qtype == mqueue.TYPE.PACKET then
-- received a packet
end
end
-- quick yield
util.nop()
end
-- check for termination request
if plc_state.shutdown then
log.info("comms tx thread exiting")
break
end
-- delay before next check
last_update = util.adaptive_delay(COMMS_SLEEP, last_update)
end
end
-- execute the thread in a protected mode, retrying it on return if not shutting down
public.p_exec = function ()
local plc_state = smem.plc_state
while not plc_state.shutdown do
local status, result = pcall(public.exec)
if status == false then
log.fatal(result)
end
if not plc_state.shutdown then
log.info("comms tx thread restarting in 5 seconds...")
util.psleep(5)
end
end
end
return public
end
-- communications handler thread
---@param smem plc_shared_memory
threads.thread__comms_rx = function (smem)
local public = {} ---@class thread
-- execute thread
public.exec = function ()
log.debug("comms rx thread start")
-- load in from shared memory
local plc_state = smem.plc_state
local setpoints = smem.setpoints
local comms_queue = smem.q.mq_comms_rx
local last_update = util.time()
-- thread loop
while true do
-- get plc_sys fields (may have been set late due to degraded boot)
local plc_comms = smem.plc_sys.plc_comms
-- check for messages in the message queue
while comms_queue.ready() and not plc_state.shutdown do
local msg = comms_queue.pop()
if msg ~= nil and plc_state.init_ok then
if msg.qtype == mqueue.TYPE.COMMAND then
-- received a command
elseif msg.qtype == mqueue.TYPE.DATA then
-- received data
elseif msg.qtype == mqueue.TYPE.PACKET then
-- 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)
end
end
-- quick yield
util.nop()
end
-- check for termination request
if plc_state.shutdown then
log.info("comms rx thread exiting")
break
end
-- delay before next check
last_update = util.adaptive_delay(COMMS_SLEEP, last_update)
end
end
-- execute the thread in a protected mode, retrying it on return if not shutting down
public.p_exec = function ()
local plc_state = smem.plc_state
while not plc_state.shutdown do
local status, result = pcall(public.exec)
if status == false then
log.fatal(result)
end
if not plc_state.shutdown then
log.info("comms rx thread restarting in 5 seconds...")
util.psleep(5)
end
end
end
return public
end
-- apply setpoints
---@param smem plc_shared_memory
threads.thread__setpoint_control = function (smem)
local public = {} ---@class thread
-- execute thread
public.exec = function ()
log.debug("setpoint control thread start")
-- load in from shared memory
local plc_state = smem.plc_state
local setpoints = smem.setpoints
local plc_dev = smem.plc_dev
local last_update = util.time()
local running = false
local last_sp_burn = 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
local min_elapsed_s = SP_CTRL_SLEEP / 1000.0
-- thread loop
while true do
-- get plc_sys fields (may have been set late due to degraded boot)
local rps = smem.plc_sys.rps
-- 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
-- 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")
---@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
-- only check I/O if active to save on processing time
if running then
-- clear so we can later evaluate if we should keep running
running = false
-- adjust burn rate (setpoints.burn_rate)
if setpoints.burn_rate_en then
if rps.is_active() then
---@diagnostic disable-next-line: need-check-nil
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
-- calculate new burn rate
local new_burn_rate = current_burn_rate
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
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
end
-- 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
end
end
end
end
-- check for termination request
if plc_state.shutdown then
log.info("setpoint control thread exiting")
break
end
-- delay before next check
last_update = util.adaptive_delay(SP_CTRL_SLEEP, last_update)
end
end
-- execute the thread in a protected mode, retrying it on return if not shutting down
public.p_exec = function ()
local plc_state = smem.plc_state
while not plc_state.shutdown do
local status, result = pcall(public.exec)
if status == false then
log.fatal(result)
end
if not plc_state.shutdown then
log.info("setpoint control thread restarting in 5 seconds...")
util.psleep(5)
end
end
end
return public
end
return threads

52
rtu/config.lua Normal file
View File

@@ -0,0 +1,52 @@
local rsio = require("scada-common.rsio")
local config = {}
-- port to send packets TO server
config.SERVER_PORT = 16000
-- port to listen to incoming packets FROM server
config.LISTEN_PORT = 15001
-- 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 = {
{
name = "boiler_1",
index = 1,
for_reactor = 1
},
{
name = "turbine_1",
index = 1,
for_reactor = 1
}
}
-- 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
}
}
}
}
return config

51
rtu/dev/boiler_rtu.lua Normal file
View File

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

58
rtu/dev/boilerv_rtu.lua Normal file
View File

@@ -0,0 +1,58 @@
local rtu = require("rtu.rtu")
local boilerv_rtu = {}
-- create new boiler (mek 10.1+) device
---@param boiler table
boilerv_rtu.new = function (boiler)
local self = {
rtu = rtu.init_unit(),
boiler = boiler
}
-- discrete inputs --
self.rtu.connect_di(self.boiler.isFormed)
-- coils --
-- none
-- input registers --
-- multiblock properties
self.rtu.connect_input_reg(self.boiler.getLength)
self.rtu.connect_input_reg(self.boiler.getWidth)
self.rtu.connect_input_reg(self.boiler.getHeight)
self.rtu.connect_input_reg(self.boiler.getMinPos)
self.rtu.connect_input_reg(self.boiler.getMaxPos)
-- build properties
self.rtu.connect_input_reg(self.boiler.getBoilCapacity)
self.rtu.connect_input_reg(self.boiler.getSteamCapacity)
self.rtu.connect_input_reg(self.boiler.getWaterCapacity)
self.rtu.connect_input_reg(self.boiler.getHeatedCoolantCapacity)
self.rtu.connect_input_reg(self.boiler.getCooledCoolantCapacity)
self.rtu.connect_input_reg(self.boiler.getSuperheaters)
self.rtu.connect_input_reg(self.boiler.getMaxBoilRate)
self.rtu.connect_input_reg(self.boiler.getEnvironmentalLoss)
-- current state
self.rtu.connect_input_reg(self.boiler.getTemperature)
self.rtu.connect_input_reg(self.boiler.getBoilRate)
-- tanks
self.rtu.connect_input_reg(self.boiler.getSteam)
self.rtu.connect_input_reg(self.boiler.getSteamNeeded)
self.rtu.connect_input_reg(self.boiler.getSteamFilledPercentage)
self.rtu.connect_input_reg(self.boiler.getWater)
self.rtu.connect_input_reg(self.boiler.getWaterNeeded)
self.rtu.connect_input_reg(self.boiler.getWaterFilledPercentage)
self.rtu.connect_input_reg(self.boiler.getHeatedCoolant)
self.rtu.connect_input_reg(self.boiler.getHeatedCoolantNeeded)
self.rtu.connect_input_reg(self.boiler.getHeatedCoolantFilledPercentage)
self.rtu.connect_input_reg(self.boiler.getCooledCoolant)
self.rtu.connect_input_reg(self.boiler.getCooledCoolantNeeded)
self.rtu.connect_input_reg(self.boiler.getCooledCoolantFilledPercentage)
-- holding registers --
-- none
return self.rtu.interface()
end
return boilerv_rtu

View File

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

45
rtu/dev/imatrix_rtu.lua Normal file
View File

@@ -0,0 +1,45 @@
local rtu = require("rtu.rtu")
local imatrix_rtu = {}
-- create new induction matrix (mek 10.1+) device
---@param imatrix table
imatrix_rtu.new = function (imatrix)
local self = {
rtu = rtu.init_unit(),
imatrix = imatrix
}
-- discrete inputs --
self.rtu.connect_di(self.boiler.isFormed)
-- coils --
-- none
-- input registers --
-- multiblock properties
self.rtu.connect_input_reg(self.boiler.getLength)
self.rtu.connect_input_reg(self.boiler.getWidth)
self.rtu.connect_input_reg(self.boiler.getHeight)
self.rtu.connect_input_reg(self.boiler.getMinPos)
self.rtu.connect_input_reg(self.boiler.getMaxPos)
-- build properties
self.rtu.connect_input_reg(self.imatrix.getMaxEnergy)
self.rtu.connect_input_reg(self.imatrix.getTransferCap)
self.rtu.connect_input_reg(self.imatrix.getInstalledCells)
self.rtu.connect_input_reg(self.imatrix.getInstalledProviders)
-- containers
self.rtu.connect_input_reg(self.imatrix.getEnergy)
self.rtu.connect_input_reg(self.imatrix.getEnergyNeeded)
self.rtu.connect_input_reg(self.imatrix.getEnergyFilledPercentage)
-- I/O rates
self.rtu.connect_input_reg(self.imatrix.getLastInput)
self.rtu.connect_input_reg(self.imatrix.getLastOutput)
-- holding registers --
-- none
return self.rtu.interface()
end
return imatrix_rtu

113
rtu/dev/redstone_rtu.lua Normal file
View File

@@ -0,0 +1,113 @@
local rtu = require("rtu.rtu")
local rsio = require("scada-common.rsio")
local redstone_rtu = {}
local digital_read = rsio.digital_read
local digital_write = rsio.digital_write
local digital_is_active = rsio.digital_is_active
-- create new redstone device
redstone_rtu.new = function ()
local self = {
rtu = rtu.init_unit()
}
-- get RTU interface
local interface = self.rtu.interface()
---@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,
read_di = interface.read_di,
read_holding_reg = interface.read_holding_reg,
read_input_reg = interface.read_input_reg,
write_coil = interface.write_coil,
write_holding_reg = interface.write_holding_reg
}
-- link digital input
---@param side string
---@param color integer
public.link_di = function (side, color)
local f_read = nil
if color then
f_read = function ()
return digital_read(rs.testBundledInput(side, color))
end
else
f_read = function ()
return digital_read(rs.getInput(side))
end
end
self.rtu.connect_di(f_read)
end
-- link digital output
---@param channel RS_IO
---@param side string
---@param color integer
public.link_do = function (channel, side, color)
local f_read = nil
local f_write = nil
if color then
f_read = function ()
return digital_read(colors.test(rs.getBundledOutput(side), color))
end
f_write = function (level)
local output = rs.getBundledOutput(side)
if digital_write(channel, level) then
output = colors.combine(output, color)
else
output = colors.subtract(output, color)
end
rs.setBundledOutput(side, output)
end
else
f_read = function ()
return digital_read(rs.getOutput(side))
end
f_write = function (level)
rs.setOutput(side, digital_is_active(channel, level))
end
end
self.rtu.connect_coil(f_read, f_write)
end
-- link analog input
---@param side string
public.link_ai = function (side)
self.rtu.connect_input_reg(
function ()
return rs.getAnalogInput(side)
end
)
end
-- link analog output
---@param side string
public.link_ao = function (side)
self.rtu.connect_holding_reg(
function ()
return rs.getAnalogOutput(side)
end,
function (value)
rs.setAnalogOutput(side, value)
end
)
end
return public
end
return redstone_rtu

46
rtu/dev/turbine_rtu.lua Normal file
View File

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

57
rtu/dev/turbinev_rtu.lua Normal file
View File

@@ -0,0 +1,57 @@
local rtu = require("rtu.rtu")
local turbinev_rtu = {}
-- create new turbine (mek 10.1+) device
---@param turbine table
turbinev_rtu.new = function (turbine)
local self = {
rtu = rtu.init_unit(),
turbine = turbine
}
-- discrete inputs --
self.rtu.connect_di(self.boiler.isFormed)
-- coils --
self.rtu.connect_coil(function () self.turbine.incrementDumpingMode() end, function () end)
self.rtu.connect_coil(function () self.turbine.decrementDumpingMode() end, function () end)
-- input registers --
-- multiblock properties
self.rtu.connect_input_reg(self.boiler.getLength)
self.rtu.connect_input_reg(self.boiler.getWidth)
self.rtu.connect_input_reg(self.boiler.getHeight)
self.rtu.connect_input_reg(self.boiler.getMinPos)
self.rtu.connect_input_reg(self.boiler.getMaxPos)
-- build properties
self.rtu.connect_input_reg(self.turbine.getBlades)
self.rtu.connect_input_reg(self.turbine.getCoils)
self.rtu.connect_input_reg(self.turbine.getVents)
self.rtu.connect_input_reg(self.turbine.getDispersers)
self.rtu.connect_input_reg(self.turbine.getCondensers)
self.rtu.connect_input_reg(self.turbine.getDumpingMode)
self.rtu.connect_input_reg(self.turbine.getSteamCapacity)
self.rtu.connect_input_reg(self.turbine.getMaxEnergy)
self.rtu.connect_input_reg(self.turbine.getMaxFlowRate)
self.rtu.connect_input_reg(self.turbine.getMaxWaterOutput)
self.rtu.connect_input_reg(self.turbine.getMaxProduction)
-- current state
self.rtu.connect_input_reg(self.turbine.getFlowRate)
self.rtu.connect_input_reg(self.turbine.getProductionRate)
self.rtu.connect_input_reg(self.turbine.getLastSteamInputRate)
-- tanks/containers
self.rtu.connect_input_reg(self.turbine.getSteam)
self.rtu.connect_input_reg(self.turbine.getSteamNeeded)
self.rtu.connect_input_reg(self.turbine.getSteamFilledPercentage)
self.rtu.connect_input_reg(self.turbine.getEnergy)
self.rtu.connect_input_reg(self.turbine.getEnergyNeeded)
self.rtu.connect_input_reg(self.turbine.getEnergyFilledPercentage)
-- holding registers --
self.rtu.connect_holding_reg(self.turbine.setDumpingMode, self.turbine.getDumpingMode)
return self.rtu.interface()
end
return turbinev_rtu

439
rtu/modbus.lua Normal file
View File

@@ -0,0 +1,439 @@
local comms = require("scada-common.comms")
local types = require("scada-common.types")
local modbus = {}
local MODBUS_FCODE = types.MODBUS_FCODE
local MODBUS_EXCODE = types.MODBUS_EXCODE
-- new modbus comms handler object
---@param rtu_dev rtu_device|rtu_rs_device RTU device
---@param use_parallel_read boolean whether or not to use parallel calls when reading
modbus.new = function (rtu_dev, use_parallel_read)
local self = {
rtu = rtu_dev,
use_parallel = use_parallel_read
}
---@class modbus
local public = {}
local insert = table.insert
---@param c_addr_start integer
---@param count integer
---@return boolean ok, table readings
local _1_read_coils = function (c_addr_start, count)
local tasks = {}
local readings = {}
local access_fault = false
local _, coils, _, _ = self.rtu.io_count()
local return_ok = ((c_addr_start + count) <= (coils + 1)) and (count > 0)
if return_ok then
for i = 1, count do
local addr = c_addr_start + i - 1
if self.use_parallel then
insert(tasks, function ()
local reading, fault = self.rtu.read_coil(addr)
if fault then access_fault = true else readings[i] = reading end
end)
else
readings[i], access_fault = self.rtu.read_coil(addr)
if access_fault then
return_ok = false
readings = MODBUS_EXCODE.SERVER_DEVICE_FAIL
break
end
end
end
-- run parallel tasks if configured
if self.use_parallel then
parallel.waitForAll(table.unpack(tasks))
if access_fault then
return_ok = false
readings = MODBUS_EXCODE.SERVER_DEVICE_FAIL
end
end
else
readings = MODBUS_EXCODE.ILLEGAL_DATA_ADDR
end
return return_ok, readings
end
---@param di_addr_start integer
---@param count integer
---@return boolean ok, table readings
local _2_read_discrete_inputs = function (di_addr_start, count)
local tasks = {}
local readings = {}
local access_fault = false
local discrete_inputs, _, _, _ = self.rtu.io_count()
local return_ok = ((di_addr_start + count) <= (discrete_inputs + 1)) and (count > 0)
if return_ok then
for i = 1, count do
local addr = di_addr_start + i - 1
if self.use_parallel then
insert(tasks, function ()
local reading, fault = self.rtu.read_di(addr)
if fault then access_fault = true else readings[i] = reading end
end)
else
readings[i], access_fault = self.rtu.read_di(addr)
if access_fault then
return_ok = false
readings = MODBUS_EXCODE.SERVER_DEVICE_FAIL
break
end
end
end
-- run parallel tasks if configured
if self.use_parallel then
parallel.waitForAll(table.unpack(tasks))
if access_fault then
return_ok = false
readings = MODBUS_EXCODE.SERVER_DEVICE_FAIL
end
end
else
readings = MODBUS_EXCODE.ILLEGAL_DATA_ADDR
end
return return_ok, readings
end
---@param hr_addr_start integer
---@param count integer
---@return boolean ok, table readings
local _3_read_multiple_holding_registers = function (hr_addr_start, count)
local tasks = {}
local readings = {}
local access_fault = false
local _, _, _, hold_regs = self.rtu.io_count()
local return_ok = ((hr_addr_start + count) <= (hold_regs + 1)) and (count > 0)
if return_ok then
for i = 1, count do
local addr = hr_addr_start + i - 1
if self.use_parallel then
insert(tasks, function ()
local reading, fault = self.rtu.read_holding_reg(addr)
if fault then access_fault = true else readings[i] = reading end
end)
else
readings[i], access_fault = self.rtu.read_holding_reg(addr)
if access_fault then
return_ok = false
readings = MODBUS_EXCODE.SERVER_DEVICE_FAIL
break
end
end
end
-- run parallel tasks if configured
if self.use_parallel then
parallel.waitForAll(table.unpack(tasks))
if access_fault then
return_ok = false
readings = MODBUS_EXCODE.SERVER_DEVICE_FAIL
end
end
else
readings = MODBUS_EXCODE.ILLEGAL_DATA_ADDR
end
return return_ok, readings
end
---@param ir_addr_start integer
---@param count integer
---@return boolean ok, table readings
local _4_read_input_registers = function (ir_addr_start, count)
local tasks = {}
local readings = {}
local access_fault = false
local _, _, input_regs, _ = self.rtu.io_count()
local return_ok = ((ir_addr_start + count) <= (input_regs + 1)) and (count > 0)
if return_ok then
for i = 1, count do
local addr = ir_addr_start + i - 1
if self.use_parallel then
insert(tasks, function ()
local reading, fault = self.rtu.read_input_reg(addr)
if fault then access_fault = true else readings[i] = reading end
end)
else
readings[i], access_fault = self.rtu.read_input_reg(addr)
if access_fault then
return_ok = false
readings = MODBUS_EXCODE.SERVER_DEVICE_FAIL
break
end
end
end
-- run parallel tasks if configured
if self.use_parallel then
parallel.waitForAll(table.unpack(tasks))
if access_fault then
return_ok = false
readings = MODBUS_EXCODE.SERVER_DEVICE_FAIL
end
end
else
readings = MODBUS_EXCODE.ILLEGAL_DATA_ADDR
end
return return_ok, readings
end
---@param c_addr integer
---@param value any
---@return boolean ok, MODBUS_EXCODE|nil
local _5_write_single_coil = function (c_addr, value)
local response = nil
local _, coils, _, _ = self.rtu.io_count()
local return_ok = c_addr <= coils
if return_ok then
local access_fault = self.rtu.write_coil(c_addr, value)
if access_fault then
return_ok = false
response = MODBUS_EXCODE.SERVER_DEVICE_FAIL
end
else
response = MODBUS_EXCODE.ILLEGAL_DATA_ADDR
end
return return_ok, response
end
---@param hr_addr integer
---@param value any
---@return boolean ok, MODBUS_EXCODE|nil
local _6_write_single_holding_register = function (hr_addr, value)
local response = nil
local _, _, _, hold_regs = self.rtu.io_count()
local return_ok = hr_addr <= hold_regs
if return_ok then
local access_fault = self.rtu.write_holding_reg(hr_addr, value)
if access_fault then
return_ok = false
response = MODBUS_EXCODE.SERVER_DEVICE_FAIL
end
else
response = MODBUS_EXCODE.ILLEGAL_DATA_ADDR
end
return return_ok, response
end
---@param c_addr_start integer
---@param values any
---@return boolean ok, MODBUS_EXCODE|nil
local _15_write_multiple_coils = function (c_addr_start, values)
local response = nil
local _, coils, _, _ = self.rtu.io_count()
local count = #values
local return_ok = ((c_addr_start + count) <= (coils + 1)) and (count > 0)
if return_ok then
for i = 1, count do
local addr = c_addr_start + i - 1
local access_fault = self.rtu.write_coil(addr, values[i])
if access_fault then
return_ok = false
response = MODBUS_EXCODE.SERVER_DEVICE_FAIL
break
end
end
else
response = MODBUS_EXCODE.ILLEGAL_DATA_ADDR
end
return return_ok, response
end
---@param hr_addr_start integer
---@param values any
---@return boolean ok, MODBUS_EXCODE|nil
local _16_write_multiple_holding_registers = function (hr_addr_start, values)
local response = nil
local _, _, _, hold_regs = self.rtu.io_count()
local count = #values
local return_ok = ((hr_addr_start + count) <= (hold_regs + 1)) and (count > 0)
if return_ok then
for i = 1, count do
local addr = hr_addr_start + i - 1
local access_fault = self.rtu.write_holding_reg(addr, values[i])
if access_fault then
return_ok = false
response = MODBUS_EXCODE.SERVER_DEVICE_FAIL
break
end
end
else
response = MODBUS_EXCODE.ILLEGAL_DATA_ADDR
end
return return_ok, response
end
-- validate a request without actually executing it
---@param packet modbus_frame
---@return boolean return_code, modbus_packet reply
public.check_request = function (packet)
local return_code = true
local response = { MODBUS_EXCODE.ACKNOWLEDGE }
if packet.length == 2 then
-- handle by function code
if packet.func_code == MODBUS_FCODE.READ_COILS then
elseif packet.func_code == MODBUS_FCODE.READ_DISCRETE_INPUTS then
elseif packet.func_code == MODBUS_FCODE.READ_MUL_HOLD_REGS then
elseif packet.func_code == MODBUS_FCODE.READ_INPUT_REGS then
elseif packet.func_code == MODBUS_FCODE.WRITE_SINGLE_COIL then
elseif packet.func_code == MODBUS_FCODE.WRITE_SINGLE_HOLD_REG then
elseif packet.func_code == MODBUS_FCODE.WRITE_MUL_COILS then
elseif packet.func_code == MODBUS_FCODE.WRITE_MUL_HOLD_REGS then
else
-- unknown function
return_code = false
response = { MODBUS_EXCODE.ILLEGAL_FUNCTION }
end
else
-- invalid length
return_code = false
response = { MODBUS_EXCODE.NEG_ACKNOWLEDGE }
end
-- default is to echo back
local func_code = packet.func_code
if not return_code then
-- echo back with error flag
func_code = bit.bor(packet.func_code, MODBUS_FCODE.ERROR_FLAG)
end
-- create reply
local reply = comms.modbus_packet()
reply.make(packet.txn_id, packet.unit_id, func_code, response)
return return_code, reply
end
-- handle a MODBUS TCP packet and generate a reply
---@param packet modbus_frame
---@return boolean return_code, modbus_packet reply
public.handle_packet = function (packet)
local return_code = true
local response = nil
if packet.length == 2 then
-- handle by function code
if packet.func_code == MODBUS_FCODE.READ_COILS then
return_code, response = _1_read_coils(packet.data[1], packet.data[2])
elseif packet.func_code == MODBUS_FCODE.READ_DISCRETE_INPUTS then
return_code, response = _2_read_discrete_inputs(packet.data[1], packet.data[2])
elseif packet.func_code == MODBUS_FCODE.READ_MUL_HOLD_REGS then
return_code, response = _3_read_multiple_holding_registers(packet.data[1], packet.data[2])
elseif packet.func_code == MODBUS_FCODE.READ_INPUT_REGS then
return_code, response = _4_read_input_registers(packet.data[1], packet.data[2])
elseif packet.func_code == MODBUS_FCODE.WRITE_SINGLE_COIL then
return_code, response = _5_write_single_coil(packet.data[1], packet.data[2])
elseif packet.func_code == MODBUS_FCODE.WRITE_SINGLE_HOLD_REG then
return_code, response = _6_write_single_holding_register(packet.data[1], packet.data[2])
elseif packet.func_code == MODBUS_FCODE.WRITE_MUL_COILS then
return_code, response = _15_write_multiple_coils(packet.data[1], packet.data[2])
elseif packet.func_code == MODBUS_FCODE.WRITE_MUL_HOLD_REGS then
return_code, response = _16_write_multiple_holding_registers(packet.data[1], packet.data[2])
else
-- unknown function
return_code = false
response = MODBUS_EXCODE.ILLEGAL_FUNCTION
end
else
-- invalid length
return_code = false
end
-- default is to echo back
local func_code = packet.func_code
if not return_code then
-- echo back with error flag
func_code = bit.bor(packet.func_code, MODBUS_FCODE.ERROR_FLAG)
end
if type(response) == "table" then
elseif type(response) == "nil" then
response = {}
else
response = { response }
end
-- create reply
local reply = comms.modbus_packet()
reply.make(packet.txn_id, packet.unit_id, func_code, response)
return return_code, reply
end
-- return a SERVER_DEVICE_BUSY error reply
---@return modbus_packet reply
public.reply__srv_device_busy = function (packet)
-- reply back with error flag and exception code
local reply = comms.modbus_packet()
local fcode = bit.bor(packet.func_code, MODBUS_FCODE.ERROR_FLAG)
local data = { MODBUS_EXCODE.SERVER_DEVICE_BUSY }
reply.make(packet.txn_id, packet.unit_id, fcode, data)
return reply
end
-- return a NEG_ACKNOWLEDGE error reply
---@return modbus_packet reply
public.reply__neg_ack = function (packet)
-- reply back with error flag and exception code
local reply = comms.modbus_packet()
local fcode = bit.bor(packet.func_code, MODBUS_FCODE.ERROR_FLAG)
local data = { MODBUS_EXCODE.NEG_ACKNOWLEDGE }
reply.make(packet.txn_id, packet.unit_id, fcode, data)
return reply
end
-- return a GATEWAY_PATH_UNAVAILABLE error reply
---@return modbus_packet reply
public.reply__gw_unavailable = function (packet)
-- reply back with error flag and exception code
local reply = comms.modbus_packet()
local fcode = bit.bor(packet.func_code, MODBUS_FCODE.ERROR_FLAG)
local data = { MODBUS_EXCODE.GATEWAY_PATH_UNAVAILABLE }
reply.make(packet.txn_id, packet.unit_id, fcode, data)
return reply
end
return public
end
return modbus

419
rtu/rtu.lua Normal file
View File

@@ -0,0 +1,419 @@
local comms = require("scada-common.comms")
local ppm = require("scada-common.ppm")
local log = require("scada-common.log")
local types = require("scada-common.types")
local util = require("scada-common.util")
local modbus = require("rtu.modbus")
local rtu = {}
local rtu_t = types.rtu_t
local PROTOCOLS = comms.PROTOCOLS
local SCADA_MGMT_TYPES = comms.SCADA_MGMT_TYPES
local RTU_UNIT_TYPES = comms.RTU_UNIT_TYPES
local print = util.print
local println = util.println
local print_ts = util.print_ts
local println_ts = util.println_ts
-- create a new RTU
rtu.init_unit = function ()
local self = {
discrete_inputs = {},
coils = {},
input_regs = {},
holding_regs = {},
io_count_cache = { 0, 0, 0, 0 }
}
local insert = table.insert
---@class rtu_device
local public = {}
---@class rtu
local protected = {}
-- refresh IO count
local _count_io = function ()
self.io_count_cache = { #self.discrete_inputs, #self.coils, #self.input_regs, #self.holding_regs }
end
-- return IO count
---@return integer discrete_inputs, integer coils, integer input_regs, integer holding_regs
public.io_count = function ()
return self.io_count_cache[1], self.io_count_cache[2], self.io_count_cache[3], self.io_count_cache[4]
end
-- discrete inputs: single bit read-only
-- connect discrete input
---@param f function
---@return integer count count of discrete inputs
protected.connect_di = function (f)
insert(self.discrete_inputs, { read = f })
_count_io()
return #self.discrete_inputs
end
-- read discrete input
---@param di_addr integer
---@return any value, boolean access_fault
public.read_di = function (di_addr)
ppm.clear_fault()
local value = self.discrete_inputs[di_addr].read()
return value, ppm.is_faulted()
end
-- coils: single bit read-write
-- connect coil
---@param f_read function
---@param f_write function
---@return integer count count of coils
protected.connect_coil = function (f_read, f_write)
insert(self.coils, { read = f_read, write = f_write })
_count_io()
return #self.coils
end
-- read coil
---@param coil_addr integer
---@return any value, boolean access_fault
public.read_coil = function (coil_addr)
ppm.clear_fault()
local value = self.coils[coil_addr].read()
return value, ppm.is_faulted()
end
-- write coil
---@param coil_addr integer
---@param value any
---@return boolean access_fault
public.write_coil = function (coil_addr, value)
ppm.clear_fault()
self.coils[coil_addr].write(value)
return ppm.is_faulted()
end
-- input registers: multi-bit read-only
-- connect input register
---@param f function
---@return integer count count of input registers
protected.connect_input_reg = function (f)
insert(self.input_regs, { read = f })
_count_io()
return #self.input_regs
end
-- read input register
---@param reg_addr integer
---@return any value, boolean access_fault
public.read_input_reg = function (reg_addr)
ppm.clear_fault()
local value = self.input_regs[reg_addr].read()
return value, ppm.is_faulted()
end
-- holding registers: multi-bit read-write
-- connect holding register
---@param f_read function
---@param f_write function
---@return integer count count of holding registers
protected.connect_holding_reg = function (f_read, f_write)
insert(self.holding_regs, { read = f_read, write = f_write })
_count_io()
return #self.holding_regs
end
-- read holding register
---@param reg_addr integer
---@return any value, boolean access_fault
public.read_holding_reg = function (reg_addr)
ppm.clear_fault()
local value = self.holding_regs[reg_addr].read()
return value, ppm.is_faulted()
end
-- write holding register
---@param reg_addr integer
---@param value any
---@return boolean access_fault
public.write_holding_reg = function (reg_addr, value)
ppm.clear_fault()
self.holding_regs[reg_addr].write(value)
return ppm.is_faulted()
end
-- public RTU device access
-- get the public interface to this RTU
protected.interface = function ()
return public
end
return protected
end
-- RTU Communications
---@param version string
---@param modem table
---@param local_port integer
---@param server_port integer
---@param conn_watchdog watchdog
rtu.comms = function (version, modem, local_port, server_port, conn_watchdog)
local self = {
version = version,
seq_num = 0,
r_seq_num = nil,
txn_id = 0,
modem = modem,
s_port = server_port,
l_port = local_port,
conn_watchdog = conn_watchdog
}
---@class rtu_comms
local public = {}
local insert = table.insert
-- open modem
if not self.modem.isOpen(self.l_port) then
self.modem.open(self.l_port)
end
-- PRIVATE FUNCTIONS --
-- send a scada management packet
---@param msg_type SCADA_MGMT_TYPES
---@param msg table
local _send = function (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, PROTOCOLS.SCADA_MGMT, m_pkt.raw_sendable())
self.modem.transmit(self.s_port, self.l_port, s_pkt.raw_sendable())
self.seq_num = self.seq_num + 1
end
-- keep alive ack
---@param srv_time integer
local _send_keep_alive_ack = function (srv_time)
_send(SCADA_MGMT_TYPES.KEEP_ALIVE, { srv_time, util.time() })
end
-- PUBLIC FUNCTIONS --
-- send a MODBUS TCP packet
---@param m_pkt modbus_packet
public.send_modbus = function (m_pkt)
local s_pkt = comms.scada_packet()
s_pkt.make(self.seq_num, PROTOCOLS.MODBUS_TCP, m_pkt.raw_sendable())
self.modem.transmit(self.s_port, self.l_port, s_pkt.raw_sendable())
self.seq_num = self.seq_num + 1
end
-- reconnect a newly connected modem
---@param modem table
---@diagnostic disable-next-line: redefined-local
public.reconnect_modem = function (modem)
self.modem = modem
-- open modem
if not self.modem.isOpen(self.l_port) then
self.modem.open(self.l_port)
end
end
-- unlink from the server
---@param rtu_state rtu_state
public.unlink = function (rtu_state)
rtu_state.linked = false
self.r_seq_num = nil
end
-- close the connection to the server
---@param rtu_state rtu_state
public.close = function (rtu_state)
self.conn_watchdog.cancel()
public.unlink(rtu_state)
_send(SCADA_MGMT_TYPES.CLOSE, {})
end
-- send capability advertisement
---@param units table
public.send_advertisement = function (units)
local advertisement = { self.version }
for i = 1, #units do
local unit = units[i] --@type rtu_unit_registry_entry
local type = comms.rtu_t_to_unit_type(unit.type)
if type ~= nil then
local advert = {
type,
unit.index,
unit.reactor
}
if type == RTU_UNIT_TYPES.REDSTONE then
insert(advert, unit.device)
end
insert(advertisement, advert)
end
end
_send(SCADA_MGMT_TYPES.RTU_ADVERT, advertisement)
end
-- parse a MODBUS/SCADA packet
---@param side string
---@param sender integer
---@param reply_to integer
---@param message any
---@param distance integer
---@return modbus_frame|mgmt_frame|nil packet
public.parse_packet = function(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 MODBUS TCP packet
if s_pkt.protocol() == PROTOCOLS.MODBUS_TCP then
local m_pkt = comms.modbus_packet()
if m_pkt.decode(s_pkt) then
pkt = m_pkt.get()
end
-- get as SCADA management packet
elseif s_pkt.protocol() == PROTOCOLS.SCADA_MGMT then
local mgmt_pkt = comms.mgmt_packet()
if mgmt_pkt.decode(s_pkt) then
pkt = mgmt_pkt.get()
end
else
log.error("illegal packet type " .. s_pkt.protocol(), true)
end
end
return pkt
end
-- handle a MODBUS/SCADA packet
---@param packet modbus_frame|mgmt_frame
---@param units table
---@param rtu_state rtu_state
public.handle_packet = function(packet, units, rtu_state)
if packet ~= nil then
-- check sequence number
if self.r_seq_num == nil then
self.r_seq_num = packet.scada_frame.seq_num()
elseif rtu_state.linked and self.r_seq_num >= packet.scada_frame.seq_num() then
log.warning("sequence out-of-order: last = " .. self.r_seq_num .. ", new = " .. packet.scada_frame.seq_num())
return
else
self.r_seq_num = packet.scada_frame.seq_num()
end
-- feed watchdog on valid sequence number
self.conn_watchdog.feed()
local protocol = packet.scada_frame.protocol()
if protocol == PROTOCOLS.MODBUS_TCP then
local return_code = false
local reply = modbus.reply__neg_ack(packet)
-- handle MODBUS instruction
if packet.unit_id <= #units then
local unit = units[packet.unit_id] ---@type rtu_unit_registry_entry
local unit_dbg_tag = " (unit " .. packet.unit_id .. ")"
if unit.name == "redstone_io" then
-- immediately execute redstone RTU requests
return_code, reply = unit.modbus_io.handle_packet(packet)
if not return_code then
log.warning("requested MODBUS operation failed" .. unit_dbg_tag)
end
else
-- check validity then pass off to unit comms thread
return_code, reply = unit.modbus_io.check_request(packet)
if return_code then
-- check if there are more than 3 active transactions
-- still queue the packet, but this may indicate a problem
if unit.pkt_queue.length() > 3 then
reply = unit.modbus_io.reply__srv_device_busy(packet)
log.debug("queueing new request with " .. unit.pkt_queue.length() ..
" transactions already in the queue" .. unit_dbg_tag)
end
-- always queue the command even if busy
unit.pkt_queue.push_packet(packet)
else
log.warning("cannot perform requested MODBUS operation" .. unit_dbg_tag)
end
end
else
-- unit ID out of range?
reply = modbus.reply__gw_unavailable(packet)
log.error("received MODBUS packet for non-existent unit")
end
public.send_modbus(reply)
elseif protocol == PROTOCOLS.SCADA_MGMT then
-- SCADA management packet
if packet.type == SCADA_MGMT_TYPES.KEEP_ALIVE then
-- keep alive request received, echo back
if packet.length == 1 then
local timestamp = packet.data[1]
local trip_time = util.time() - timestamp
if trip_time > 500 then
log.warning("RTU KEEP_ALIVE trip time > 500ms (" .. trip_time .. "ms)")
end
-- log.debug("RTU 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
-- close connection
self.conn_watchdog.cancel()
public.unlink(rtu_state)
println_ts("server connection closed by remote host")
log.warning("server connection closed by remote host")
elseif packet.type == SCADA_MGMT_TYPES.REMOTE_LINKED then
-- acknowledgement
rtu_state.linked = true
self.r_seq_num = nil
elseif packet.type == SCADA_MGMT_TYPES.RTU_ADVERT then
-- request for capabilities again
public.send_advertisement(units)
else
-- not supported
log.warning("RTU got unexpected SCADA message type " .. packet.type)
end
else
-- should be unreachable assuming packet is from parse_packet()
log.error("illegal packet type " .. protocol, true)
end
end
end
return public
end
return rtu

286
rtu/startup.lua Normal file
View File

@@ -0,0 +1,286 @@
--
-- RTU: Remote Terminal Unit
--
require("/initenv").init_env()
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 types = require("scada-common.types")
local util = require("scada-common.util")
local config = require("rtu.config")
local modbus = require("rtu.modbus")
local rtu = require("rtu.rtu")
local threads = require("rtu.threads")
local redstone_rtu = require("rtu.dev.redstone_rtu")
local boiler_rtu = require("rtu.dev.boiler_rtu")
local boilerv_rtu = require("rtu.dev.boilerv_rtu")
local energymachine_rtu = require("rtu.dev.energymachine_rtu")
local imatrix_rtu = require("rtu.dev.imatrix_rtu")
local turbine_rtu = require("rtu.dev.turbine_rtu")
local turbinev_rtu = require("rtu.dev.turbinev_rtu")
local RTU_VERSION = "alpha-v0.7.1"
local rtu_t = types.rtu_t
local print = util.print
local println = util.println
local print_ts = util.print_ts
local println_ts = util.println_ts
log.init(config.LOG_PATH, config.LOG_MODE)
log.info("========================================")
log.info("BOOTING rtu.startup " .. RTU_VERSION)
log.info("========================================")
println(">> RTU " .. RTU_VERSION .. " <<")
----------------------------------------
-- startup
----------------------------------------
-- mount connected devices
ppm.mount_all()
---@class rtu_shared_memory
local __shared_memory = {
-- RTU system state flags
---@class rtu_state
rtu_state = {
linked = false,
shutdown = false
},
-- core RTU devices
rtu_dev = {
modem = ppm.get_wireless_modem()
},
-- system objects
rtu_sys = {
rtu_comms = nil, ---@type rtu_comms
conn_watchdog = nil, ---@type watchdog
units = {} ---@type table
},
-- message queues
q = {
mq_comms = mqueue.new()
}
}
local smem_dev = __shared_memory.rtu_dev
local smem_sys = __shared_memory.rtu_sys
-- get modem
if smem_dev.modem == nil then
println("boot> wireless modem not found")
log.warning("no wireless modem on startup")
return
end
----------------------------------------
-- interpret config and init units
----------------------------------------
local units = __shared_memory.rtu_sys.units
local rtu_redstone = config.RTU_REDSTONE
local rtu_devices = config.RTU_DEVICES
-- redstone interfaces
for entry_idx = 1, #rtu_redstone do
local rs_rtu = redstone_rtu.new()
local io_table = rtu_redstone[entry_idx].io
local io_reactor = rtu_redstone[entry_idx].for_reactor
local capabilities = {}
log.debug("init> starting redstone RTU I/O linking for reactor " .. io_reactor .. "...")
local continue = true
for i = 1, #units do
local unit = units[i] ---@type rtu_unit_registry_entry
if unit.reactor == io_reactor and unit.type == rtu_t.redstone then
-- duplicate entry
log.warning("init> skipping definition block #" .. entry_idx .. " for reactor " .. io_reactor .. " with already defined redstone I/O")
continue = false
break
end
end
if continue then
for i = 1, #io_table do
local valid = false
local conf = io_table[i]
-- verify configuration
if rsio.is_valid_channel(conf.channel) and rsio.is_valid_side(conf.side) then
if conf.bundled_color then
valid = rsio.is_color(conf.bundled_color)
else
valid = true
end
end
if not valid then
local message = "init> invalid redstone definition at index " .. i .. " in definition block #" .. entry_idx ..
" (for reactor " .. io_reactor .. ")"
println_ts(message)
log.warning(message)
else
-- link redstone in RTU
local mode = rsio.get_io_mode(conf.channel)
if mode == rsio.IO_MODE.DIGITAL_IN then
-- can't have duplicate inputs
if util.table_contains(capabilities, conf.channel) then
log.warning("init> skipping duplicate input for channel " .. rsio.to_string(conf.channel) .. " on side " .. conf.side)
else
rs_rtu.link_di(conf.side, conf.bundled_color)
end
elseif mode == rsio.IO_MODE.DIGITAL_OUT then
rs_rtu.link_do(conf.channel, conf.side, conf.bundled_color)
elseif mode == rsio.IO_MODE.ANALOG_IN then
-- can't have duplicate inputs
if util.table_contains(capabilities, conf.channel) then
log.warning("init> skipping duplicate input for channel " .. rsio.to_string(conf.channel) .. " on side " .. conf.side)
else
rs_rtu.link_ai(conf.side)
end
elseif mode == rsio.IO_MODE.ANALOG_OUT then
rs_rtu.link_ao(conf.side)
else
-- should be unreachable code, we already validated channels
log.error("init> fell through if chain attempting to identify IO mode", true)
break
end
table.insert(capabilities, conf.channel)
log.debug("init> linked redstone " .. #capabilities .. ": " .. rsio.to_string(conf.channel) .. " (" .. conf.side ..
") for reactor " .. io_reactor)
end
end
---@class rtu_unit_registry_entry
local unit = {
name = "redstone_io",
type = rtu_t.redstone,
index = entry_idx,
reactor = io_reactor,
device = capabilities, -- use device field for redstone channels
rtu = rs_rtu,
modbus_io = modbus.new(rs_rtu, false),
pkt_queue = nil,
thread = nil
}
table.insert(units, unit)
log.debug("init> initialized RTU unit #" .. #units .. ": redstone_io (redstone) [1] for reactor " .. io_reactor)
end
end
-- mounted peripherals
for i = 1, #rtu_devices do
local device = ppm.get_periph(rtu_devices[i].name)
if device == nil then
local message = "init> '" .. rtu_devices[i].name .. "' not found"
println_ts(message)
log.warning(message)
else
local type = ppm.get_type(rtu_devices[i].name)
local rtu_iface = nil ---@type rtu_device
local rtu_type = ""
if type == "boiler" then
-- boiler multiblock
rtu_type = rtu_t.boiler
rtu_iface = boiler_rtu.new(device)
elseif type == "boilerValve" then
-- boiler multiblock (10.1+)
rtu_type = rtu_t.boiler_valve
rtu_iface = boilerv_rtu.new(device)
elseif type == "turbine" then
-- turbine multiblock
rtu_type = rtu_t.turbine
rtu_iface = turbine_rtu.new(device)
elseif type == "turbineValve" then
-- turbine multiblock (10.1+)
rtu_type = rtu_t.turbine_valve
rtu_iface = turbinev_rtu.new(device)
elseif type == "mekanismMachine" then
-- assumed to be an induction matrix multiblock, pre Mekanism 10.1
-- also works with energy cubes
rtu_type = rtu_t.energy_machine
rtu_iface = energymachine_rtu.new(device)
elseif type == "inductionPort" then
-- induction matrix multiblock (10.1+)
rtu_type = rtu_t.induction_matrix
rtu_iface = imatrix_rtu.new(device)
else
local message = "init> device '" .. rtu_devices[i].name .. "' is not a known type (" .. type .. ")"
println_ts(message)
log.warning(message)
end
if rtu_iface ~= nil then
---@class rtu_unit_registry_entry
local rtu_unit = {
name = rtu_devices[i].name,
type = rtu_type,
index = rtu_devices[i].index,
reactor = rtu_devices[i].for_reactor,
device = device,
rtu = rtu_iface,
modbus_io = modbus.new(rtu_iface, true),
pkt_queue = mqueue.new(),
thread = nil
}
rtu_unit.thread = threads.thread__unit_comms(__shared_memory, rtu_unit)
table.insert(units, rtu_unit)
log.debug("init> initialized RTU unit #" .. #units .. ": " .. rtu_devices[i].name .. " (" .. rtu_type .. ") [" ..
rtu_devices[i].index .. "] for reactor " .. rtu_devices[i].for_reactor)
end
end
end
----------------------------------------
-- start system
----------------------------------------
-- start connection watchdog
smem_sys.conn_watchdog = util.new_watchdog(5)
log.debug("boot> conn watchdog started")
-- setup comms
smem_sys.rtu_comms = rtu.comms(RTU_VERSION, smem_dev.modem, config.LISTEN_PORT, config.SERVER_PORT, smem_sys.conn_watchdog)
log.debug("boot> comms init")
-- init threads
local main_thread = threads.thread__main(__shared_memory)
local comms_thread = threads.thread__comms(__shared_memory)
-- assemble thread list
local _threads = { main_thread.p_exec, comms_thread.p_exec }
for i = 1, #units do
if units[i].thread ~= nil then
table.insert(_threads, units[i].thread.p_exec)
end
end
-- run threads
parallel.waitForAll(table.unpack(_threads))
println_ts("exited")
log.info("exited")

319
rtu/threads.lua Normal file
View File

@@ -0,0 +1,319 @@
local log = require("scada-common.log")
local mqueue = require("scada-common.mqueue")
local ppm = require("scada-common.ppm")
local types = require("scada-common.types")
local util = require("scada-common.util")
local boiler_rtu = require("rtu.dev.boiler_rtu")
local boilerv_rtu = require("rtu.dev.boilerv_rtu")
local energymachine_rtu = require("rtu.dev.energymachine_rtu")
local imatrix_rtu = require("rtu.dev.imatrix_rtu")
local turbine_rtu = require("rtu.dev.turbine_rtu")
local turbinev_rtu = require("rtu.dev.turbinev_rtu")
local modbus = require("rtu.modbus")
local threads = {}
local rtu_t = types.rtu_t
local print = util.print
local println = util.println
local print_ts = util.print_ts
local println_ts = util.println_ts
local MAIN_CLOCK = 2 -- (2Hz, 40 ticks)
local COMMS_SLEEP = 100 -- (100ms, 2 ticks)
-- main thread
---@param smem rtu_shared_memory
threads.thread__main = function (smem)
local public = {} ---@class thread
-- execute thread
public.exec = function ()
log.debug("main thread start")
-- main loop clock
local loop_clock = util.new_clock(MAIN_CLOCK)
-- load in from shared memory
local rtu_state = smem.rtu_state
local rtu_dev = smem.rtu_dev
local rtu_comms = smem.rtu_sys.rtu_comms
local conn_watchdog = smem.rtu_sys.conn_watchdog
local units = smem.rtu_sys.units
-- start clock
loop_clock.start()
-- event loop
while true do
---@diagnostic disable-next-line: undefined-field
local event, param1, param2, param3, param4, param5 = os.pullEventRaw()
if event == "timer" and loop_clock.is_clock(param1) then
-- start next clock timer
loop_clock.start()
-- period tick, if we are not linked send advertisement
if not rtu_state.linked then
-- advertise units
rtu_comms.send_advertisement(units)
end
elseif event == "modem_message" then
-- got a packet
local packet = rtu_comms.parse_packet(param1, param2, param3, param4, param5)
if packet ~= nil then
-- pass the packet onto the comms message queue
smem.q.mq_comms.push_packet(packet)
end
elseif event == "timer" and conn_watchdog.is_timer(param1) then
-- haven't heard from server recently? unlink
rtu_comms.unlink(rtu_state)
elseif event == "peripheral_detach" then
-- handle loss of a device
local type, device = ppm.handle_unmount(param1)
if type ~= nil and device ~= nil then
if type == "modem" then
-- we only care if this is our wireless modem
if device == rtu_dev.modem then
println_ts("wireless modem disconnected!")
log.warning("comms modem disconnected!")
else
log.warning("non-comms modem disconnected")
end
else
for i = 1, #units do
-- find disconnected device
if units[i].device == device then
-- we are going to let the PPM prevent crashes
-- return fault flags/codes to MODBUS queries
local unit = units[i]
println_ts("lost the " .. unit.type .. " on interface " .. unit.name)
end
end
end
end
elseif event == "peripheral" then
-- peripheral connect
local type, device = ppm.mount(param1)
if type ~= nil and device ~= nil then
if type == "modem" then
if device.isWireless() then
-- reconnected modem
rtu_dev.modem = device
rtu_comms.reconnect_modem(rtu_dev.modem)
println_ts("wireless modem reconnected.")
log.info("comms modem reconnected.")
else
log.info("wired modem reconnected.")
end
else
-- relink lost peripheral to correct unit entry
for i = 1, #units do
local unit = units[i] ---@type rtu_unit_registry_entry
-- find disconnected device to reconnect
if unit.name == param1 then
-- found, re-link
unit.device = device
if unit.type == rtu_t.boiler then
unit.rtu = boiler_rtu.new(device)
elseif unit.type == rtu_t.boiler_valve then
unit.rtu = boilerv_rtu.new(device)
elseif unit.type == rtu_t.turbine then
unit.rtu = turbine_rtu.new(device)
elseif unit.type == rtu_t.turbine_valve then
unit.rtu = turbinev_rtu.new(device)
elseif unit.type == rtu_t.energy_machine then
unit.rtu = energymachine_rtu.new(device)
elseif unit.type == rtu_t.induction_matrix then
unit.rtu = imatrix_rtu.new(device)
end
unit.modbus_io = modbus.new(unit.rtu, true)
println_ts("reconnected the " .. unit.type .. " on interface " .. unit.name)
end
end
end
end
end
-- check for termination request
if event == "terminate" or ppm.should_terminate() then
rtu_state.shutdown = true
log.info("terminate requested, main thread exiting")
break
end
end
end
-- execute the thread in a protected mode, retrying it on return if not shutting down
public.p_exec = function ()
local rtu_state = smem.rtu_state
while not rtu_state.shutdown do
local status, result = pcall(public.exec)
if status == false then
log.fatal(result)
end
if not rtu_state.shutdown then
log.info("main thread restarting in 5 seconds...")
util.psleep(5)
end
end
end
return public
end
-- communications handler thread
---@param smem rtu_shared_memory
threads.thread__comms = function (smem)
local public = {} ---@class thread
-- execute thread
public.exec = function ()
log.debug("comms thread start")
-- load in from shared memory
local rtu_state = smem.rtu_state
local rtu_comms = smem.rtu_sys.rtu_comms
local units = smem.rtu_sys.units
local comms_queue = smem.q.mq_comms
local last_update = util.time()
-- thread loop
while true do
-- check for messages in the message queue
while comms_queue.ready() and not rtu_state.shutdown do
local msg = comms_queue.pop()
if msg ~= nil then
if msg.qtype == mqueue.TYPE.COMMAND then
-- received a command
elseif msg.qtype == mqueue.TYPE.DATA then
-- received data
elseif msg.qtype == mqueue.TYPE.PACKET then
-- received a packet
-- handle the packet (rtu_state passed to allow setting link flag)
rtu_comms.handle_packet(msg.message, units, rtu_state)
end
end
-- quick yield
util.nop()
end
-- check for termination request
if rtu_state.shutdown then
rtu_comms.close(rtu_state)
log.info("comms thread exiting")
break
end
-- delay before next check
last_update = util.adaptive_delay(COMMS_SLEEP, last_update)
end
end
-- execute the thread in a protected mode, retrying it on return if not shutting down
public.p_exec = function ()
local rtu_state = smem.rtu_state
while not rtu_state.shutdown do
local status, result = pcall(public.exec)
if status == false then
log.fatal(result)
end
if not rtu_state.shutdown then
log.info("comms thread restarting in 5 seconds...")
util.psleep(5)
end
end
end
return public
end
-- per-unit communications handler thread
---@param smem rtu_shared_memory
---@param unit rtu_unit_registry_entry
threads.thread__unit_comms = function (smem, unit)
local public = {} ---@class thread
-- execute thread
public.exec = function ()
log.debug("rtu unit thread start -> " .. unit.name .. "(" .. unit.type .. ")")
-- load in from shared memory
local rtu_state = smem.rtu_state
local rtu_comms = smem.rtu_sys.rtu_comms
local packet_queue = unit.pkt_queue
local last_update = util.time()
-- thread loop
while true do
-- check for messages in the message queue
while packet_queue.ready() and not rtu_state.shutdown do
local msg = packet_queue.pop()
if msg ~= nil then
if msg.qtype == mqueue.TYPE.COMMAND then
-- received a command
elseif msg.qtype == mqueue.TYPE.DATA then
-- received data
elseif msg.qtype == mqueue.TYPE.PACKET then
-- received a packet
local _, reply = unit.modbus_io.handle_packet(msg.message)
rtu_comms.send_modbus(reply)
end
end
-- quick yield
util.nop()
end
-- check for termination request
if rtu_state.shutdown then
log.info("rtu unit thread exiting -> " .. unit.name .. "(" .. unit.type .. ")")
break
end
-- delay before next check
last_update = util.adaptive_delay(COMMS_SLEEP, last_update)
end
end
-- execute the thread in a protected mode, retrying it on return if not shutting down
public.p_exec = function ()
local rtu_state = smem.rtu_state
while not rtu_state.shutdown do
local status, result = pcall(public.exec)
if status == false then
log.fatal(result)
end
if not rtu_state.shutdown then
log.info("rtu unit thread " .. unit.name .. "(" .. unit.type .. ") restarting in 5 seconds...")
util.psleep(5)
end
end
end
return public
end
return threads

73
scada-common/alarm.lua Normal file
View File

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

609
scada-common/comms.lua Normal file
View File

@@ -0,0 +1,609 @@
--
-- Communications
--
local log = require("scada-common.log")
local types = require("scada-common.types")
---@class comms
local comms = {}
local rtu_t = types.rtu_t
local insert = table.insert
---@alias PROTOCOLS integer
local PROTOCOLS = {
MODBUS_TCP = 0, -- our "MODBUS TCP"-esque protocol
RPLC = 1, -- reactor PLC protocol
SCADA_MGMT = 2, -- SCADA supervisor management, device advertisements, etc
COORD_DATA = 3, -- data/control packets for coordinators to/from supervisory controllers
COORD_API = 4 -- data/control packets for pocket computers to/from coordinators
}
---@alias RPLC_TYPES integer
local RPLC_TYPES = {
LINK_REQ = 0, -- linking requests
STATUS = 1, -- reactor/system status
MEK_STRUCT = 2, -- mekanism build structure
MEK_BURN_RATE = 3, -- set burn rate
RPS_ENABLE = 4, -- enable reactor
RPS_SCRAM = 5, -- SCRAM reactor
RPS_STATUS = 6, -- RPS status
RPS_ALARM = 7, -- RPS alarm broadcast
RPS_RESET = 8 -- clear RPS trip (if in bad state, will trip immediately)
}
---@alias RPLC_LINKING integer
local RPLC_LINKING = {
ALLOW = 0, -- link approved
DENY = 1, -- link denied
COLLISION = 2 -- link denied due to existing active link
}
---@alias SCADA_MGMT_TYPES integer
local SCADA_MGMT_TYPES = {
KEEP_ALIVE = 0, -- keep alive packet w/ RTT
CLOSE = 1, -- close a connection
RTU_ADVERT = 2, -- RTU capability advertisement
REMOTE_LINKED = 3 -- remote device linked
}
---@alias RTU_UNIT_TYPES integer
local RTU_UNIT_TYPES = {
REDSTONE = 0, -- redstone I/O
BOILER = 1, -- boiler
BOILER_VALVE = 2, -- boiler mekanism 10.1+
TURBINE = 3, -- turbine
TURBINE_VALVE = 4, -- turbine, mekanism 10.1+
EMACHINE = 5, -- energy machine
IMATRIX = 6 -- induction matrix
}
comms.PROTOCOLS = PROTOCOLS
comms.RPLC_TYPES = RPLC_TYPES
comms.RPLC_LINKING = RPLC_LINKING
comms.SCADA_MGMT_TYPES = SCADA_MGMT_TYPES
comms.RTU_UNIT_TYPES = RTU_UNIT_TYPES
-- generic SCADA packet object
comms.scada_packet = function ()
local self = {
modem_msg_in = nil,
valid = false,
raw = nil,
seq_num = nil,
protocol = nil,
length = nil,
payload = nil
}
---@class scada_packet
local public = {}
-- make a SCADA packet
---@param seq_num integer
---@param protocol PROTOCOLS
---@param payload table
public.make = function (seq_num, protocol, payload)
self.valid = true
self.seq_num = seq_num
self.protocol = protocol
self.length = #payload
self.payload = payload
self.raw = { self.seq_num, self.protocol, self.payload }
end
-- parse in a modem message as a SCADA packet
---@param side string
---@param sender integer
---@param reply_to integer
---@param message any
---@param distance integer
public.receive = function (side, sender, reply_to, message, distance)
self.modem_msg_in = {
iface = side,
s_port = sender,
r_port = reply_to,
msg = message,
dist = distance
}
self.raw = self.modem_msg_in.msg
if type(self.raw) == "table" then
if #self.raw >= 3 then
self.valid = true
self.seq_num = self.raw[1]
self.protocol = self.raw[2]
self.length = #self.raw[3]
self.payload = self.raw[3]
end
end
return self.valid
end
-- public accessors --
public.modem_event = function () return self.modem_msg_in end
public.raw_sendable = function () return self.raw end
public.local_port = function () return self.modem_msg_in.s_port end
public.remote_port = function () return self.modem_msg_in.r_port end
public.is_valid = function () return self.valid end
public.seq_num = function () return self.seq_num end
public.protocol = function () return self.protocol end
public.length = function () return self.length end
public.data = function () return self.payload end
return public
end
-- MODBUS packet
-- modeled after MODBUS TCP packet
comms.modbus_packet = function ()
local self = {
frame = nil,
raw = nil,
txn_id = nil,
length = nil,
unit_id = nil,
func_code = nil,
data = nil
}
---@class modbus_packet
local public = {}
-- make a MODBUS packet
---@param txn_id integer
---@param unit_id integer
---@param func_code MODBUS_FCODE
---@param data table
public.make = function (txn_id, unit_id, func_code, data)
self.txn_id = txn_id
self.length = #data
self.unit_id = unit_id
self.func_code = func_code
self.data = data
-- populate raw array
self.raw = { self.txn_id, self.unit_id, self.func_code }
for i = 1, self.length do
insert(self.raw, data[i])
end
end
-- decode a MODBUS packet from a SCADA frame
---@param frame scada_packet
---@return boolean success
public.decode = function (frame)
if frame then
self.frame = frame
if frame.protocol() == PROTOCOLS.MODBUS_TCP then
local size_ok = frame.length() >= 3
if size_ok then
local data = frame.data()
public.make(data[1], data[2], data[3], { table.unpack(data, 4, #data) })
end
return size_ok
else
log.debug("attempted MODBUS_TCP parse of incorrect protocol " .. frame.protocol(), true)
return false
end
else
log.debug("nil frame encountered", true)
return false
end
end
-- get raw to send
public.raw_sendable = function () return self.raw end
-- get this packet as a frame with an immutable relation to this object
public.get = function ()
---@class modbus_frame
local frame = {
scada_frame = self.frame,
txn_id = self.txn_id,
length = self.length,
unit_id = self.unit_id,
func_code = self.func_code,
data = self.data
}
return frame
end
return public
end
-- reactor PLC packet
comms.rplc_packet = function ()
local self = {
frame = nil,
raw = nil,
id = nil,
type = nil,
length = nil,
body = nil
}
---@class rplc_packet
local public = {}
-- check that type is known
local _rplc_type_valid = function ()
return self.type == RPLC_TYPES.LINK_REQ or
self.type == RPLC_TYPES.STATUS or
self.type == RPLC_TYPES.MEK_STRUCT or
self.type == RPLC_TYPES.MEK_BURN_RATE or
self.type == RPLC_TYPES.RPS_ENABLE or
self.type == RPLC_TYPES.RPS_SCRAM or
self.type == RPLC_TYPES.RPS_ALARM or
self.type == RPLC_TYPES.RPS_STATUS or
self.type == RPLC_TYPES.RPS_RESET
end
-- make an RPLC packet
---@param id integer
---@param packet_type RPLC_TYPES
---@param data table
public.make = function (id, packet_type, data)
-- packet accessor properties
self.id = id
self.type = packet_type
self.length = #data
self.data = data
-- populate raw array
self.raw = { self.id, self.type }
for i = 1, #data do
insert(self.raw, data[i])
end
end
-- decode an RPLC packet from a SCADA frame
---@param frame scada_packet
---@return boolean success
public.decode = function (frame)
if frame then
self.frame = frame
if frame.protocol() == PROTOCOLS.RPLC then
local ok = frame.length() >= 2
if ok then
local data = frame.data()
public.make(data[1], data[2], { table.unpack(data, 3, #data) })
ok = _rplc_type_valid()
end
return ok
else
log.debug("attempted RPLC parse of incorrect protocol " .. frame.protocol(), true)
return false
end
else
log.debug("nil frame encountered", true)
return false
end
end
-- get raw to send
public.raw_sendable = function () return self.raw end
-- get this packet as a frame with an immutable relation to this object
public.get = function ()
---@class rplc_frame
local frame = {
scada_frame = self.frame,
id = self.id,
type = self.type,
length = self.length,
data = self.data
}
return frame
end
return public
end
-- SCADA management packet
comms.mgmt_packet = function ()
local self = {
frame = nil,
raw = nil,
type = nil,
length = nil,
data = nil
}
---@class mgmt_packet
local public = {}
-- check that type is known
local _scada_type_valid = function ()
return self.type == SCADA_MGMT_TYPES.KEEP_ALIVE or
self.type == SCADA_MGMT_TYPES.CLOSE or
self.type == SCADA_MGMT_TYPES.REMOTE_LINKED or
self.type == SCADA_MGMT_TYPES.RTU_ADVERT
end
-- make a SCADA management packet
---@param packet_type SCADA_MGMT_TYPES
---@param data table
public.make = function (packet_type, data)
-- packet accessor properties
self.type = packet_type
self.length = #data
self.data = data
-- populate raw array
self.raw = { self.type }
for i = 1, #data do
insert(self.raw, data[i])
end
end
-- decode a SCADA management packet from a SCADA frame
---@param frame scada_packet
---@return boolean success
public.decode = function (frame)
if frame then
self.frame = frame
if frame.protocol() == PROTOCOLS.SCADA_MGMT then
local ok = frame.length() >= 1
if ok then
local data = frame.data()
public.make(data[1], { table.unpack(data, 2, #data) })
ok = _scada_type_valid()
end
return ok
else
log.debug("attempted SCADA_MGMT parse of incorrect protocol " .. frame.protocol(), true)
return false
end
else
log.debug("nil frame encountered", true)
return false
end
end
-- get raw to send
public.raw_sendable = function () return self.raw end
-- get this packet as a frame with an immutable relation to this object
public.get = function ()
---@class mgmt_frame
local frame = {
scada_frame = self.frame,
type = self.type,
length = self.length,
data = self.data
}
return frame
end
return public
end
-- SCADA coordinator packet
-- @todo
comms.coord_packet = function ()
local self = {
frame = nil,
raw = nil,
type = nil,
length = nil,
data = nil
}
---@class coord_packet
local public = {}
local _coord_type_valid = function ()
-- @todo
return false
end
-- make a coordinator packet
---@param packet_type any
---@param data table
public.make = function (packet_type, data)
-- packet accessor properties
self.type = packet_type
self.length = #data
self.data = data
-- populate raw array
self.raw = { self.type }
for i = 1, #data do
insert(self.raw, data[i])
end
end
-- decode a coordinator packet from a SCADA frame
---@param frame scada_packet
---@return boolean success
public.decode = function (frame)
if frame then
self.frame = frame
if frame.protocol() == PROTOCOLS.COORD_DATA then
local ok = frame.length() >= 1
if ok then
local data = frame.data()
public.make(data[1], { table.unpack(data, 2, #data) })
ok = _coord_type_valid()
end
return ok
else
log.debug("attempted COORD_DATA parse of incorrect protocol " .. frame.protocol(), true)
return false
end
else
log.debug("nil frame encountered", true)
return false
end
end
-- get raw to send
public.raw_sendable = function () return self.raw end
-- get this packet as a frame with an immutable relation to this object
public.get = function ()
---@class coord_frame
local frame = {
scada_frame = self.frame,
type = self.type,
length = self.length,
data = self.data
}
return frame
end
return public
end
-- coordinator API (CAPI) packet
-- @todo
comms.capi_packet = function ()
local self = {
frame = nil,
raw = nil,
type = nil,
length = nil,
data = nil
}
---@class capi_packet
local public = {}
local _coord_type_valid = function ()
-- @todo
return false
end
-- make a coordinator API packet
---@param packet_type any
---@param data table
public.make = function (packet_type, data)
-- packet accessor properties
self.type = packet_type
self.length = #data
self.data = data
-- populate raw array
self.raw = { self.type }
for i = 1, #data do
insert(self.raw, data[i])
end
end
-- decode a coordinator API packet from a SCADA frame
---@param frame scada_packet
---@return boolean success
public.decode = function (frame)
if frame then
self.frame = frame
if frame.protocol() == PROTOCOLS.COORD_API then
local ok = frame.length() >= 1
if ok then
local data = frame.data()
public.make(data[1], { table.unpack(data, 2, #data) })
ok = _coord_type_valid()
end
return ok
else
log.debug("attempted COORD_API parse of incorrect protocol " .. frame.protocol(), true)
return false
end
else
log.debug("nil frame encountered", true)
return false
end
end
-- get raw to send
public.raw_sendable = function () return self.raw end
-- get this packet as a frame with an immutable relation to this object
public.get = function ()
---@class capi_frame
local frame = {
scada_frame = self.frame,
type = self.type,
length = self.length,
data = self.data
}
return frame
end
return public
end
-- convert rtu_t to RTU unit type
---@param type rtu_t
---@return RTU_UNIT_TYPES|nil
comms.rtu_t_to_unit_type = function (type)
if type == rtu_t.redstone then
return RTU_UNIT_TYPES.REDSTONE
elseif type == rtu_t.boiler then
return RTU_UNIT_TYPES.BOILER
elseif type == rtu_t.boiler_valve then
return RTU_UNIT_TYPES.BOILER_VALVE
elseif type == rtu_t.turbine then
return RTU_UNIT_TYPES.TURBINE
elseif type == rtu_t.turbine_valve then
return RTU_UNIT_TYPES.TURBINE_VALVE
elseif type == rtu_t.energy_machine then
return RTU_UNIT_TYPES.EMACHINE
elseif type == rtu_t.induction_matrix then
return RTU_UNIT_TYPES.IMATRIX
end
return nil
end
-- convert RTU unit type to rtu_t
---@param utype RTU_UNIT_TYPES
---@return rtu_t|nil
comms.advert_type_to_rtu_t = function (utype)
if utype == RTU_UNIT_TYPES.REDSTONE then
return rtu_t.redstone
elseif utype == RTU_UNIT_TYPES.BOILER then
return rtu_t.boiler
elseif utype == RTU_UNIT_TYPES.BOILER_VALVE then
return rtu_t.boiler_valve
elseif utype == RTU_UNIT_TYPES.TURBINE then
return rtu_t.turbine
elseif utype == RTU_UNIT_TYPES.TURBINE_VALVE then
return rtu_t.turbine_valve
elseif utype == RTU_UNIT_TYPES.EMACHINE then
return rtu_t.energy_machine
elseif utype == RTU_UNIT_TYPES.IMATRIX then
return rtu_t.induction_matrix
end
return nil
end
return comms

205
scada-common/log.lua Normal file
View File

@@ -0,0 +1,205 @@
local util = require("scada-common.util")
--
-- File System Logger
--
---@class log
local log = {}
---@alias MODE integer
local MODE = {
APPEND = 0,
NEW = 1
}
log.MODE = MODE
-- whether to log debug messages or not
local LOG_DEBUG = true
local _log_sys = {
path = "/log.txt",
mode = MODE.APPEND,
file = nil,
dmesg_out = nil
}
---@type function
local free_space = fs.getFreeSpace
-- initialize logger
---@param path string file path
---@param write_mode MODE
---@param dmesg_redirect? table terminal/window to direct dmesg to
log.init = function (path, write_mode, dmesg_redirect)
_log_sys.path = path
_log_sys.mode = write_mode
if _log_sys.mode == MODE.APPEND then
_log_sys.file = fs.open(path, "a")
else
_log_sys.file = fs.open(path, "w")
end
if dmesg_redirect then
_log_sys.dmesg_out = dmesg_redirect
else
_log_sys.dmesg_out = term.current()
end
end
-- private log write function
---@param msg string
local _log = function (msg)
local time_stamp = os.date("[%c] ")
local stamped = time_stamp .. util.strval(msg)
-- attempt to write log
local status, result = pcall(function ()
_log_sys.file.writeLine(stamped)
_log_sys.file.flush()
end)
-- if we don't have space, we need to create a new log file
if not status then
if result == "Out of space" then
-- will delete log file
elseif result ~= nil then
util.println("unknown error writing to logfile: " .. result)
end
end
if (result == "Out of space") or (free_space(_log_sys.path) < 100) then
-- delete the old log file and open a new one
_log_sys.file.close()
fs.delete(_log_sys.path)
log.init(_log_sys.path, _log_sys.mode)
-- leave a message
_log_sys.file.writeLine(time_stamp .. "recycled log file")
_log_sys.file.writeLine(stamped)
_log_sys.file.flush()
end
end
-- write a message to the dmesg output
---@param msg string message to write
local _write = function (msg)
local out = _log_sys.dmesg_out
local out_w, out_h = out.getSize()
local lines = { msg }
-- wrap if needed
if string.len(msg) > out_w then
local remaining = true
local s_start = 1
local s_end = out_w
local i = 1
lines = {}
while remaining do
local line = string.sub(msg, s_start, s_end)
if line == "" then
remaining = false
else
lines[i] = line
s_start = s_end + 1
s_end = s_end + out_w
i = i + 1
end
end
end
-- output message
for i = 1, #lines do
local cur_x, cur_y = out.getCursorPos()
if cur_x > 1 then
if cur_y == out_h then
out.scroll(1)
out.setCursorPos(1, cur_y)
else
out.setCursorPos(1, cur_y + 1)
end
end
out.write(lines[i])
end
end
-- dmesg style logging for boot because I like linux-y things
---@param msg string message
---@param show_term? boolean whether or not to show on terminal output
log.dmesg = function (msg, show_term)
local message = string.format("[%10.3f] ", os.clock()) .. util.strval(msg)
if show_term then _write(message) end
_log(message)
end
-- log debug messages
---@param msg string message
---@param trace? boolean include file trace
log.debug = function (msg, trace)
if LOG_DEBUG then
local dbg_info = ""
if trace then
local info = debug.getinfo(2)
local name = ""
if info.name ~= nil then
name = ":" .. info.name .. "():"
end
dbg_info = info.short_src .. ":" .. name .. info.currentline .. " > "
end
_log("[DBG] " .. dbg_info .. util.strval(msg))
end
end
-- log info messages
---@param msg string message
log.info = function (msg)
_log("[INF] " .. util.strval(msg))
end
-- log warning messages
---@param msg string message
log.warning = function (msg)
_log("[WRN] " .. util.strval(msg))
end
-- log error messages
---@param msg string message
---@param trace? boolean include file trace
log.error = function (msg, trace)
local dbg_info = ""
if trace then
local info = debug.getinfo(2)
local name = ""
if info.name ~= nil then
name = ":" .. info.name .. "():"
end
dbg_info = info.short_src .. ":" .. name .. info.currentline .. " > "
end
_log("[ERR] " .. dbg_info .. util.strval(msg))
end
-- log fatal errors
---@param msg string message
log.fatal = function (msg)
_log("[FTL] " .. util.strval(msg))
end
return log

83
scada-common/mqueue.lua Normal file
View File

@@ -0,0 +1,83 @@
--
-- Message Queue
--
local mqueue = {}
---@alias TYPE integer
local TYPE = {
COMMAND = 0,
DATA = 1,
PACKET = 2
}
mqueue.TYPE = TYPE
-- create a new message queue
mqueue.new = function ()
local queue = {}
local insert = table.insert
local remove = table.remove
---@class queue_item
---@field qtype TYPE
---@field message any
---@class queue_data
---@field key any
---@field val any
---@class mqueue
local public = {}
-- get queue length
public.length = function () return #queue end
-- check if queue is empty
---@return boolean is_empty
public.empty = function () return #queue == 0 end
-- check if queue has contents
public.ready = function () return #queue ~= 0 end
-- push a new item onto the queue
---@param qtype TYPE
---@param message string
local _push = function (qtype, message)
insert(queue, { qtype = qtype, message = message })
end
-- push a command onto the queue
---@param message any
public.push_command = function (message)
_push(TYPE.COMMAND, message)
end
-- push data onto the queue
---@param key any
---@param value any
public.push_data = function (key, value)
_push(TYPE.DATA, { key = key, val = value })
end
-- push a packet onto the queue
---@param packet scada_packet|modbus_packet|rplc_packet|coord_packet|capi_packet
public.push_packet = function (packet)
_push(TYPE.PACKET, packet)
end
-- get an item off the queue
---@return queue_item|nil
public.pop = function ()
if #queue > 0 then
return remove(queue, 1)
else
return nil
end
end
return public
end
return mqueue

319
scada-common/ppm.lua Normal file
View File

@@ -0,0 +1,319 @@
local log = require("scada-common.log")
--
-- Protected Peripheral Manager
--
---@class ppm
local ppm = {}
local ACCESS_FAULT = nil ---@type nil
ppm.ACCESS_FAULT = ACCESS_FAULT
----------------------------
-- PRIVATE DATA/FUNCTIONS --
----------------------------
local REPORT_FREQUENCY = 20 -- log every 20 faults per function
local _ppm_sys = {
mounts = {},
auto_cf = false,
faulted = false,
last_fault = "",
terminate = false,
mute = false
}
-- wrap peripheral calls with lua protected call as we don't want a disconnect to crash a program
---
---also provides peripheral-specific fault checks (auto-clear fault defaults to true)
---
---assumes iface is a valid peripheral
---@param iface string CC peripheral interface
local peri_init = function (iface)
local self = {
faulted = false,
last_fault = "",
fault_counts = {},
auto_cf = true,
type = peripheral.getType(iface),
device = peripheral.wrap(iface)
}
-- initialization process (re-map)
for key, func in pairs(self.device) do
self.fault_counts[key] = 0
self.device[key] = function (...)
local status, result = pcall(func, ...)
if status then
-- auto fault clear
if self.auto_cf then self.faulted = false end
if _ppm_sys.auto_cf then _ppm_sys.faulted = false end
self.fault_counts[key] = 0
return result
else
-- function failed
self.faulted = true
self.last_fault = result
_ppm_sys.faulted = true
_ppm_sys.last_fault = result
if not _ppm_sys.mute and (self.fault_counts[key] % REPORT_FREQUENCY == 0) then
local count_str = ""
if self.fault_counts[key] > 0 then
count_str = " [" .. self.fault_counts[key] .. " total faults]"
end
log.error("PPM: protected " .. key .. "() -> " .. result .. count_str)
end
self.fault_counts[key] = self.fault_counts[key] + 1
if result == "Terminated" then
_ppm_sys.terminate = true
end
return ACCESS_FAULT
end
end
end
-- fault management functions
local clear_fault = function () self.faulted = false end
local get_last_fault = function () return self.last_fault end
local is_faulted = function () return self.faulted end
local is_ok = function () return not self.faulted end
local enable_afc = function () self.auto_cf = true end
local disable_afc = function () self.auto_cf = false end
-- append to device functions
self.device.__p_clear_fault = clear_fault
self.device.__p_last_fault = get_last_fault
self.device.__p_is_faulted = is_faulted
self.device.__p_is_ok = is_ok
self.device.__p_enable_afc = enable_afc
self.device.__p_disable_afc = disable_afc
return {
type = self.type,
dev = self.device
}
end
----------------------
-- PUBLIC FUNCTIONS --
----------------------
-- REPORTING --
-- silence error prints
ppm.disable_reporting = function ()
_ppm_sys.mute = true
end
-- allow error prints
ppm.enable_reporting = function ()
_ppm_sys.mute = false
end
-- FAULT MEMORY --
-- enable automatically clearing fault flag
ppm.enable_afc = function ()
_ppm_sys.auto_cf = true
end
-- disable automatically clearing fault flag
ppm.disable_afc = function ()
_ppm_sys.auto_cf = false
end
-- clear fault flag
ppm.clear_fault = function ()
_ppm_sys.faulted = false
end
-- check fault flag
ppm.is_faulted = function ()
return _ppm_sys.faulted
end
-- get the last fault message
ppm.get_last_fault = function ()
return _ppm_sys.last_fault
end
-- TERMINATION --
-- if a caught error was a termination request
ppm.should_terminate = function ()
return _ppm_sys.terminate
end
-- MOUNTING --
-- mount all available peripherals (clears mounts first)
ppm.mount_all = function ()
local ifaces = peripheral.getNames()
_ppm_sys.mounts = {}
for i = 1, #ifaces do
_ppm_sys.mounts[ifaces[i]] = peri_init(ifaces[i])
log.info("PPM: found a " .. _ppm_sys.mounts[ifaces[i]].type .. " (" .. ifaces[i] .. ")")
end
if #ifaces == 0 then
log.warning("PPM: mount_all() -> no devices found")
end
end
-- mount a particular device
---@param iface string CC peripheral interface
---@return string|nil type, table|nil device
ppm.mount = function (iface)
local ifaces = peripheral.getNames()
local pm_dev = nil
local pm_type = nil
for i = 1, #ifaces do
if iface == ifaces[i] then
_ppm_sys.mounts[iface] = peri_init(iface)
pm_type = _ppm_sys.mounts[iface].type
pm_dev = _ppm_sys.mounts[iface].dev
log.info("PPM: mount(" .. iface .. ") -> found a " .. pm_type)
break
end
end
return pm_type, pm_dev
end
-- handle peripheral_detach event
---@param iface string CC peripheral interface
---@return string|nil type, table|nil device
ppm.handle_unmount = function (iface)
local pm_dev = nil
local pm_type = nil
-- what got disconnected?
local lost_dev = _ppm_sys.mounts[iface]
if lost_dev then
pm_type = lost_dev.type
pm_dev = lost_dev.dev
log.warning("PPM: lost device " .. pm_type .. " mounted to " .. iface)
else
log.error("PPM: lost device unknown to the PPM mounted to " .. iface)
end
return pm_type, pm_dev
end
-- GENERAL ACCESSORS --
-- list all available peripherals
---@return table names
ppm.list_avail = function ()
return peripheral.getNames()
end
-- list mounted peripherals
---@return table mounts
ppm.list_mounts = function ()
return _ppm_sys.mounts
end
-- get a mounted peripheral by side/interface
---@param iface string CC peripheral interface
---@return table|nil device function table
ppm.get_periph = function (iface)
if _ppm_sys.mounts[iface] then
return _ppm_sys.mounts[iface].dev
else return nil end
end
-- get a mounted peripheral type by side/interface
---@param iface string CC peripheral interface
---@return string|nil type
ppm.get_type = function (iface)
if _ppm_sys.mounts[iface] then
return _ppm_sys.mounts[iface].type
else return nil end
end
-- get all mounted peripherals by type
---@param name string type name
---@return table devices device function tables
ppm.get_all_devices = function (name)
local devices = {}
for _, data in pairs(_ppm_sys.mounts) do
if data.type == name then
table.insert(devices, data.dev)
end
end
return devices
end
-- get a mounted peripheral by type (if multiple, returns the first)
---@param name string type name
---@return table|nil device function table
ppm.get_device = function (name)
local device = nil
for side, data in pairs(_ppm_sys.mounts) do
if data.type == name then
device = data.dev
break
end
end
return device
end
-- SPECIFIC DEVICE ACCESSORS --
-- get the fission reactor (if multiple, returns the first)
---@return table|nil reactor function table
ppm.get_fission_reactor = function ()
return ppm.get_device("fissionReactor")
end
-- get the wireless modem (if multiple, returns the first)
---@return table|nil modem function table
ppm.get_wireless_modem = function ()
local w_modem = nil
for _, device in pairs(_ppm_sys.mounts) do
if device.type == "modem" and device.dev.isWireless() then
w_modem = device.dev
break
end
end
return w_modem
end
-- list all connected monitors
---@return table monitors
ppm.list_monitors = function ()
return ppm.get_all_devices("monitor")
end
return ppm

289
scada-common/rsio.lua Normal file
View File

@@ -0,0 +1,289 @@
--
-- Redstone I/O
--
local rsio = {}
----------------------
-- RS I/O CONSTANTS --
----------------------
---@alias IO_LVL integer
local IO_LVL = {
LOW = 0,
HIGH = 1,
DISCONNECT = -1 -- use for RTU session to indicate this RTU is not connected to this channel
}
---@alias IO_DIR integer
local IO_DIR = {
IN = 0,
OUT = 1
}
---@alias IO_MODE integer
local IO_MODE = {
DIGITAL_IN = 0,
DIGITAL_OUT = 1,
ANALOG_IN = 2,
ANALOG_OUT = 3
}
---@alias RS_IO integer
local RS_IO = {
-- digital inputs --
-- facility
F_SCRAM = 1, -- active low, facility-wide scram
-- reactor
R_SCRAM = 2, -- active low, reactor scram
R_ENABLE = 3, -- active high, reactor enable
-- digital outputs --
-- facility
F_ALARM = 4, -- active high, facility safety alarm
-- waste
WASTE_PO = 5, -- active low, polonium routing
WASTE_PU = 6, -- active low, plutonium routing
WASTE_AM = 7, -- active low, antimatter routing
-- reactor
R_ALARM = 8, -- active high, reactor safety alarm
R_SCRAMMED = 9, -- active high, if the reactor is scrammed
R_AUTO_SCRAM = 10, -- active high, if the reactor was automatically scrammed
R_ACTIVE = 11, -- active high, if the reactor is active
R_AUTO_CTRL = 12, -- active high, if the reactor burn rate is automatic
R_DMG_CRIT = 13, -- active high, if the reactor damage is critical
R_HIGH_TEMP = 14, -- active high, if the reactor is at a high temperature
R_NO_COOLANT = 15, -- active high, if the reactor has no coolant
R_EXCESS_HC = 16, -- active high, if the reactor has excess heated coolant
R_EXCESS_WS = 17, -- active high, if the reactor has excess waste
R_INSUFF_FUEL = 18, -- active high, if the reactor has insufficent fuel
R_PLC_FAULT = 19, -- active high, if the reactor PLC reports a device access fault
R_PLC_TIMEOUT = 20 -- active high, if the reactor PLC has not been heard from
}
rsio.IO_LVL = IO_LVL
rsio.IO_DIR = IO_DIR
rsio.IO_MODE = IO_MODE
rsio.IO = RS_IO
-----------------------
-- UTILITY FUNCTIONS --
-----------------------
-- channel to string
---@param channel RS_IO
rsio.to_string = function (channel)
local names = {
"F_SCRAM",
"R_SCRAM",
"R_ENABLE",
"F_ALARM",
"WASTE_PO",
"WASTE_PU",
"WASTE_AM",
"R_ALARM",
"R_SCRAMMED",
"R_AUTO_SCRAM",
"R_ACTIVE",
"R_AUTO_CTRL",
"R_DMG_CRIT",
"R_HIGH_TEMP",
"R_NO_COOLANT",
"R_EXCESS_HC",
"R_EXCESS_WS",
"R_INSUFF_FUEL",
"R_PLC_FAULT",
"R_PLC_TIMEOUT"
}
if type(channel) == "number" and channel > 0 and channel <= #names then
return names[channel]
else
return ""
end
end
local _B_AND = bit.band
local function _ACTIVE_HIGH(level) return level == IO_LVL.HIGH end
local function _ACTIVE_LOW(level) return level == IO_LVL.LOW end
-- I/O mappings to I/O function and I/O mode
local RS_DIO_MAP = {
-- F_SCRAM
{ _f = _ACTIVE_LOW, mode = IO_DIR.IN },
-- R_SCRAM
{ _f = _ACTIVE_LOW, mode = IO_DIR.IN },
-- R_ENABLE
{ _f = _ACTIVE_HIGH, mode = IO_DIR.IN },
-- F_ALARM
{ _f = _ACTIVE_HIGH, mode = IO_DIR.OUT },
-- WASTE_PO
{ _f = _ACTIVE_LOW, mode = IO_DIR.OUT },
-- WASTE_PU
{ _f = _ACTIVE_LOW, mode = IO_DIR.OUT },
-- WASTE_AM
{ _f = _ACTIVE_LOW, mode = IO_DIR.OUT },
-- R_ALARM
{ _f = _ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_SCRAMMED
{ _f = _ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_AUTO_SCRAM
{ _f = _ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_ACTIVE
{ _f = _ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_AUTO_CTRL
{ _f = _ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_DMG_CRIT
{ _f = _ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_HIGH_TEMP
{ _f = _ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_NO_COOLANT
{ _f = _ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_EXCESS_HC
{ _f = _ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_EXCESS_WS
{ _f = _ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_INSUFF_FUEL
{ _f = _ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_PLC_FAULT
{ _f = _ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_PLC_TIMEOUT
{ _f = _ACTIVE_HIGH, mode = IO_DIR.OUT }
}
-- get the mode of a channel
---@param channel RS_IO
---@return IO_MODE
rsio.get_io_mode = function (channel)
local modes = {
IO_MODE.DIGITAL_IN, -- F_SCRAM
IO_MODE.DIGITAL_IN, -- R_SCRAM
IO_MODE.DIGITAL_IN, -- R_ENABLE
IO_MODE.DIGITAL_OUT, -- F_ALARM
IO_MODE.DIGITAL_OUT, -- WASTE_PO
IO_MODE.DIGITAL_OUT, -- WASTE_PU
IO_MODE.DIGITAL_OUT, -- WASTE_AM
IO_MODE.DIGITAL_OUT, -- R_ALARM
IO_MODE.DIGITAL_OUT, -- R_SCRAMMED
IO_MODE.DIGITAL_OUT, -- R_AUTO_SCRAM
IO_MODE.DIGITAL_OUT, -- R_ACTIVE
IO_MODE.DIGITAL_OUT, -- R_AUTO_CTRL
IO_MODE.DIGITAL_OUT, -- R_DMG_CRIT
IO_MODE.DIGITAL_OUT, -- R_HIGH_TEMP
IO_MODE.DIGITAL_OUT, -- R_NO_COOLANT
IO_MODE.DIGITAL_OUT, -- R_EXCESS_HC
IO_MODE.DIGITAL_OUT, -- R_EXCESS_WS
IO_MODE.DIGITAL_OUT, -- R_INSUFF_FUEL
IO_MODE.DIGITAL_OUT, -- R_PLC_FAULT
IO_MODE.DIGITAL_OUT -- R_PLC_TIMEOUT
}
if type(channel) == "number" and channel > 0 and channel <= #modes then
return modes[channel]
else
return IO_MODE.ANALOG_IN
end
end
--------------------
-- GENERIC CHECKS --
--------------------
local RS_SIDES = rs.getSides()
-- check if a channel is valid
---@param channel RS_IO
---@return boolean valid
rsio.is_valid_channel = function (channel)
return (type(channel) == "number") and (channel > 0) and (channel <= RS_IO.R_PLC_TIMEOUT)
end
-- check if a side is valid
---@param side string
---@return boolean valid
rsio.is_valid_side = function (side)
if side ~= nil then
for i = 0, #RS_SIDES do
if RS_SIDES[i] == side then return true end
end
end
return false
end
-- check if a color is a valid single color
---@param color integer
---@return boolean valid
rsio.is_color = function (color)
return (type(color) == "number") and (color > 0) and (_B_AND(color, (color - 1)) == 0);
end
-----------------
-- DIGITAL I/O --
-----------------
-- get digital IO level reading
---@param rs_value boolean
---@return IO_LVL
rsio.digital_read = function (rs_value)
if rs_value then
return IO_LVL.HIGH
else
return IO_LVL.LOW
end
end
-- returns the level corresponding to active
---@param channel RS_IO
---@param level IO_LVL
---@return boolean
rsio.digital_write = function (channel, level)
if type(channel) ~= "number" or channel < RS_IO.F_ALARM or channel > RS_IO.R_PLC_TIMEOUT then
return false
else
return RS_DIO_MAP[channel]._f(level)
end
end
-- returns true if the level corresponds to active
---@param channel RS_IO
---@param level IO_LVL
---@return boolean
rsio.digital_is_active = function (channel, level)
if type(channel) ~= "number" or channel > RS_IO.R_ENABLE then
return false
else
return RS_DIO_MAP[channel]._f(level)
end
end
----------------
-- ANALOG I/O --
----------------
-- read an analog value scaled from min to max
---@param rs_value number redstone reading (0 to 15)
---@param min number minimum of range
---@param max number maximum of range
---@return number value scaled reading (min to max)
rsio.analog_read = function (rs_value, min, max)
local value = rs_value / 15
return (value * (max - min)) + min
end
-- write an analog value from the provided scale range
---@param value number value to write (from min to max range)
---@param min number minimum of range
---@param max number maximum of range
---@return number rs_value scaled redstone reading (0 to 15)
rsio.analog_write = function (value, min, max)
local scaled_value = (value - min) / (max - min)
return scaled_value * 15
end
return rsio

100
scada-common/types.lua Normal file
View File

@@ -0,0 +1,100 @@
--
-- Global Types
--
---@class types
local types = {}
-- CLASSES --
---@class tank_fluid
---@field name string
---@field amount integer
---@class coordinate
---@field x integer
---@field y integer
---@field z integer
---@class rtu_advertisement
---@field type integer
---@field index integer
---@field reactor integer
---@field rsio table|nil
-- ENUMERATION TYPES --
---@alias TRI_FAIL integer
types.TRI_FAIL = {
OK = 0,
PARTIAL = 1,
FULL = 2
}
-- STRING TYPES --
---@alias rtu_t string
types.rtu_t = {
redstone = "redstone",
boiler = "boiler",
boiler_valve = "boiler_valve",
turbine = "turbine",
turbine_valve = "turbine_valve",
energy_machine = "emachine",
induction_matrix = "induction_matrix"
}
---@alias rps_status_t string
types.rps_status_t = {
ok = "ok",
dmg_crit = "dmg_crit",
high_temp = "high_temp",
no_coolant = "no_coolant",
ex_waste = "full_waste",
ex_hcoolant = "heated_coolant_backup",
no_fuel = "no_fuel",
fault = "fault",
timeout = "timeout",
manual = "manual"
}
-- turbine steam dumping modes
---@alias DUMPING_MODE string
types.DUMPING_MODE = {
IDLE = "IDLE",
DUMPING = "DUMPING",
DUMPING_EXCESS = "DUMPING_EXCESS"
}
-- MODBUS
-- modbus function codes
---@alias MODBUS_FCODE integer
types.MODBUS_FCODE = {
READ_COILS = 0x01,
READ_DISCRETE_INPUTS = 0x02,
READ_MUL_HOLD_REGS = 0x03,
READ_INPUT_REGS = 0x04,
WRITE_SINGLE_COIL = 0x05,
WRITE_SINGLE_HOLD_REG = 0x06,
WRITE_MUL_COILS = 0x0F,
WRITE_MUL_HOLD_REGS = 0x10,
ERROR_FLAG = 0x80
}
-- modbus exception codes
---@alias MODBUS_EXCODE integer
types.MODBUS_EXCODE = {
ILLEGAL_FUNCTION = 0x01,
ILLEGAL_DATA_ADDR = 0x02,
ILLEGAL_DATA_VALUE = 0x03,
SERVER_DEVICE_FAIL = 0x04,
ACKNOWLEDGE = 0x05,
SERVER_DEVICE_BUSY = 0x06,
NEG_ACKNOWLEDGE = 0x07,
MEMORY_PARITY_ERROR = 0x08,
GATEWAY_PATH_UNAVAILABLE = 0x0A,
GATEWAY_TARGET_TIMEOUT = 0x0B
}
return types

262
scada-common/util.lua Normal file
View File

@@ -0,0 +1,262 @@
--
-- Utility Functions
--
---@class util
local util = {}
-- PRINT --
-- print
---@param message any
util.print = function (message)
term.write(tostring(message))
end
-- print line
---@param message any
util.println = function (message)
print(tostring(message))
end
-- timestamped print
---@param message any
util.print_ts = function (message)
term.write(os.date("[%H:%M:%S] ") .. tostring(message))
end
-- timestamped print line
---@param message any
util.println_ts = function (message)
print(os.date("[%H:%M:%S] ") .. tostring(message))
end
-- STRING TOOLS --
-- get a value as a string
---@param val any
---@return string
util.strval = function (val)
local t = type(val)
if t == "table" or t == "function" then
return "[" .. tostring(val) .. "]"
else
return tostring(val)
end
end
-- concatenation with built-in to string
---@vararg any
---@return string
util.concat = function (...)
local str = ""
for _, v in ipairs(arg) do
str = str .. util.strval(v)
end
return str
end
-- sprintf implementation
---@param format string
---@vararg any
util.sprintf = function (format, ...)
return string.format(format, table.unpack(arg))
end
-- TIME --
-- current time
---@return integer milliseconds
util.time_ms = function ()
---@diagnostic disable-next-line: undefined-field
return os.epoch('local')
end
-- current time
---@return number seconds
util.time_s = function ()
---@diagnostic disable-next-line: undefined-field
return os.epoch('local') / 1000.0
end
-- current time
---@return integer milliseconds
util.time = function ()
return util.time_ms()
end
-- PARALLELIZATION --
-- protected sleep call so we still are in charge of catching termination
---@param t integer seconds
--- EVENT_CONSUMER: this function consumes events
util.psleep = function (t)
---@diagnostic disable-next-line: undefined-field
pcall(os.sleep, t)
end
-- no-op to provide a brief pause (1 tick) to yield
---
--- EVENT_CONSUMER: this function consumes events
util.nop = function ()
util.psleep(0.05)
end
-- attempt to maintain a minimum loop timing (duration of execution)
---@param target_timing integer minimum amount of milliseconds to wait for
---@param last_update integer millisecond time of last update
---@return integer time_now
-- EVENT_CONSUMER: this function consumes events
util.adaptive_delay = function (target_timing, last_update)
local sleep_for = target_timing - (util.time() - last_update)
-- only if >50ms since worker loops already yield 0.05s
if sleep_for >= 50 then
util.psleep(sleep_for / 1000.0)
end
return util.time()
end
-- TABLE UTILITIES --
-- delete elements from a table if the passed function returns false when passed a table element
--
-- put briefly: deletes elements that return false, keeps elements that return true
---@param t table table to remove elements from
---@param f function should return false to delete an element when passed the element: f(elem) = true|false
---@param on_delete? function optional function to execute on deletion, passed the table element to be deleted as the parameter
util.filter_table = function (t, f, on_delete)
local move_to = 1
for i = 1, #t do
local element = t[i]
if element ~= nil then
if f(element) then
if t[move_to] == nil then
t[move_to] = element
t[i] = nil
end
move_to = move_to + 1
else
if on_delete then on_delete(element) end
t[i] = nil
end
end
end
end
-- check if a table contains the provided element
---@param t table table to check
---@param element any element to check for
util.table_contains = function (t, element)
for i = 1, #t do
if t[i] == element then return true end
end
return false
end
-- MEKANISM POWER --
-- function util.kFE(fe) return fe / 1000.0 end
-- function util.MFE(fe) return fe / 1000000.0 end
-- function util.GFE(fe) return fe / 1000000000.0 end
-- function util.TFE(fe) return fe / 1000000000000.0 end
-- -- FLOATING POINT PRINTS --
-- local function fractional_1s(number)
-- return number == math.round(number)
-- end
-- local function fractional_10ths(number)
-- number = number * 10
-- return number == math.round(number)
-- end
-- local function fractional_100ths(number)
-- number = number * 100
-- return number == math.round(number)
-- end
-- function util.power_format(fe)
-- if fe < 1000 then
-- return string.format("%.2f FE", fe)
-- elseif fe < 1000000 then
-- return string.format("%.3f kFE", kFE(fe))
-- end
-- end
-- WATCHDOG --
-- ComputerCraft OS Timer based Watchdog
---@param timeout number timeout duration
---
--- triggers a timer event if not fed within 'timeout' seconds
util.new_watchdog = function (timeout)
---@diagnostic disable-next-line: undefined-field
local start_timer = os.startTimer
---@diagnostic disable-next-line: undefined-field
local cancel_timer = os.cancelTimer
local self = {
timeout = timeout,
wd_timer = start_timer(timeout)
}
---@class watchdog
local public = {}
---@param timer number timer event timer ID
public.is_timer = function (timer)
return self.wd_timer == timer
end
-- satiate the beast
public.feed = function ()
if self.wd_timer ~= nil then
cancel_timer(self.wd_timer)
end
self.wd_timer = start_timer(self.timeout)
end
-- cancel the watchdog
public.cancel = function ()
if self.wd_timer ~= nil then
cancel_timer(self.wd_timer)
end
end
return public
end
-- LOOP CLOCK --
-- ComputerCraft OS Timer based Loop Clock
---@param period number clock period
---
--- fires a timer event at the specified period, does not start at construct time
util.new_clock = function (period)
---@diagnostic disable-next-line: undefined-field
local start_timer = os.startTimer
local self = {
period = period,
timer = nil
}
---@class clock
local public = {}
---@param timer number timer event timer ID
public.is_clock = function (timer)
return self.timer == timer
end
-- start the clock
public.start = function ()
self.timer = start_timer(self.period)
end
return public
end
return util

View File

@@ -1,159 +0,0 @@
-- reactor signal router
-- transmits status information and controls enable state
-- bundeled redstone key
-- top:
-- black (in): insufficent fuel
-- brown (in): excess waste
-- orange (in): overheat
-- red (in): damage critical
-- right:
-- cyan (out): plutonium/plutonium pellet pipe
-- green (out): polonium pipe
-- magenta (out): polonium pellet pipe
-- purple (out): antimatter pipe
-- white (out): reactor enable
-- constants
REACTOR_ID = 1
DEST_PORT = 1000
local state = {
id = REACTOR_ID,
run = false,
no_fuel = false,
full_waste = false,
high_temp = false,
damage_crit = false
}
local waste_production = "antimatter"
local listen_port = 1000 + REACTOR_ID
local modem = peripheral.wrap("left")
print("Reactor Signal Router v1.0")
print("Configured for Reactor #" .. REACTOR_ID)
if not modem.isOpen(listen_port) then
modem.open(listen_port)
end
-- greeting
modem.transmit(DEST_PORT, listen_port, REACTOR_ID)
-- queue event to read initial state and make sure reactor starts off
os.queueEvent("redstone")
rs.setBundledOutput("right", colors.white)
rs.setBundledOutput("right", 0)
re_eval_output = true
local connection_timeout = os.startTimer(3)
-- event loop
while true do
local event, param1, param2, param3, param4, param5 = os.pullEvent()
if event == "redstone" then
-- redstone state change
input = rs.getBundledInput("top")
if state.no_fuel ~= colors.test(input, colors.black) then
state.no_fuel = colors.test(input, colors.black)
if state.no_fuel then
print("insufficient fuel")
end
end
if state.full_waste ~= colors.test(input, colors.brown) then
state.full_waste = colors.test(input, colors.brown)
if state.full_waste then
print("waste tank full")
end
end
if state.high_temp ~= colors.test(input, colors.orange) then
state.high_temp = colors.test(input, colors.orange)
if state.high_temp then
print("high temperature")
end
end
if state.damage_crit ~= colors.test(input, colors.red) then
state.damage_crit = colors.test(input, colors.red)
if state.damage_crit then
print("damage critical")
end
end
elseif event == "modem_message" then
-- got data, reset timer
if connection_timeout ~= nil then
os.cancelTimer(connection_timeout)
end
connection_timeout = os.startTimer(3)
if type(param4) == "number" and param4 == 0 then
print("[info] controller server startup detected")
modem.transmit(DEST_PORT, listen_port, REACTOR_ID)
elseif type(param4) == "number" and param4 == 1 then
-- keep-alive, do nothing, just had to reset timer
elseif type(param4) == "boolean" then
state.run = param4
if state.run then
print("[alert] reactor enabled")
else
print("[alert] reactor disabled")
end
re_eval_output = true
elseif type(param4) == "string" then
if param4 == "plutonium" then
print("[alert] switching to plutonium production")
waste_production = param4
re_eval_output = true
elseif param4 == "polonium" then
print("[alert] switching to polonium production")
waste_production = param4
re_eval_output = true
elseif param4 == "antimatter" then
print("[alert] switching to antimatter production")
waste_production = param4
re_eval_output = true
end
else
print("[error] got unknown packet (" .. param4 .. ")")
end
elseif event == "timer" and param1 == connection_timeout then
-- haven't heard from server in 3 seconds? shutdown
-- timer won't be restarted until next packet, so no need to do anything with it
print("[alert] server timeout, reactor disabled")
state.run = false
re_eval_output = true
end
-- check for control state changes
if re_eval_output then
re_eval_output = false
local run_color = 0
if state.run then
run_color = colors.white
end
-- values are swapped, as on disables and off enables
local waste_color
if waste_production == "plutonium" then
waste_color = colors.green
elseif waste_production == "polonium" then
waste_color = colors.cyan + colors.purple
else
-- antimatter (default)
waste_color = colors.cyan + colors.magenta
end
rs.setBundledOutput("right", run_color + waste_color)
end
modem.transmit(DEST_PORT, listen_port, state)
end

50
startup.lua Normal file
View File

@@ -0,0 +1,50 @@
local util = require("scada-common.util")
local BOOTLOADER_VERSION = "0.2"
local println = util.println
local println_ts = util.println_ts
println("SCADA BOOTLOADER V" .. BOOTLOADER_VERSION)
local exit_code = false
println_ts("BOOT> SCANNING FOR APPLICATIONS...")
if fs.exists("reactor-plc/startup.lua") then
-- found reactor-plc application
println("BOOT> FOUND REACTOR PLC APPLICATION")
println("BOOT> EXEC STARTUP")
exit_code = shell.execute("reactor-plc/startup")
elseif fs.exists("rtu/startup.lua") then
-- found rtu application
println("BOOT> FOUND RTU APPLICATION")
println("BOOT> EXEC STARTUP")
exit_code = shell.execute("rtu/startup")
elseif fs.exists("supervisor/startup.lua") then
-- found supervisor application
println("BOOT> FOUND SUPERVISOR APPLICATION")
println("BOOT> EXEC STARTUP")
exit_code = shell.execute("supervisor/startup")
elseif fs.exists("coordinator/startup.lua") then
-- found coordinator application
println("BOOT> FOUND COORDINATOR APPLICATION")
println("BOOT> EXEC STARTUP")
exit_code = shell.execute("coordinator/startup")
elseif fs.exists("pocket/startup.lua") then
-- found pocket application
println("BOOT> FOUND POCKET APPLICATION")
println("BOOT> EXEC STARTUP")
exit_code = shell.execute("pocket/startup")
else
-- no known applications found
println("BOOT> NO SCADA STARTUP APPLICATION FOUND")
println("BOOT> EXIT")
return false
end
if not exit_code then
println_ts("BOOT> APPLICATION CRASHED")
end
return exit_code

23
supervisor/config.lua Normal file
View File

@@ -0,0 +1,23 @@
local config = {}
-- scada network listen for PLC's and RTU's
config.SCADA_DEV_LISTEN = 16000
-- listen port for SCADA supervisor access by coordinators
config.SCADA_SV_LISTEN = 16100
-- expected number of reactors
config.NUM_REACTORS = 4
-- expected number of boilers/turbines for each reactor
config.REACTOR_COOLING = {
{ BOILERS = 1, TURBINES = 1 }, -- reactor unit 1
{ BOILERS = 1, TURBINES = 1 }, -- reactor unit 2
{ BOILERS = 1, TURBINES = 1 }, -- reactor unit 3
{ BOILERS = 1, TURBINES = 1 } -- reactor unit 4
}
-- 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

View File

@@ -0,0 +1,3 @@
local coordinator = {}
return coordinator

621
supervisor/session/plc.lua Normal file
View File

@@ -0,0 +1,621 @@
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 plc = {}
local PROTOCOLS = comms.PROTOCOLS
local RPLC_TYPES = comms.RPLC_TYPES
local SCADA_MGMT_TYPES = comms.SCADA_MGMT_TYPES
local print = util.print
local println = util.println
local print_ts = util.print_ts
local println_ts = util.println_ts
-- retry time constants in ms
local INITIAL_WAIT = 1500
local RETRY_PERIOD = 1000
local PLC_S_CMDS = {
SCRAM = 0,
ENABLE = 1,
RPS_RESET = 2
}
local PLC_S_DATA = {
BURN_RATE = 1,
RAMP_BURN_RATE = 2
}
plc.PLC_S_CMDS = PLC_S_CMDS
plc.PLC_S_DATA = PLC_S_DATA
local PERIODICS = {
KEEP_ALIVE = 2.0
}
-- PLC supervisor session
---@param id integer
---@param for_reactor integer
---@param in_queue mqueue
---@param out_queue mqueue
plc.new_session = function (id, for_reactor, in_queue, out_queue)
local log_header = "plc_session(" .. id .. "): "
local self = {
id = id,
for_reactor = for_reactor,
in_q = in_queue,
out_q = out_queue,
commanded_state = false,
commanded_burn_rate = 0.0,
ramping_rate = false,
-- connection properties
seq_num = 0,
r_seq_num = nil,
connected = true,
received_struct = false,
received_status_cache = false,
plc_conn_watchdog = util.new_watchdog(3),
last_rtt = 0,
-- periodic messages
periodics = {
last_update = 0,
keep_alive = 0
},
-- when to next retry one of these requests
retry_times = {
struct_req = (util.time() + 500),
status_req = (util.time() + 500),
scram_req = 0,
enable_req = 0,
burn_rate_req = 0,
rps_reset_req = 0
},
-- command acknowledgements
acks = {
scram = true,
enable = true,
burn_rate = true,
rps_reset = true
},
-- session database
---@class reactor_db
sDB = {
last_status_update = 0,
control_state = false,
overridden = false,
degraded = false,
rps_tripped = false,
rps_trip_cause = "ok",
---@class rps_status
rps_status = {
dmg_crit = false,
ex_hcool = false,
ex_waste = false,
high_temp = false,
no_fuel = false,
no_cool = false,
timed_out = false
},
---@class mek_status
mek_status = {
heating_rate = 0.0,
status = false,
burn_rate = 0.0,
act_burn_rate = 0.0,
temp = 0.0,
damage = 0.0,
boil_eff = 0.0,
env_loss = 0.0,
fuel = 0,
fuel_need = 0,
fuel_fill = 0.0,
waste = 0,
waste_need = 0,
waste_fill = 0.0,
ccool_type = "?",
ccool_amnt = 0,
ccool_need = 0,
ccool_fill = 0.0,
hcool_type = "?",
hcool_amnt = 0,
hcool_need = 0,
hcool_fill = 0.0
},
---@class mek_struct
mek_struct = {
heat_cap = 0,
fuel_asm = 0,
fuel_sa = 0,
fuel_cap = 0,
waste_cap = 0,
ccool_cap = 0,
hcool_cap = 0,
max_burn = 0.0
}
}
}
---@class plc_session
local public = {}
-- copy in the RPS status
---@param rps_status table
local _copy_rps_status = function (rps_status)
self.sDB.rps_status.dmg_crit = rps_status[1]
self.sDB.rps_status.ex_hcool = rps_status[2]
self.sDB.rps_status.ex_waste = rps_status[3]
self.sDB.rps_status.high_temp = rps_status[4]
self.sDB.rps_status.no_fuel = rps_status[5]
self.sDB.rps_status.no_cool = rps_status[6]
self.sDB.rps_status.timed_out = rps_status[7]
end
-- copy in the reactor status
---@param mek_data table
local _copy_status = function (mek_data)
-- copy status information
self.sDB.mek_status.status = mek_data[1]
self.sDB.mek_status.burn_rate = mek_data[2]
self.sDB.mek_status.act_burn_rate = mek_data[3]
self.sDB.mek_status.temp = mek_data[4]
self.sDB.mek_status.damage = mek_data[5]
self.sDB.mek_status.boil_eff = mek_data[6]
self.sDB.mek_status.env_loss = mek_data[7]
-- copy container information
self.sDB.mek_status.fuel = mek_data[8]
self.sDB.mek_status.fuel_fill = mek_data[9]
self.sDB.mek_status.waste = mek_data[10]
self.sDB.mek_status.waste_fill = mek_data[11]
self.sDB.mek_status.ccool_type = mek_data[12]
self.sDB.mek_status.ccool_amnt = mek_data[13]
self.sDB.mek_status.ccool_fill = mek_data[14]
self.sDB.mek_status.hcool_type = mek_data[15]
self.sDB.mek_status.hcool_amnt = mek_data[16]
self.sDB.mek_status.hcool_fill = mek_data[17]
-- update computable fields if we have our structure
if self.received_struct then
self.sDB.mek_status.fuel_need = self.sDB.mek_struct.fuel_cap - self.sDB.mek_status.fuel_fill
self.sDB.mek_status.waste_need = self.sDB.mek_struct.waste_cap - self.sDB.mek_status.waste_fill
self.sDB.mek_status.cool_need = self.sDB.mek_struct.ccool_cap - self.sDB.mek_status.ccool_fill
self.sDB.mek_status.hcool_need = self.sDB.mek_struct.hcool_cap - self.sDB.mek_status.hcool_fill
end
end
-- copy in the reactor structure
---@param mek_data table
local _copy_struct = function (mek_data)
self.sDB.mek_struct.heat_cap = mek_data[1]
self.sDB.mek_struct.fuel_asm = mek_data[2]
self.sDB.mek_struct.fuel_sa = mek_data[3]
self.sDB.mek_struct.fuel_cap = mek_data[4]
self.sDB.mek_struct.waste_cap = mek_data[5]
self.sDB.mek_struct.ccool_cap = mek_data[6]
self.sDB.mek_struct.hcool_cap = mek_data[7]
self.sDB.mek_struct.max_burn = mek_data[8]
end
-- mark this PLC session as closed, stop watchdog
local _close = function ()
self.plc_conn_watchdog.cancel()
self.connected = false
end
-- send an RPLC packet
---@param msg_type RPLC_TYPES
---@param msg table
local _send = function (msg_type, msg)
local s_pkt = comms.scada_packet()
local r_pkt = comms.rplc_packet()
r_pkt.make(self.id, msg_type, msg)
s_pkt.make(self.seq_num, PROTOCOLS.RPLC, r_pkt.raw_sendable())
self.out_q.push_packet(s_pkt)
self.seq_num = self.seq_num + 1
end
-- send a SCADA management packet
---@param msg_type SCADA_MGMT_TYPES
---@param msg table
local _send_mgmt = function (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, PROTOCOLS.SCADA_MGMT, m_pkt.raw_sendable())
self.out_q.push_packet(s_pkt)
self.seq_num = self.seq_num + 1
end
-- get an ACK status
---@param pkt rplc_frame
---@return boolean|nil ack
local _get_ack = function (pkt)
if pkt.length == 1 then
return pkt.data[1]
else
log.warning(log_header .. "RPLC ACK length mismatch")
return nil
end
end
-- handle a packet
---@param pkt rplc_frame
local _handle_packet = function (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 >= 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
-- process packet
if pkt.scada_frame.protocol() == PROTOCOLS.RPLC then
-- check reactor ID
if pkt.id ~= for_reactor then
log.warning(log_header .. "RPLC packet with ID not matching reactor ID: reactor " .. self.for_reactor .. " != " .. pkt.id)
return
end
-- feed watchdog
self.plc_conn_watchdog.feed()
-- handle packet by type
if pkt.type == RPLC_TYPES.STATUS then
-- status packet received, update data
if pkt.length >= 5 then
self.sDB.last_status_update = pkt.data[1]
self.sDB.control_state = pkt.data[2]
self.sDB.overridden = pkt.data[3]
self.sDB.degraded = pkt.data[4]
self.sDB.mek_status.heating_rate = pkt.data[5]
-- attempt to read mek_data table
if pkt.data[6] ~= nil then
local status = pcall(_copy_status, pkt.data[6])
if status then
-- copied in status data OK
self.received_status_cache = true
else
-- error copying status data
log.error(log_header .. "failed to parse status packet data")
end
end
else
log.debug(log_header .. "RPLC status packet length mismatch")
end
elseif pkt.type == RPLC_TYPES.MEK_STRUCT then
-- received reactor structure, record it
if pkt.length == 8 then
local status = pcall(_copy_struct, pkt.data)
if status then
-- copied in structure data OK
self.received_struct = true
else
-- error copying structure data
log.error(log_header .. "failed to parse struct packet data")
end
else
log.debug(log_header .. "RPLC struct packet length mismatch")
end
elseif pkt.type == RPLC_TYPES.MEK_BURN_RATE then
-- burn rate acknowledgement
local ack = _get_ack(pkt)
if ack then
self.acks.burn_rate = true
elseif ack == false then
log.debug(log_header .. "burn rate update failed!")
end
elseif pkt.type == RPLC_TYPES.RPS_ENABLE then
-- enable acknowledgement
local ack = _get_ack(pkt)
if ack then
self.acks.enable = true
self.sDB.control_state = true
elseif ack == false then
log.debug(log_header .. "enable failed!")
end
elseif pkt.type == RPLC_TYPES.RPS_SCRAM then
-- SCRAM acknowledgement
local ack = _get_ack(pkt)
if ack then
self.acks.scram = true
self.sDB.control_state = false
elseif ack == false then
log.debug(log_header .. "SCRAM failed!")
end
elseif pkt.type == RPLC_TYPES.RPS_STATUS then
-- RPS status packet received, copy data
if pkt.length == 7 then
local status = pcall(_copy_rps_status, pkt.data)
if status then
-- copied in RPS status data OK
else
-- error copying RPS status data
log.error(log_header .. "failed to parse RPS status packet data")
end
else
log.debug(log_header .. "RPLC RPS status packet length mismatch")
end
elseif pkt.type == RPLC_TYPES.RPS_ALARM then
-- RPS alarm
self.sDB.overridden = true
if pkt.length == 8 then
self.sDB.rps_tripped = true
self.sDB.rps_trip_cause = pkt.data[1]
local status = pcall(_copy_rps_status, { table.unpack(pkt.data, 2, #pkt.length) })
if status then
-- copied in RPS status data OK
else
-- error copying RPS status data
log.error(log_header .. "failed to parse RPS alarm status data")
end
else
log.debug(log_header .. "RPLC RPS alarm packet length mismatch")
end
elseif pkt.type == RPLC_TYPES.RPS_RESET then
-- RPS reset acknowledgement
local ack = _get_ack(pkt)
if ack then
self.acks.rps_tripped = true
self.sDB.rps_tripped = false
self.sDB.rps_trip_cause = "ok"
elseif ack == false then
log.debug(log_header .. "RPS reset failed")
end
else
log.debug(log_header .. "handler received unsupported RPLC packet type " .. pkt.type)
end
elseif pkt.scada_frame.protocol() == PROTOCOLS.SCADA_MGMT then
if pkt.type == SCADA_MGMT_TYPES.KEEP_ALIVE then
-- keep alive reply
if pkt.length == 2 then
local srv_start = pkt.data[1]
local plc_send = pkt.data[2]
local srv_now = util.time()
self.last_rtt = srv_now - srv_start
if self.last_rtt > 500 then
log.warning(log_header .. "PLC KEEP_ALIVE round trip time > 500ms (" .. self.last_rtt .. "ms)")
end
-- log.debug(log_header .. "PLC RTT = ".. self.last_rtt .. "ms")
-- log.debug(log_header .. "PLC TT = ".. (srv_now - plc_send) .. "ms")
else
log.debug(log_header .. "SCADA keep alive packet length mismatch")
end
elseif pkt.type == SCADA_MGMT_TYPES.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
public.get_id = function () return self.id end
-- get the session database
public.get_db = function () return self.sDB end
-- get the reactor structure
public.get_struct = function ()
if self.received_struct then
return self.sDB.mek_struct
else
return nil
end
end
-- get the reactor status
public.get_status = function ()
if self.received_status_cache then
return self.sDB.mek_status
else
return nil
end
end
-- get the reactor RPS status
public.get_rps = function ()
return self.sDB.rps_status
end
-- get the general status information
public.get_general_status = function ()
return {
last_status_update = self.sDB.last_status_update,
control_state = self.sDB.control_state,
overridden = self.sDB.overridden,
degraded = self.sDB.degraded,
rps_tripped = self.sDB.rps_tripped,
rps_trip_cause = self.sDB.rps_trip_cause
}
end
-- check if a timer matches this session's watchdog
public.check_wd = function (timer)
return self.plc_conn_watchdog.is_timer(timer) and self.connected
end
-- close the connection
public.close = function ()
_close()
_send_mgmt(SCADA_MGMT_TYPES.CLOSE, {})
println("connection to reactor " .. self.for_reactor .. " PLC closed by server")
log.info(log_header .. "session closed by server")
end
-- iterate the session
---@return boolean connected
public.iterate = function ()
if self.connected then
------------------
-- handle queue --
------------------
local handle_start = util.time()
while self.in_q.ready() and self.connected do
-- get a new message to process
local message = self.in_q.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
local cmd = message.message
if cmd == PLC_S_CMDS.ENABLE then
-- enable reactor
self.acks.enable = false
self.retry_times.enable_req = util.time() + INITIAL_WAIT
_send(RPLC_TYPES.RPS_ENABLE, {})
elseif cmd == PLC_S_CMDS.SCRAM then
-- SCRAM reactor
self.acks.scram = false
self.retry_times.scram_req = util.time() + INITIAL_WAIT
_send(RPLC_TYPES.RPS_SCRAM, {})
elseif cmd == PLC_S_CMDS.RPS_RESET then
-- reset RPS
self.acks.rps_reset = false
self.retry_times.rps_reset_req = util.time() + INITIAL_WAIT
_send(RPLC_TYPES.RPS_RESET, {})
end
elseif message.qtype == mqueue.TYPE.DATA then
-- instruction with body
local cmd = message.message
if cmd.key == PLC_S_DATA.BURN_RATE then
-- update burn rate
self.commanded_burn_rate = cmd.val
self.ramping_rate = false
self.acks.burn_rate = false
self.retry_times.burn_rate_req = util.time() + INITIAL_WAIT
_send(RPLC_TYPES.MEK_BURN_RATE, { self.commanded_burn_rate, self.ramping_rate })
elseif cmd.key == PLC_S_DATA.RAMP_BURN_RATE then
-- ramp to burn rate
self.commanded_burn_rate = cmd.val
self.ramping_rate = true
self.acks.burn_rate = false
self.retry_times.burn_rate_req = util.time() + INITIAL_WAIT
_send(RPLC_TYPES.MEK_BURN_RATE, { self.commanded_burn_rate, self.ramping_rate })
end
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 reactor " .. self.for_reactor .. " PLC 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_TYPES.KEEP_ALIVE, { util.time() })
periodics.keep_alive = 0
end
self.periodics.last_update = util.time()
---------------------
-- attempt retries --
---------------------
local rtimes = self.retry_times
-- struct request retry
if not self.received_struct then
if rtimes.struct_req - util.time() <= 0 then
_send(RPLC_TYPES.MEK_STRUCT, {})
rtimes.struct_req = util.time() + RETRY_PERIOD
end
end
-- status cache request retry
if not self.received_status_cache then
if rtimes.status_req - util.time() <= 0 then
_send(RPLC_TYPES.MEK_STATUS, {})
rtimes.status_req = util.time() + RETRY_PERIOD
end
end
-- SCRAM request retry
if not self.acks.scram then
if rtimes.scram_req - util.time() <= 0 then
_send(RPLC_TYPES.RPS_SCRAM, {})
rtimes.scram_req = util.time() + RETRY_PERIOD
end
end
-- enable request retry
if not self.acks.enable then
if rtimes.enable_req - util.time() <= 0 then
_send(RPLC_TYPES.RPS_ENABLE, {})
rtimes.enable_req = util.time() + RETRY_PERIOD
end
end
-- burn rate request retry
if not self.acks.burn_rate then
if rtimes.burn_rate_req - util.time() <= 0 then
_send(RPLC_TYPES.MEK_BURN_RATE, { self.commanded_burn_rate, self.ramping_rate })
rtimes.burn_rate_req = util.time() + RETRY_PERIOD
end
end
-- RPS reset request retry
if not self.acks.rps_reset then
if rtimes.rps_reset_req - util.time() <= 0 then
_send(RPLC_TYPES.RPS_RESET, {})
rtimes.rps_reset_req = util.time() + RETRY_PERIOD
end
end
end
return self.connected
end
return public
end
return plc

325
supervisor/session/rtu.lua Normal file
View File

@@ -0,0 +1,325 @@
local comms = require("scada-common.comms")
local log = require("scada-common.log")
local mqueue = require("scada-common.mqueue")
local rsio = require("scada-common.rsio")
local util = require("scada-common.util")
-- supervisor rtu sessions (svrs)
local svrs_boiler = require("supervisor.session.rtu.boiler")
local svrs_emachine = require("supervisor.session.rtu.emachine")
local svrs_redstone = require("supervisor.session.rtu.redstone")
local svrs_turbine = require("supervisor.session.rtu.turbine")
local rtu = {}
local PROTOCOLS = comms.PROTOCOLS
local SCADA_MGMT_TYPES = comms.SCADA_MGMT_TYPES
local RTU_UNIT_TYPES = comms.RTU_UNIT_TYPES
local print = util.print
local println = util.println
local print_ts = util.print_ts
local println_ts = util.println_ts
local RTU_S_CMDS = {
}
local RTU_S_DATA = {
RS_COMMAND = 1,
UNIT_COMMAND = 2
}
rtu.RTU_S_CMDS = RTU_S_CMDS
rtu.RTU_S_DATA = RTU_S_DATA
local PERIODICS = {
KEEP_ALIVE = 2.0
}
---@class rs_session_command
---@field reactor integer
---@field channel RS_IO
---@field value integer|boolean
-- create a new RTU session
---@param id integer
---@param in_queue mqueue
---@param out_queue mqueue
---@param advertisement table
rtu.new_session = function (id, in_queue, out_queue, advertisement)
local log_header = "rtu_session(" .. id .. "): "
local self = {
id = id,
in_q = in_queue,
out_q = out_queue,
advert = advertisement,
-- connection properties
seq_num = 0,
r_seq_num = nil,
connected = true,
rtu_conn_watchdog = util.new_watchdog(3),
last_rtt = 0,
rs_io_q = {},
units = {}
}
---@class rtu_session
local public = {}
-- parse the recorded advertisement and create unit sub-sessions
local _handle_advertisement = function ()
self.units = {}
self.rs_io_q = {}
for i = 1, #self.advert do
local unit = nil ---@type unit_session|nil
local rs_in_q = nil ---@type mqueue|nil
---@type rtu_advertisement
local unit_advert = {
type = self.advert[i][1],
index = self.advert[i][2],
reactor = self.advert[i][3],
rsio = self.advert[i][4]
}
local u_type = unit_advert.type
-- create unit by type
if u_type == RTU_UNIT_TYPES.REDSTONE then
unit, rs_in_q = svrs_redstone.new(self.id, i, unit_advert, self.out_q)
elseif u_type == RTU_UNIT_TYPES.BOILER then
unit = svrs_boiler.new(self.id, i, unit_advert, self.out_q)
elseif u_type == RTU_UNIT_TYPES.BOILER_VALVE then
-- @todo Mekanism 10.1+
elseif u_type == RTU_UNIT_TYPES.TURBINE then
unit = svrs_turbine.new(self.id, i, unit_advert, self.out_q)
elseif u_type == RTU_UNIT_TYPES.TURBINE_VALVE then
-- @todo Mekanism 10.1+
elseif u_type == RTU_UNIT_TYPES.EMACHINE then
unit = svrs_emachine.new(self.id, i, unit_advert, self.out_q)
elseif u_type == RTU_UNIT_TYPES.IMATRIX then
-- @todo Mekanism 10.1+
else
log.error(log_header .. "bad advertisement: encountered unsupported RTU type")
end
if unit ~= nil then
table.insert(self.units, unit)
if self.rs_io_q[unit_advert.reactor] == nil then
self.rs_io_q[unit_advert.reactor] = rs_in_q
else
self.units = {}
self.rs_io_q = {}
log.error(log_header .. "bad advertisement: duplicate redstone RTU for reactor " .. unit_advert.reactor)
break
end
else
self.units = {}
self.rs_io_q = {}
local type_string = comms.advert_type_to_rtu_t(u_type)
if type_string == nil then type_string = "unknown" end
log.error(log_header .. "bad advertisement: error occured while creating a unit (type is " .. type_string .. ")")
break
end
end
end
-- mark this RTU session as closed, stop watchdog
local _close = function ()
self.rtu_conn_watchdog.cancel()
self.connected = false
-- mark all RTU unit sessions as closed so the reactor unit knows
for i = 1, #self.units do
self.units[i].close()
end
end
-- send a SCADA management packet
---@param msg_type SCADA_MGMT_TYPES
---@param msg table
local _send_mgmt = function (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, PROTOCOLS.SCADA_MGMT, m_pkt.raw_sendable())
self.out_q.push_packet(s_pkt)
self.seq_num = self.seq_num + 1
end
-- handle a packet
---@param pkt modbus_frame|mgmt_frame
local _handle_packet = function (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 >= 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.rtu_conn_watchdog.feed()
-- process packet
if pkt.scada_frame.protocol() == PROTOCOLS.MODBUS_TCP then
if self.units[pkt.unit_id] ~= nil then
local unit = self.units[pkt.unit_id] ---@type unit_session
unit.handle_packet(pkt)
end
elseif pkt.scada_frame.protocol() == PROTOCOLS.SCADA_MGMT then
-- handle management packet
if pkt.type == SCADA_MGMT_TYPES.KEEP_ALIVE then
-- keep alive reply
if pkt.length == 2 then
local srv_start = pkt.data[1]
local rtu_send = pkt.data[2]
local srv_now = util.time()
self.last_rtt = srv_now - srv_start
if self.last_rtt > 500 then
log.warning(log_header .. "RTU KEEP_ALIVE round trip time > 500ms (" .. self.last_rtt .. "ms)")
end
-- log.debug(log_header .. "RTU RTT = ".. self.last_rtt .. "ms")
-- log.debug(log_header .. "RTU TT = ".. (srv_now - rtu_send) .. "ms")
else
log.debug(log_header .. "SCADA keep alive packet length mismatch")
end
elseif pkt.type == SCADA_MGMT_TYPES.CLOSE then
-- close the session
_close()
elseif pkt.type == SCADA_MGMT_TYPES.RTU_ADVERT then
-- RTU unit advertisement
-- handle advertisement; this will re-create all unit sub-sessions
self.advert = pkt.data
_handle_advertisement()
else
log.debug(log_header .. "handler received unsupported SCADA_MGMT packet type " .. pkt.type)
end
end
end
-- PUBLIC FUNCTIONS --
-- get the session ID
public.get_id = function () return self.id end
-- check if a timer matches this session's watchdog
---@param timer number
public.check_wd = function (timer)
return self.rtu_conn_watchdog.is_timer(timer) and self.connected
end
-- close the connection
public.close = function ()
_close()
_send_mgmt(SCADA_MGMT_TYPES.CLOSE, {})
println(log_header .. "connection to RTU closed by server")
log.info(log_header .. "session closed by server")
end
-- iterate the session
---@return boolean connected
public.iterate = function ()
if self.connected then
------------------
-- handle queue --
------------------
local handle_start = util.time()
while self.in_q.ready() and self.connected do
-- get a new message to process
local msg = self.in_q.pop()
if msg ~= nil then
if msg.qtype == mqueue.TYPE.PACKET then
-- handle a packet
_handle_packet(msg.message)
elseif msg.qtype == mqueue.TYPE.COMMAND then
-- handle instruction
elseif msg.qtype == mqueue.TYPE.DATA then
-- instruction with body
local cmd = msg.message ---@type queue_data
if cmd.key == RTU_S_DATA.RS_COMMAND then
local rs_cmd = cmd.val ---@type rs_session_command
if rsio.is_valid_channel(rs_cmd.channel) then
cmd.key = svrs_redstone.RS_RTU_S_DATA.RS_COMMAND
if rs_cmd.reactor == nil then
-- for all reactors (facility)
for i = 1, #self.rs_io_q do
local q = self.rs_io.q[i] ---@type mqueue
q.push_data(msg)
end
elseif self.rs_io_q[rs_cmd.reactor] ~= nil then
-- for just one reactor
local q = self.rs_io.q[rs_cmd.reactor] ---@type mqueue
q.push_data(msg)
end
end
end
end
end
-- 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(log_header .. "connection to RTU closed by remote host")
log.info(log_header .. "session closed by remote host")
return self.connected
end
------------------
-- update units --
------------------
local time_now = util.time()
for i = 1, #self.units do
self.units[i].update(time_now)
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_TYPES.KEEP_ALIVE, { util.time() })
periodics.keep_alive = 0
end
self.periodics.last_update = util.time()
end
return self.connected
end
return public
end
return rtu

View File

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

View File

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

View File

@@ -0,0 +1,254 @@
local comms = require("scada-common.comms")
local log = require("scada-common.log")
local mqueue= require("scada-common.mqueue")
local rsio = require("scada-common.rsio")
local types = require("scada-common.types")
local util = require("scada-common.util")
local unit_session = require("supervisor.session.rtu.unit_session")
local redstone = {}
local RTU_UNIT_TYPES = comms.RTU_UNIT_TYPES
local MODBUS_FCODE = types.MODBUS_FCODE
local RS_IO = rsio.IO
local IO_LVL = rsio.IO_LVL
local IO_DIR = rsio.IO_DIR
local IO_MODE = rsio.IO_MODE
local RS_RTU_S_CMDS = {
}
local RS_RTU_S_DATA = {
RS_COMMAND = 1
}
redstone.RS_RTU_S_CMDS = RS_RTU_S_CMDS
redstone.RS_RTU_S_DATA = RS_RTU_S_DATA
local TXN_TYPES = {
DI_READ = 1,
COIL_WRITE = 2,
INPUT_REG_READ = 3,
HOLD_REG_WRITE = 4
}
local TXN_TAGS = {
"redstone.di_read",
"redstone.coil_write",
"redstone.input_reg_write",
"redstone.hold_reg_write"
}
local PERIODICS = {
INPUT_READ = 200
}
-- create a new redstone rtu session runner
---@param session_id integer
---@param unit_id integer
---@param advert rtu_advertisement
---@param out_queue mqueue
redstone.new = function (session_id, unit_id, advert, out_queue)
-- type check
if advert.type ~= RTU_UNIT_TYPES.REDSTONE then
log.error("attempt to instantiate redstone RTU for type '" .. advert.type .. "'. this is a bug.")
return nil
end
-- for redstone, use unit ID not device index
local log_tag = "session.rtu(" .. session_id .. ").redstone(" .. unit_id .. "): "
local self = {
session = unit_session.new(unit_id, advert, out_queue, log_tag, TXN_TAGS),
has_di = false,
has_ai = false,
periodics = {
next_di_req = 0,
next_ir_req = 0,
},
io_list = {
digital_in = {}, -- discrete inputs
digital_out = {}, -- coils
analog_in = {}, -- input registers
analog_out = {} -- holding registers
},
db = {}
}
local public = self.session.get()
-- INITIALIZE --
-- create all channels as disconnected
for _ = 1, #RS_IO do
table.insert(self.db, IO_LVL.DISCONNECT)
end
-- setup I/O
for i = 1, #advert.rsio do
local channel = advert.rsio[i]
local mode = rsio.get_io_mode(channel)
if mode == IO_MODE.DIGITAL_IN then
self.has_di = true
table.insert(self.io_list.digital_in, channel)
elseif mode == IO_MODE.DIGITAL_OUT then
table.insert(self.io_list.digital_out, channel)
elseif mode == IO_MODE.ANALOG_IN then
self.has_ai = true
table.insert(self.io_list.analog_in, channel)
elseif mode == IO_MODE.ANALOG_OUT then
table.insert(self.io_list.analog_out, channel)
else
-- should be unreachable code, we already validated channels
log.error(log_tag .. "failed to identify advertisement channel IO mode (" .. channel .. ")", true)
return nil
end
self.db[channel] = IO_LVL.LOW
end
-- PRIVATE FUNCTIONS --
-- query discrete inputs
local _request_discrete_inputs = function ()
self.session.send_request(TXN_TYPES.DI_READ, MODBUS_FCODE.READ_DISCRETE_INPUTS, { 1, #self.io_list.digital_in })
end
-- query input registers
local _request_input_registers = function ()
self.session.send_request(TXN_TYPES.INPUT_REG_READ, MODBUS_FCODE.READ_INPUT_REGS, { 1, #self.io_list.analog_in })
end
-- write coil output
local _write_coil = function (coil, value)
self.session.send_request(TXN_TYPES.COIL_WRITE, MODBUS_FCODE.WRITE_MUL_COILS, { coil, value })
end
-- write holding register output
local _write_holding_register = function (reg, value)
self.session.send_request(TXN_TYPES.HOLD_REG_WRITE, MODBUS_FCODE.WRITE_MUL_HOLD_REGS, { reg, value })
end
-- PUBLIC FUNCTIONS --
-- handle a packet
---@param m_pkt modbus_frame
public.handle_packet = function (m_pkt)
local txn_type = self.session.try_resolve(m_pkt.txn_id)
if txn_type == false then
-- nothing to do
elseif txn_type == TXN_TYPES.DI_READ then
-- discrete input read response
if m_pkt.length == #self.io_list.digital_in then
for i = 1, m_pkt.length do
local channel = self.io_list.digital_in[i]
local value = m_pkt.data[i]
self.db[channel] = value
end
else
log.debug(log_tag .. "MODBUS transaction reply length mismatch (redstone.di_read)")
end
elseif txn_type == TXN_TYPES.INPUT_REG_READ then
-- input register read response
if m_pkt.length == #self.io_list.analog_in then
for i = 1, m_pkt.length do
local channel = self.io_list.analog_in[i]
local value = m_pkt.data[i]
self.db[channel] = value
end
else
log.debug(log_tag .. "MODBUS transaction reply length mismatch (redstone.input_reg_read)")
end
elseif txn_type == TXN_TYPES.COIL_WRITE or txn_type == TXN_TYPES.HOLD_REG_WRITE then
-- successful acknowledgement
elseif txn_type == nil then
log.error(log_tag .. "unknown transaction reply")
else
log.error(log_tag .. "unknown transaction type " .. txn_type)
end
end
-- update this runner
---@param time_now integer milliseconds
public.update = function (time_now)
-- check command queue
while self.in_q.ready() do
-- get a new message to process
local msg = self.in_q.pop()
if msg ~= nil then
if msg.qtype == mqueue.TYPE.DATA then
-- instruction with body
local cmd = msg.message ---@type queue_data
if cmd.key == RS_RTU_S_DATA.RS_COMMAND then
local rs_cmd = cmd.val ---@type rs_session_command
if self.db[rs_cmd.channel] ~= IO_LVL.DISCONNECT then
-- we have this as a connected channel
local mode = rsio.get_io_mode(rs_cmd.channel)
if mode == IO_MODE.DIGITAL_OUT then
-- record the value for retries
self.db[rs_cmd.channel] = rs_cmd.value
-- find the coil address then write to it
for i = 0, #self.digital_out do
if self.digital_out[i] == rs_cmd.channel then
_write_coil(i, rs_cmd.value)
break
end
end
elseif mode == IO_MODE.ANALOG_OUT then
-- record the value for retries
self.db[rs_cmd.channel] = rs_cmd.value
-- find the holding register address then write to it
for i = 0, #self.analog_out do
if self.analog_out[i] == rs_cmd.channel then
_write_holding_register(i, rs_cmd.value)
break
end
end
elseif mode ~= nil then
log.debug(log_tag .. "attemted write to non D/O or A/O mode " .. mode)
end
end
end
end
end
-- max 100ms spent processing queue
if util.time() - time_now > 100 then
log.warning(log_tag .. "exceeded 100ms queue process limit")
break
end
end
time_now = util.time()
-- poll digital inputs
if self.has_di then
if self.periodics.next_di_req <= time_now then
_request_discrete_inputs()
self.periodics.next_di_req = time_now + PERIODICS.INPUT_READ
end
end
-- poll analog inputs
if self.has_ai then
if self.periodics.next_ir_req <= time_now then
_request_input_registers()
self.periodics.next_ir_req = time_now + PERIODICS.INPUT_READ
end
end
end
-- get the unit session database
public.get_db = function () return self.db end
return public, self.in_q
end
return redstone

View File

@@ -0,0 +1,176 @@
local comms = require("scada-common.comms")
local log = require("scada-common.log")
local types = require("scada-common.types")
local unit_session = require("supervisor.session.rtu.unit_session")
local turbine = {}
local RTU_UNIT_TYPES = comms.RTU_UNIT_TYPES
local DUMPING_MODE = types.DUMPING_MODE
local MODBUS_FCODE = types.MODBUS_FCODE
local TXN_TYPES = {
BUILD = 1,
STATE = 2,
TANKS = 3
}
local TXN_TAGS = {
"turbine.build",
"turbine.state",
"turbine.tanks",
}
local PERIODICS = {
BUILD = 1000,
STATE = 500,
TANKS = 1000
}
-- create a new turbine rtu session runner
---@param session_id integer
---@param unit_id integer
---@param advert rtu_advertisement
---@param out_queue mqueue
turbine.new = function (session_id, unit_id, advert, out_queue)
-- type check
if advert.type ~= RTU_UNIT_TYPES.TURBINE then
log.error("attempt to instantiate turbine RTU for type '" .. advert.type .. "'. this is a bug.")
return nil
end
local log_tag = "session.rtu(" .. session_id .. ").turbine(" .. advert.index .. "): "
local self = {
session = unit_session.new(unit_id, advert, out_queue, log_tag, TXN_TAGS),
has_build = false,
periodics = {
next_build_req = 0,
next_state_req = 0,
next_tanks_req = 0,
},
---@class turbine_session_db
db = {
build = {
blades = 0,
coils = 0,
vents = 0,
dispersers = 0,
condensers = 0,
steam_cap = 0,
max_flow_rate = 0,
max_production = 0,
max_water_output = 0
},
state = {
flow_rate = 0.0,
prod_rate = 0.0,
steam_input_rate = 0.0,
dumping_mode = DUMPING_MODE.IDLE ---@type DUMPING_MODE
},
tanks = {
steam = 0,
steam_need = 0,
steam_fill = 0.0
}
}
}
local public = self.session.get()
-- PRIVATE FUNCTIONS --
-- query the build of the device
local _request_build = function ()
-- read input registers 1 through 9 (start = 1, count = 9)
self.session.send_request(TXN_TYPES.BUILD, MODBUS_FCODE.READ_INPUT_REGS, { 1, 9 })
end
-- query the state of the device
local _request_state = function ()
-- read input registers 10 through 13 (start = 10, count = 4)
self.session.send_request(TXN_TYPES.STATE, MODBUS_FCODE.READ_INPUT_REGS, { 10, 4 })
end
-- query the tanks of the device
local _request_tanks = function ()
-- read input registers 14 through 16 (start = 14, count = 3)
self.session.send_request(TXN_TYPES.TANKS, MODBUS_FCODE.READ_INPUT_REGS, { 14, 3 })
end
-- PUBLIC FUNCTIONS --
-- handle a packet
---@param m_pkt modbus_frame
public.handle_packet = function (m_pkt)
local txn_type = self.session.try_resolve(m_pkt.txn_id)
if txn_type == false then
-- nothing to do
elseif txn_type == TXN_TYPES.BUILD then
-- build response
if m_pkt.length == 9 then
self.db.build.blades = m_pkt.data[1]
self.db.build.coils = m_pkt.data[2]
self.db.build.vents = m_pkt.data[3]
self.db.build.dispersers = m_pkt.data[4]
self.db.build.condensers = m_pkt.data[5]
self.db.build.steam_cap = m_pkt.data[6]
self.db.build.max_flow_rate = m_pkt.data[7]
self.db.build.max_production = m_pkt.data[8]
self.db.build.max_water_output = m_pkt.data[9]
else
log.debug(log_tag .. "MODBUS transaction reply length mismatch (turbine.build)")
end
elseif txn_type == TXN_TYPES.STATE then
-- state response
if m_pkt.length == 4 then
self.db.state.flow_rate = m_pkt.data[1]
self.db.state.prod_rate = m_pkt.data[2]
self.db.state.steam_input_rate = m_pkt.data[3]
self.db.state.dumping_mode = m_pkt.data[4]
else
log.debug(log_tag .. "MODBUS transaction reply length mismatch (turbine.state)")
end
elseif txn_type == TXN_TYPES.TANKS then
-- tanks response
if m_pkt.length == 3 then
self.db.tanks.steam = m_pkt.data[1]
self.db.tanks.steam_need = m_pkt.data[2]
self.db.tanks.steam_fill = m_pkt.data[3]
else
log.debug(log_tag .. "MODBUS transaction reply length mismatch (turbine.tanks)")
end
elseif txn_type == nil then
log.error(log_tag .. "unknown transaction reply")
else
log.error(log_tag .. "unknown transaction type " .. txn_type)
end
end
-- update this runner
---@param time_now integer milliseconds
public.update = function (time_now)
if not self.has_build and self.periodics.next_build_req <= time_now then
_request_build()
self.periodics.next_build_req = time_now + PERIODICS.BUILD
end
if self.periodics.next_state_req <= time_now then
_request_state()
self.periodics.next_state_req = time_now + PERIODICS.STATE
end
if self.periodics.next_tanks_req <= time_now then
_request_tanks()
self.periodics.next_tanks_req = time_now + PERIODICS.TANKS
end
end
-- get the unit session database
public.get_db = function () return self.db end
return public
end
return turbine

View File

@@ -0,0 +1,91 @@
--
-- MODBUS Transaction Controller
--
local util = require("scada-common.util")
local txnctrl = {}
local TIMEOUT = 2000 -- 2000ms max wait
-- create a new transaction controller
txnctrl.new = function ()
local self = {
list = {},
next_id = 0
}
---@class transaction_controller
local public = {}
local insert = table.insert
-- get the length of the transaction list
public.length = function ()
return #self.list
end
-- check if there are no active transactions
public.empty = function ()
return #self.list == 0
end
-- create a new transaction of the given type
---@param txn_type integer
---@return integer txn_id
public.create = function (txn_type)
local txn_id = self.next_id
insert(self.list, {
txn_id = txn_id,
txn_type = txn_type,
expiry = util.time() + TIMEOUT
})
self.next_id = self.next_id + 1
return txn_id
end
-- mark a transaction as resolved to get its transaction type
---@param txn_id integer
---@return integer txn_type
public.resolve = function (txn_id)
local txn_type = nil
for i = 1, public.length() do
if self.list[i].txn_id == txn_id then
txn_type = self.list[i].txn_type
self.list[i] = nil
end
end
return txn_type
end
-- renew a transaction by re-inserting it with its ID and type
---@param txn_id integer
---@param txn_type integer
public.renew = function (txn_id, txn_type)
insert(self.list, {
txn_id = txn_id,
txn_type = txn_type,
expiry = util.time() + TIMEOUT
})
end
-- close timed-out transactions
public.cleanup = function ()
local now = util.time()
util.filter_table(self.list, function (txn) return txn.expiry > now end)
end
-- clear the transaction list
public.clear = function ()
self.list = {}
end
return public
end
return txnctrl

View File

@@ -0,0 +1,155 @@
local comms = require("scada-common.comms")
local log = require("scada-common.log")
local types = require("scada-common.types")
local txnctrl = require("supervisor.session.rtu.txnctrl")
local unit_session = {}
local PROTOCOLS = comms.PROTOCOLS
local MODBUS_FCODE = types.MODBUS_FCODE
local MODBUS_EXCODE = types.MODBUS_EXCODE
-- create a new unit session runner
---@param unit_id integer MODBUS unit ID
---@param advert rtu_advertisement RTU advertisement for this unit
---@param out_queue mqueue send queue
---@param log_tag string logging tag
---@param txn_tags table transaction log tags
unit_session.new = function (unit_id, advert, out_queue, log_tag, txn_tags)
local self = {
log_tag = log_tag,
txn_tags = txn_tags,
unit_id = unit_id,
device_index = advert.index,
reactor = advert.reactor,
out_q = out_queue,
transaction_controller = txnctrl.new(),
connected = true,
device_fail = false
}
---@class _unit_session
local protected = {}
---@class unit_session
local public = {}
-- PROTECTED FUNCTIONS --
-- send a MODBUS message, creating a transaction in the process
---@param txn_type integer transaction type
---@param f_code MODBUS_FCODE function code
---@param register_param table register range or register and values
protected.send_request = function (txn_type, f_code, register_param)
local m_pkt = comms.modbus_packet()
local txn_id = self.transaction_controller.create(txn_type)
m_pkt.make(txn_id, self.unit_id, f_code, register_param)
self.out_q.push_packet(m_pkt)
end
-- try to resolve a MODBUS transaction
---@param m_pkt modbus_frame MODBUS packet
---@return integer|false txn_type transaction type or false on error/busy
protected.try_resolve = function (m_pkt)
if m_pkt.scada_frame.protocol() == PROTOCOLS.MODBUS_TCP then
if m_pkt.unit_id == self.unit_id then
local txn_type = self.transaction_controller.resolve(m_pkt.txn_id)
local txn_tag = " (" .. self.txn_tags[txn_type] .. ")"
if bit.band(m_pkt.func_code, MODBUS_FCODE.ERROR_FLAG) ~= 0 then
-- transaction incomplete or failed
local ex = m_pkt.data[1]
if ex == MODBUS_EXCODE.ILLEGAL_FUNCTION then
log.error(log_tag .. "MODBUS: illegal function" .. txn_tag)
elseif ex == MODBUS_EXCODE.ILLEGAL_DATA_ADDR then
log.error(log_tag .. "MODBUS: illegal data address" .. txn_tag)
elseif ex == MODBUS_EXCODE.SERVER_DEVICE_FAIL then
if self.device_fail then
log.debug(log_tag .. "MODBUS: repeated device failure" .. txn_tag)
else
self.device_fail = true
log.warning(log_tag .. "MODBUS: device failure" .. txn_tag)
end
elseif ex == MODBUS_EXCODE.ACKNOWLEDGE then
-- will have to wait on reply, renew the transaction
self.transaction_controller.renew(m_pkt.txn_id, txn_type)
elseif ex == MODBUS_EXCODE.SERVER_DEVICE_BUSY then
-- will have to wait on reply, renew the transaction
self.transaction_controller.renew(m_pkt.txn_id, txn_type)
log.debug(log_tag .. "MODBUS: device busy" .. txn_tag)
elseif ex == MODBUS_EXCODE.NEG_ACKNOWLEDGE then
-- general failure
log.error(log_tag .. "MODBUS: negative acknowledge (bad request)" .. txn_tag)
elseif ex == MODBUS_EXCODE.GATEWAY_PATH_UNAVAILABLE then
-- RTU gateway has no known unit with the given ID
log.error(log_tag .. "MODBUS: gateway path unavailable (unknown unit)" .. txn_tag)
elseif ex ~= nil then
-- unsupported exception code
log.debug(log_tag .. "MODBUS: unsupported error " .. ex .. txn_tag)
else
-- nil exception code
log.debug(log_tag .. "MODBUS: nil exception code" .. txn_tag)
end
else
-- clear device fail flag
self.device_fail = false
-- no error, return the transaction type
return txn_type
end
else
log.error(log_tag .. "wrong unit ID: " .. m_pkt.unit_id, true)
end
else
log.error(log_tag .. "illegal packet type " .. m_pkt.scada_frame.protocol(), true)
end
-- error or transaction in progress, return false
return false
end
-- get the public interface
protected.get = function () return public end
-- PUBLIC FUNCTIONS --
-- get the unit ID
public.get_unit_id = function () return self.unit_id end
-- get the device index
public.get_device_idx = function () return self.device_index end
-- get the reactor ID
public.get_reactor = function () return self.reactor end
-- close this unit
public.close = function () self.connected = false end
-- check if this unit is connected
public.is_connected = function () return self.connected end
-- check if this unit is faulted
public.is_faulted = function () return self.device_fail end
-- PUBLIC TEMPLATE FUNCTIONS --
-- handle a packet
---@param m_pkt modbus_frame
---@diagnostic disable-next-line: unused-local
public.handle_packet = function (m_pkt)
log.debug("template unit_session.handle_packet() called", true)
end
-- update this runner
---@param time_now integer milliseconds
---@diagnostic disable-next-line: unused-local
public.update = function (time_now)
log.debug("template unit_session.update() called", true)
end
-- get the unit session database
public.get_db = function () return {} end
return protected
end
return unit_session

View File

@@ -0,0 +1,294 @@
local log = require("scada-common.log")
local mqueue = require("scada-common.mqueue")
local util = require("scada-common.util")
local coordinator = require("supervisor.session.coordinator")
local plc = require("supervisor.session.plc")
local rtu = require("supervisor.session.rtu")
-- Supervisor Sessions Handler
local svsessions = {}
local SESSION_TYPE = {
RTU_SESSION = 0,
PLC_SESSION = 1,
COORD_SESSION = 2
}
svsessions.SESSION_TYPE = SESSION_TYPE
local self = {
modem = nil,
num_reactors = 0,
rtu_sessions = {},
plc_sessions = {},
coord_sessions = {},
next_rtu_id = 0,
next_plc_id = 0,
next_coord_id = 0
}
-- PRIVATE FUNCTIONS --
-- iterate all the given sessions
---@param sessions table
local function _iterate(sessions)
for i = 1, #sessions do
local session = sessions[i] ---@type plc_session_struct|rtu_session_struct
if session.open then
local ok = session.instance.iterate()
if ok then
-- send packets in out queue
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
else
session.open = false
end
end
end
end
-- cleanly close a session
---@param session plc_session_struct|rtu_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("closed session " .. session.instance.get_id() .. " on remote port " .. session.r_port)
end
-- close connections
---@param sessions table
local function _close(sessions)
for i = 1, #sessions do
local session = sessions[i] ---@type plc_session_struct
if session.open then
_shutdown(session)
end
end
end
-- check if a watchdog timer event matches that of one of the provided sessions
---@param sessions table
---@param timer_event number
local function _check_watchdogs(sessions, timer_event)
for i = 1, #sessions do
local session = sessions[i] ---@type plc_session_struct
if session.open then
local triggered = session.instance.check_wd(timer_event)
if triggered then
log.debug("watchdog closing session " .. session.instance.get_id() .. " on remote port " .. session.r_port .. "...")
_shutdown(session)
end
end
end
end
-- delete any closed sessions
---@param sessions table
local function _free_closed(sessions)
local f = function (session) return session.open end
local on_delete = function (session) log.debug("free'ing closed session " .. session.instance.get_id() .. " on remote port " .. session.r_port) end
util.filter_table(sessions, f, on_delete)
end
-- find a session by remote port
---@param list table
---@param port integer
---@return plc_session_struct|rtu_session_struct|nil
local function _find_session(list, port)
for i = 1, #list do
if list[i].r_port == port then return list[i] end
end
return nil
end
-- PUBLIC FUNCTIONS --
-- link the modem
---@param modem table
svsessions.link_modem = function (modem)
self.modem = modem
end
-- find an RTU session by the remote port
---@param remote_port integer
---@return rtu_session_struct|nil
svsessions.find_rtu_session = function (remote_port)
-- check RTU sessions
return _find_session(self.rtu_sessions, remote_port)
end
-- find a PLC session by the remote port
---@param remote_port integer
---@return plc_session_struct|nil
svsessions.find_plc_session = function (remote_port)
-- check PLC sessions
return _find_session(self.plc_sessions, remote_port)
end
-- find a PLC/RTU session by the remote port
---@param remote_port integer
---@return plc_session_struct|rtu_session_struct|nil
svsessions.find_device_session = function (remote_port)
-- check RTU sessions
local s = _find_session(self.rtu_sessions, remote_port)
-- check PLC sessions
if s == nil then s = _find_session(self.plc_sessions, remote_port) end
return s
end
-- find a coordinator session by the remote port
---@param remote_port integer
---@return nil
svsessions.find_coord_session = function (remote_port)
-- check coordinator sessions
return _find_session(self.coord_sessions, remote_port)
end
-- get a session by reactor ID
---@param reactor integer
---@return plc_session_struct|nil session
svsessions.get_reactor_session = function (reactor)
local session = nil
for i = 1, #self.plc_sessions do
if self.plc_sessions[i].reactor == reactor then
session = self.plc_sessions[i]
end
end
return session
end
-- establish a new PLC session
---@param local_port integer
---@param remote_port integer
---@param for_reactor integer
---@param version string
---@return integer|false session_id
svsessions.establish_plc_session = function (local_port, remote_port, for_reactor, version)
if svsessions.get_reactor_session(for_reactor) == nil then
---@class plc_session_struct
local plc_s = {
open = true,
reactor = for_reactor,
version = version,
l_port = local_port,
r_port = remote_port,
in_queue = mqueue.new(),
out_queue = mqueue.new(),
instance = nil
}
plc_s.instance = plc.new_session(self.next_plc_id, for_reactor, plc_s.in_queue, plc_s.out_queue)
table.insert(self.plc_sessions, plc_s)
log.debug("established new PLC session to " .. remote_port .. " with ID " .. self.next_plc_id)
self.next_plc_id = self.next_plc_id + 1
-- success
return plc_s.instance.get_id()
else
-- reactor already assigned to a PLC
return false
end
end
-- establish a new RTU session
---@param local_port integer
---@param remote_port integer
---@param advertisement table
---@return integer session_id
svsessions.establish_rtu_session = function (local_port, remote_port, advertisement)
-- pull version from advertisement
local version = table.remove(advertisement, 1)
---@class rtu_session_struct
local rtu_s = {
open = true,
version = version,
l_port = local_port,
r_port = remote_port,
in_queue = mqueue.new(),
out_queue = mqueue.new(),
instance = nil
}
rtu_s.instance = rtu.new_session(self.next_rtu_id, rtu_s.in_queue, rtu_s.out_queue, advertisement)
table.insert(self.rtu_sessions, rtu_s)
log.debug("established new RTU session to " .. remote_port .. " with ID " .. self.next_rtu_id)
self.next_rtu_id = self.next_rtu_id + 1
-- success
return rtu_s.instance.get_id()
end
-- attempt to identify which session's watchdog timer fired
---@param timer_event number
svsessions.check_all_watchdogs = function (timer_event)
-- check RTU session watchdogs
_check_watchdogs(self.rtu_sessions, timer_event)
-- check PLC session watchdogs
_check_watchdogs(self.plc_sessions, timer_event)
-- check coordinator session watchdogs
_check_watchdogs(self.coord_sessions, timer_event)
end
-- iterate all sessions
svsessions.iterate_all = function ()
-- iterate RTU sessions
_iterate(self.rtu_sessions)
-- iterate PLC sessions
_iterate(self.plc_sessions)
-- iterate coordinator sessions
_iterate(self.coord_sessions)
end
-- delete all closed sessions
svsessions.free_all_closed = function ()
-- free closed RTU sessions
_free_closed(self.rtu_sessions)
-- free closed PLC sessions
_free_closed(self.plc_sessions)
-- free closed coordinator sessions
_free_closed(self.coord_sessions)
end
-- close all open connections
svsessions.close_all = function ()
-- close sessions
_close(self.rtu_sessions)
_close(self.plc_sessions)
_close(self.coord_sessions)
-- free sessions
svsessions.free_all_closed()
end
return svsessions

117
supervisor/startup.lua Normal file
View File

@@ -0,0 +1,117 @@
--
-- Nuclear Generation Facility SCADA Supervisor
--
require("/initenv").init_env()
local log = require("scada-common.log")
local ppm = require("scada-common.ppm")
local util = require("scada-common.util")
local svsessions = require("supervisor.session.svsessions")
local config = require("supervisor.config")
local supervisor = require("supervisor.supervisor")
local SUPERVISOR_VERSION = "alpha-v0.4.0"
local print = util.print
local println = util.println
local print_ts = util.print_ts
local println_ts = util.println_ts
log.init(config.LOG_PATH, config.LOG_MODE)
log.info("========================================")
log.info("BOOTING supervisor.startup " .. SUPERVISOR_VERSION)
log.info("========================================")
println(">> SCADA Supervisor " .. SUPERVISOR_VERSION .. " <<")
-- mount connected devices
ppm.mount_all()
local modem = ppm.get_wireless_modem()
if modem == nil then
println("boot> wireless modem not found")
log.warning("no wireless modem on startup")
return
end
-- start comms, open all channels
local superv_comms = supervisor.comms(SUPERVISOR_VERSION, config.NUM_REACTORS, modem, config.SCADA_DEV_LISTEN, config.SCADA_SV_LISTEN)
-- base loop clock (6.67Hz, 3 ticks)
local MAIN_CLOCK = 0.15
local loop_clock = util.new_clock(MAIN_CLOCK)
-- start clock
loop_clock.start()
-- event loop
while true do
---@diagnostic disable-next-line: undefined-field
local event, param1, param2, param3, param4, param5 = os.pullEventRaw()
-- 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 care if this is our wireless modem
if device == modem then
println_ts("wireless modem disconnected!")
log.error("comms modem disconnected!")
else
log.warning("non-comms modem disconnected")
end
end
end
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
modem = device
superv_comms.reconnect_modem(modem)
println_ts("wireless modem reconnected.")
log.info("comms modem reconnected.")
else
log.info("wired modem reconnected.")
end
end
end
elseif event == "timer" and loop_clock.is_clock(param1) then
-- main loop tick
-- iterate sessions
svsessions.iterate_all()
-- free any closed sessions
svsessions.free_all_closed()
loop_clock.start()
elseif event == "timer" then
-- a non-clock timer event, check watchdogs
svsessions.check_all_watchdogs(param1)
elseif event == "modem_message" then
-- got a packet
local packet = superv_comms.parse_packet(param1, param2, param3, param4, param5)
superv_comms.handle_packet(packet)
end
-- check for termination request
if event == "terminate" or ppm.should_terminate() then
println_ts("closing sessions...")
log.info("terminate requested, closing sessions...")
svsessions.close_all()
log.info("sessions closed")
break
end
end
println_ts("exited")
log.info("exited")

255
supervisor/supervisor.lua Normal file
View File

@@ -0,0 +1,255 @@
local comms = require("scada-common.comms")
local log = require("scada-common.log")
local util = require("scada-common.util")
local svsessions = require("supervisor.session.svsessions")
local supervisor = {}
local PROTOCOLS = comms.PROTOCOLS
local RPLC_TYPES = comms.RPLC_TYPES
local RPLC_LINKING = comms.RPLC_LINKING
local SCADA_MGMT_TYPES = comms.SCADA_MGMT_TYPES
local RTU_UNIT_TYPES = comms.RTU_UNIT_TYPES
local SESSION_TYPE = svsessions.SESSION_TYPE
local print = util.print
local println = util.println
local print_ts = util.print_ts
local println_ts = util.println_ts
-- supervisory controller communications
---@param version string
---@param num_reactors integer
---@param modem table
---@param dev_listen integer
---@param coord_listen integer
supervisor.comms = function (version, num_reactors, modem, dev_listen, coord_listen)
local self = {
version = version,
num_reactors = num_reactors,
modem = modem,
dev_listen = dev_listen,
coord_listen = coord_listen,
reactor_struct_cache = nil
}
---@class superv_comms
local public = {}
-- PRIVATE FUNCTIONS --
-- open all channels
local _open_channels = function ()
if not self.modem.isOpen(self.dev_listen) then
self.modem.open(self.dev_listen)
end
if not self.modem.isOpen(self.coord_listen) then
self.modem.open(self.coord_listen)
end
end
-- open at construct time
_open_channels()
-- link modem to svsessions
svsessions.link_modem(self.modem)
-- send PLC link request responses
---@param dest integer
---@param msg table
local _send_plc_linking = function (seq_id, dest, msg)
local s_pkt = comms.scada_packet()
local r_pkt = comms.rplc_packet()
r_pkt.make(0, RPLC_TYPES.LINK_REQ, msg)
s_pkt.make(seq_id, PROTOCOLS.RPLC, r_pkt.raw_sendable())
self.modem.transmit(dest, self.dev_listen, s_pkt.raw_sendable())
end
-- send RTU advertisement responses
---@param seq_id integer
---@param dest integer
local _send_remote_linked = function (seq_id, dest)
local s_pkt = comms.scada_packet()
local m_pkt = comms.mgmt_packet()
m_pkt.make(SCADA_MGMT_TYPES.REMOTE_LINKED, {})
s_pkt.make(seq_id, PROTOCOLS.SCADA_MGMT, m_pkt.raw_sendable())
self.modem.transmit(dest, self.dev_listen, s_pkt.raw_sendable())
end
-- PUBLIC FUNCTIONS --
-- reconnect a newly connected modem
---@param modem table
---@diagnostic disable-next-line: redefined-local
public.reconnect_modem = function (modem)
self.modem = modem
svsessions.link_modem(self.modem)
_open_channels()
end
-- parse a packet
---@param side string
---@param sender integer
---@param reply_to integer
---@param message any
---@param distance integer
---@return modbus_frame|rplc_frame|mgmt_frame|coord_frame|nil packet
public.parse_packet = function(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 MODBUS TCP packet
if s_pkt.protocol() == PROTOCOLS.MODBUS_TCP then
local m_pkt = comms.modbus_packet()
if m_pkt.decode(s_pkt) then
pkt = m_pkt.get()
end
-- get as RPLC packet
elseif s_pkt.protocol() == PROTOCOLS.RPLC then
local rplc_pkt = comms.rplc_packet()
if rplc_pkt.decode(s_pkt) then
pkt = rplc_pkt.get()
end
-- get as SCADA management packet
elseif s_pkt.protocol() == PROTOCOLS.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.COORD_DATA then
local coord_pkt = comms.coord_packet()
if coord_pkt.decode(s_pkt) then
pkt = coord_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 modbus_frame|rplc_frame|mgmt_frame|coord_frame
public.handle_packet = function(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()
-- device (RTU/PLC) listening channel
if l_port == self.dev_listen then
if protocol == PROTOCOLS.MODBUS_TCP then
-- look for an associated session
local session = svsessions.find_rtu_session(r_port)
-- MODBUS response
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 MODBUS_TCP packet without a known session")
end
elseif protocol == PROTOCOLS.RPLC then
-- look for an associated session
local session = svsessions.find_plc_session(r_port)
-- reactor PLC packet
if session ~= nil then
if packet.type == RPLC_TYPES.LINK_REQ then
-- new device on this port? that's a collision
log.debug("PLC_LNK: request from existing connection received on " .. r_port .. ", responding with collision")
_send_plc_linking(packet.scada_frame.seq_num() + 1, r_port, { RPLC_LINKING.COLLISION })
else
-- pass the packet onto the session handler
session.in_queue.push_packet(packet)
end
else
local next_seq_id = packet.scada_frame.seq_num() + 1
-- unknown session, is this a linking request?
if packet.type == RPLC_TYPES.LINK_REQ then
if packet.length == 2 then
-- this is a linking request
local plc_id = svsessions.establish_plc_session(l_port, r_port, packet.data[1], packet.data[2])
if plc_id == false then
-- reactor already has a PLC assigned
log.debug("PLC_LNK: assignment collision with reactor " .. packet.data[1])
_send_plc_linking(next_seq_id, r_port, { RPLC_LINKING.COLLISION })
else
-- got an ID; assigned to a reactor successfully
println("connected to reactor " .. packet.data[1] .. " PLC (" .. packet.data[2] .. ") [:" .. r_port .. "]")
log.debug("PLC_LNK: allowed for device at " .. r_port)
_send_plc_linking(next_seq_id, r_port, { RPLC_LINKING.ALLOW })
end
else
log.debug("PLC_LNK: new linking packet length mismatch")
end
else
-- force a re-link
log.debug("PLC_LNK: no session but not a link, force relink")
_send_plc_linking(next_seq_id, r_port, { RPLC_LINKING.DENY })
end
end
elseif protocol == PROTOCOLS.SCADA_MGMT then
-- look for an associated session
local session = svsessions.find_device_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_TYPES.RTU_ADVERT then
if packet.length >= 1 then
-- this is an RTU advertisement for a new session
println("connected to RTU (" .. packet.data[1] .. ") [:" .. r_port .. "]")
svsessions.establish_rtu_session(l_port, r_port, packet.data)
log.debug("RTU_ADVERT: linked " .. r_port)
_send_remote_linked(packet.scada_frame.seq_num() + 1, r_port)
else
log.debug("RTU_ADVERT: advertisement packet empty")
end
else
-- any other packet should be session related, discard it
log.debug("discarding SCADA_MGMT packet without a known session")
end
else
log.debug("illegal packet type " .. protocol .. " on device listening channel")
end
-- coordinator listening channel
elseif l_port == self.coord_listen then
-- look for an associated session
local session = svsessions.find_coord_session(r_port)
if protocol == PROTOCOLS.SCADA_MGMT then
-- SCADA management packet
elseif protocol == PROTOCOLS.COORD_DATA then
-- coordinator packet
else
log.debug("illegal packet type " .. protocol .. " on coordinator listening channel")
end
else
log.error("received packet on unused channel " .. l_port, true)
end
end
end
return public
end
return supervisor

460
supervisor/unit.lua Normal file
View File

@@ -0,0 +1,460 @@
local types = require "scada-common.types"
local util = require "scada-common.util"
local unit = {}
local TRI_FAIL = types.TRI_FAIL
local DUMPING_MODE = types.DUMPING_MODE
local DT_KEYS = {
ReactorTemp = "RTP",
ReactorFuel = "RFL",
ReactorWaste = "RWS",
ReactorCCool = "RCC",
ReactorHCool = "RHC",
BoilerWater = "BWR",
BoilerSteam = "BST",
BoilerCCool = "BCC",
BoilerHCool = "BHC",
TurbineSteam = "TST"
}
-- create a new reactor unit
---@param for_reactor integer reactor unit number
---@param num_boilers integer number of boilers expected
---@param num_turbines integer number of turbines expected
unit.new = function (for_reactor, num_boilers, num_turbines)
local self = {
r_id = for_reactor,
plc_s = nil, ---@class plc_session
counts = { boilers = num_boilers, turbines = num_turbines },
turbines = {},
boilers = {},
redstone = {},
deltas = {},
db = {
---@class annunciator
annunciator = {
-- reactor
PLCOnline = false,
ReactorTrip = false,
ManualReactorTrip = false,
RCPTrip = false,
RCSFlowLow = false,
ReactorTempHigh = false,
ReactorHighDeltaT = false,
FuelInputRateLow = false,
WasteLineOcclusion = false,
HighStartupRate = false,
-- boiler
BoilerOnline = TRI_FAIL.OK,
HeatingRateLow = {},
BoilRateMismatch = false,
CoolantFeedMismatch = false,
-- turbine
TurbineOnline = TRI_FAIL.OK,
SteamFeedMismatch = false,
MaxWaterReturnFeed = false,
SteamDumpOpen = {},
TurbineOverSpeed = {},
TurbineTrip = {}
}
}
}
-- init boiler table fields
for _ = 1, self.num_boilers do
table.insert(self.db.annunciator.HeatingRateLow, false)
end
-- init turbine table fields
for _ = 1, self.num_turbines do
table.insert(self.db.annunciator.SteamDumpOpen, TRI_FAIL.OK)
table.insert(self.db.annunciator.TurbineOverSpeed, false)
table.insert(self.db.annunciator.TurbineTrip, false)
end
---@class reactor_unit
local public = {}
-- PRIVATE FUNCTIONS --
-- compute a change with respect to time of the given value
---@param key string value key
---@param value number value
local _compute_dt = function (key, value)
if self.deltas[key] then
local data = self.deltas[key]
data.dt = (value - data.last_v) / (util.time_s() - data.last_t)
data.last_v = value
data.last_t = util.time_s()
else
self.deltas[key] = {
last_t = util.time_s(),
last_v = value,
dt = 0.0
}
end
end
-- clear a delta
---@param key string value key
local _reset_dt = function (key)
self.deltas[key] = nil
end
-- get the delta t of a value
---@param key string value key
---@return number
local _get_dt = function (key)
if self.deltas[key] then
return self.deltas[key].dt
else
return 0.0
end
end
-- update all delta computations
local _dt__compute_all = function ()
if self.plc_s ~= nil then
local plc_db = self.plc_s.get_db()
-- @todo Meknaism 10.1+ will change fuel/waste to need _amnt
_compute_dt(DT_KEYS.ReactorTemp, plc_db.mek_status.temp)
_compute_dt(DT_KEYS.ReactorFuel, plc_db.mek_status.fuel)
_compute_dt(DT_KEYS.ReactorWaste, plc_db.mek_status.waste)
_compute_dt(DT_KEYS.ReactorCCool, plc_db.mek_status.ccool_amnt)
_compute_dt(DT_KEYS.ReactorHCool, plc_db.mek_status.hcool_amnt)
end
for i = 1, #self.boilers do
local boiler = self.boilers[i] ---@type unit_session
local db = boiler.get_db() ---@type boiler_session_db
-- @todo Meknaism 10.1+ will change water/steam to need .amount
_compute_dt(DT_KEYS.BoilerWater .. boiler.get_device_idx(), db.tanks.water)
_compute_dt(DT_KEYS.BoilerSteam .. boiler.get_device_idx(), db.tanks.steam)
_compute_dt(DT_KEYS.BoilerCCool .. boiler.get_device_idx(), db.tanks.ccool.amount)
_compute_dt(DT_KEYS.BoilerHCool .. boiler.get_device_idx(), db.tanks.hcool.amount)
end
for i = 1, #self.turbines do
local turbine = self.turbines[i] ---@type unit_session
local db = turbine.get_db() ---@type turbine_session_db
_compute_dt(DT_KEYS.TurbineSteam .. turbine.get_device_idx(), db.tanks.steam)
-- @todo Mekanism 10.1+ needed
-- _compute_dt(DT_KEYS.TurbinePower .. turbine.get_device_idx(), db.?)
end
end
-- update the annunciator
local _update_annunciator = function ()
-- update deltas
_dt__compute_all()
-------------
-- REACTOR --
-------------
-- check PLC status
self.db.annunciator.PLCOnline = (self.plc_s ~= nil) and (self.plc_s.open)
if self.plc_s ~= nil then
local plc_db = self.plc_s.get_db()
-- update annunciator
self.db.annunciator.ReactorTrip = plc_db.rps_tripped
self.db.annunciator.ManualReactorTrip = plc_db.rps_trip_cause == types.rps_status_t.manual
self.db.annunciator.RCPTrip = plc_db.rps_tripped and (plc_db.rps_status.ex_hcool or plc_db.rps_status.no_cool)
self.db.annunciator.RCSFlowLow = plc_db.mek_status.ccool_fill < 0.75 or plc_db.mek_status.hcool_fill > 0.25
self.db.annunciator.ReactorTempHigh = plc_db.mek_status.temp > 1000
self.db.annunciator.ReactorHighDeltaT = _get_dt(DT_KEYS.ReactorTemp) > 100
self.db.annunciator.FuelInputRateLow = _get_dt(DT_KEYS.ReactorFuel) < 0.0 or plc_db.mek_status.fuel_fill <= 0.01
self.db.annunciator.WasteLineOcclusion = _get_dt(DT_KEYS.ReactorWaste) > 0.0 or plc_db.mek_status.waste_fill >= 0.99
-- @todo this is dependent on setup, i.e. how much coolant is buffered and the turbine setup
self.db.annunciator.HighStartupRate = not plc_db.control_state and plc_db.mek_status.burn_rate > 40
end
-------------
-- BOILERS --
-------------
-- check boiler online status
local connected_boilers = #self.boilers
if connected_boilers == 0 and self.num_boilers > 0 then
self.db.annunciator.BoilerOnline = TRI_FAIL.FULL
elseif connected_boilers > 0 and connected_boilers ~= self.num_boilers then
self.db.annunciator.BoilerOnline = TRI_FAIL.PARTIAL
else
self.db.annunciator.BoilerOnline = TRI_FAIL.OK
end
-- compute aggregated statistics
local total_boil_rate = 0.0
local boiler_steam_dt_sum = 0.0
local boiler_water_dt_sum = 0.0
for i = 1, #self.boilers do
local boiler = self.boilers[i].get_db() ---@type boiler_session_db
total_boil_rate = total_boil_rate + boiler.state.boil_rate
boiler_steam_dt_sum = _get_dt(DT_KEYS.BoilerSteam .. self.boilers[i].get_device_idx())
boiler_water_dt_sum = _get_dt(DT_KEYS.BoilerWater .. self.boilers[i].get_device_idx())
end
-- check heating rate low
if self.plc_s ~= nil then
-- check for inactive boilers while reactor is active
for i = 1, #self.boilers do
local boiler = self.boilers[i] ---@type unit_session
local idx = boiler.get_device_idx()
local db = boiler.get_db() ---@type boiler_session_db
if self.plc_s.get_db().mek_status.status then
self.db.annunciator.HeatingRateLow[idx] = db.state.boil_rate == 0
else
self.db.annunciator.HeatingRateLow[idx] = false
end
end
-- check for rate mismatch
local expected_boil_rate = self.plc_s.get_db().mek_status.heating_rate / 10.0
self.db.annunciator.BoilRateMismatch = math.abs(expected_boil_rate - total_boil_rate) > 25.0
end
-- check coolant feed mismatch
local cfmismatch = false
for i = 1, #self.boilers do
local boiler = self.boilers[i] ---@type unit_session
local idx = boiler.get_device_idx()
local db = boiler.get_db() ---@type boiler_session_db
-- gaining heated coolant
cfmismatch = cfmismatch or _get_dt(DT_KEYS.BoilerHCool .. idx) > 0 or db.tanks.hcool_fill == 1
-- losing cooled coolant
cfmismatch = cfmismatch or _get_dt(DT_KEYS.BoilerCCool .. idx) < 0 or db.tanks.ccool_fill == 0
end
self.db.annunciator.CoolantFeedMismatch = cfmismatch
--------------
-- TURBINES --
--------------
-- check turbine online status
local connected_turbines = #self.turbines
if connected_turbines == 0 and self.num_turbines > 0 then
self.db.annunciator.TurbineOnline = TRI_FAIL.FULL
elseif connected_turbines > 0 and connected_turbines ~= self.num_turbines then
self.db.annunciator.TurbineOnline = TRI_FAIL.PARTIAL
else
self.db.annunciator.TurbineOnline = TRI_FAIL.OK
end
-- compute aggregated statistics
local total_flow_rate = 0
local total_input_rate = 0
local max_water_return_rate = 0
for i = 1, #self.turbines do
local turbine = self.turbines[i].get_db() ---@type turbine_session_db
total_flow_rate = total_flow_rate + turbine.state.flow_rate
total_input_rate = total_input_rate + turbine.state.steam_input_rate
max_water_return_rate = max_water_return_rate + turbine.build.max_water_output
end
-- check for steam feed mismatch and max return rate
local sfmismatch = math.abs(total_flow_rate - total_input_rate) > 10
sfmismatch = sfmismatch or boiler_steam_dt_sum > 0 or boiler_water_dt_sum < 0
self.db.annunciator.SteamFeedMismatch = sfmismatch
self.db.annunciator.MaxWaterReturnFeed = max_water_return_rate == total_flow_rate
-- check if steam dumps are open
for i = 1, #self.turbines do
local turbine = self.turbines[i] ---@type unit_session
local db = turbine.get_db() ---@type turbine_session_db
local idx = turbine.get_device_idx()
if db.state.dumping_mode == DUMPING_MODE.IDLE then
self.db.annunciator.SteamDumpOpen[idx] = TRI_FAIL.OK
elseif db.state.dumping_mode == DUMPING_MODE.DUMPING_EXCESS then
self.db.annunciator.SteamDumpOpen[idx] = TRI_FAIL.PARTIAL
else
self.db.annunciator.SteamDumpOpen[idx] = TRI_FAIL.FULL
end
end
-- check if turbines are at max speed but not keeping up
for i = 1, #self.turbines do
local turbine = self.turbines[i] ---@type unit_session
local db = turbine.get_db() ---@type turbine_session_db
local idx = turbine.get_device_idx()
self.db.annunciator.TurbineOverSpeed[idx] = (db.state.flow_rate == db.build.max_flow_rate) and (_get_dt(DT_KEYS.TurbineSteam .. idx) > 0)
end
--[[
Turbine Trip
a turbine trip is when the turbine stops, which means we are no longer receiving water and lose the ability to cool.
this can be identified by these conditions:
- the current flow rate is 0 mB/t and it should not be
- can initially catch this by detecting a 0 flow rate with a non-zero input rate, but eventually the steam will fill up
- can later identified by presence of steam in tank with a 0 flow rate
]]--
for i = 1, #self.turbines do
local turbine = self.turbines[i] ---@type unit_session
local db = turbine.get_db() ---@type turbine_session_db
local has_steam = db.state.steam_input_rate > 0 or db.tanks.steam_fill > 0.01
self.db.annunciator.TurbineTrip[turbine.get_device_idx()] = has_steam and db.state.flow_rate == 0
end
end
-- unlink disconnected units
---@param sessions table
local _unlink_disconnected_units = function (sessions)
util.filter_table(sessions, function (u) return u.is_connected() end)
end
-- PUBLIC FUNCTIONS --
-- link the PLC
---@param plc_session plc_session_struct
public.link_plc_session = function (plc_session)
self.plc_s = plc_session
-- reset deltas
_reset_dt(DT_KEYS.ReactorTemp)
_reset_dt(DT_KEYS.ReactorFuel)
_reset_dt(DT_KEYS.ReactorWaste)
_reset_dt(DT_KEYS.ReactorCCool)
_reset_dt(DT_KEYS.ReactorHCool)
end
-- link a turbine RTU session
---@param turbine unit_session
public.add_turbine = function (turbine)
if #self.turbines < self.num_turbines and turbine.get_device_idx() <= self.num_turbines then
table.insert(self.turbines, turbine)
-- reset deltas
_reset_dt(DT_KEYS.TurbineSteam .. turbine.get_device_idx())
_reset_dt(DT_KEYS.TurbinePower .. turbine.get_device_idx())
return true
else
return false
end
end
-- link a boiler RTU session
---@param boiler unit_session
public.add_boiler = function (boiler)
if #self.boilers < self.num_boilers and boiler.get_device_idx() <= self.num_boilers then
table.insert(self.boilers, boiler)
-- reset deltas
_reset_dt(DT_KEYS.BoilerWater .. boiler.get_device_idx())
_reset_dt(DT_KEYS.BoilerSteam .. boiler.get_device_idx())
_reset_dt(DT_KEYS.BoilerCCool .. boiler.get_device_idx())
_reset_dt(DT_KEYS.BoilerHCool .. boiler.get_device_idx())
return true
else
return false
end
end
-- link a redstone RTU capability
public.add_redstone = function (field, accessor)
-- ensure field exists
if self.redstone[field] == nil then
self.redstone[field] = {}
end
-- insert into list
table.insert(self.redstone[field], accessor)
end
-- update (iterate) this unit
public.update = function ()
-- unlink PLC if session was closed
if not self.plc_s.open then
self.plc_s = nil
end
-- unlink RTU unit sessions if they are closed
_unlink_disconnected_units(self.boilers)
_unlink_disconnected_units(self.turbines)
-- update annunciator logic
_update_annunciator()
end
-- get build properties of all machines
public.get_build = function ()
local build = {}
if self.plc_s ~= nil then
build.reactor = self.plc_s.get_struct()
end
build.boilers = {}
for i = 1, #self.boilers do
table.insert(build.boilers, self.boilers[i].get_db().build)
end
build.turbines = {}
for i = 1, #self.turbines do
table.insert(build.turbines, self.turbines[i].get_db().build)
end
return build
end
-- get reactor status
public.get_reactor_status = function ()
local status = {}
if self.plc_s ~= nil then
local reactor = self.plc_s
status.mek = reactor.get_status()
status.rps = reactor.get_rps()
status.general = reactor.get_general_status()
end
return status
end
-- get RTU statuses
public.get_rtu_statuses = function ()
local status = {}
-- status of boilers (including tanks)
status.boilers = {}
for i = 1, #self.boilers do
table.insert(status.boilers, {
state = self.boilers[i].get_db().state,
tanks = self.boilers[i].get_db().tanks,
})
end
-- status of turbines (including tanks)
status.turbines = {}
for i = 1, #self.turbines do
table.insert(status.turbines, {
state = self.turbines[i].get_db().state,
tanks = self.turbines[i].get_db().tanks,
})
end
return status
end
-- get the annunciator status
public.get_annunciator = function () return self.db.annunciator end
return public
end
return unit

236
test/modbustest.lua Normal file
View File

@@ -0,0 +1,236 @@
require("/initenv").init_env()
local types = require("scada-common.types")
local util = require("scada-common.util")
local testutils = require("test.testutils")
local modbus = require("rtu.modbus")
local redstone_rtu = require("rtu.dev.redstone_rtu")
local rsio = require("scada-common.rsio")
local print = util.print
local println = util.println
local MODBUS_FCODE = types.MODBUS_FCODE
local MODBUS_EXCODE = types.MODBUS_EXCODE
println("starting redstone RTU and MODBUS tester")
println("")
-- RTU init --
print(">>> init redstone RTU: ")
local rs_rtu = redstone_rtu.new()
local di, c, ir, hr = rs_rtu.io_count()
assert(di == 0 and c == 0 and ir == 0 and hr == 0, "IOCOUNT_0")
rs_rtu.link_di("back", colors.black)
rs_rtu.link_di("back", colors.blue)
rs_rtu.link_do(rsio.IO.F_ALARM, "back", colors.red)
rs_rtu.link_do(rsio.IO.WASTE_AM, "back", colors.purple)
rs_rtu.link_ai("right")
rs_rtu.link_ao("left")
di, c, ir, hr = rs_rtu.io_count()
assert(di == 2, "IOCOUNT_DI")
assert(c == 2, "IOCOUNT_C")
assert(ir == 1, "IOCOUNT_IR")
assert(hr == 1, "IOCOUNT_HR")
println("OK")
-- MODBUS testing --
local rs_modbus = modbus.new(rs_rtu, false)
local mbt = testutils.modbus_tester(rs_modbus, MODBUS_FCODE.ERROR_FLAG)
-------------------------
--- CHECKING REQUESTS ---
-------------------------
println(">>> checking MODBUS requests:")
print("read c {0}: ")
mbt.pkt_set(MODBUS_FCODE.READ_COILS, {0})
mbt.test_error__check_request(MODBUS_EXCODE.NEG_ACKNOWLEDGE)
println("PASS")
print("99 {1,2}: ")
mbt.pkt_set(99, {1, 2})
mbt.test_error__check_request(MODBUS_EXCODE.ILLEGAL_FUNCTION)
println("PASS")
print("read c {1,2}: ")
mbt.pkt_set(MODBUS_FCODE.READ_COILS, {1, 2})
mbt.test_success__check_request(MODBUS_EXCODE.ACKNOWLEDGE)
println("PASS")
testutils.pause()
--------------------
--- BAD REQUESTS ---
--------------------
println(">>> trying bad requests:")
print("read di {1,10}: ")
mbt.pkt_set(MODBUS_FCODE.READ_DISCRETE_INPUTS, {1, 10})
mbt.test_error__handle_packet(MODBUS_EXCODE.ILLEGAL_DATA_ADDR)
println("PASS")
print("read di {5,1}: ")
mbt.pkt_set(MODBUS_FCODE.READ_DISCRETE_INPUTS, {5, 1})
mbt.test_error__handle_packet(MODBUS_EXCODE.ILLEGAL_DATA_ADDR)
println("PASS")
print("read di {1,0}: ")
mbt.pkt_set(MODBUS_FCODE.READ_DISCRETE_INPUTS, {1, 0})
mbt.test_error__handle_packet(MODBUS_EXCODE.ILLEGAL_DATA_ADDR)
println("PASS")
print("read c {5,1}: ")
mbt.pkt_set(MODBUS_FCODE.READ_COILS, {5, 1})
mbt.test_error__handle_packet(MODBUS_EXCODE.ILLEGAL_DATA_ADDR)
println("PASS")
print("read c {1,0}: ")
mbt.pkt_set(MODBUS_FCODE.READ_COILS, {1, 0})
mbt.test_error__handle_packet(MODBUS_EXCODE.ILLEGAL_DATA_ADDR)
println("PASS")
print("read ir {5,1}: ")
mbt.pkt_set(MODBUS_FCODE.READ_INPUT_REGS, {5, 1})
mbt.test_error__handle_packet(MODBUS_EXCODE.ILLEGAL_DATA_ADDR)
println("PASS")
print("read ir {1,0}: ")
mbt.pkt_set(MODBUS_FCODE.READ_INPUT_REGS, {1, 0})
mbt.test_error__handle_packet(MODBUS_EXCODE.ILLEGAL_DATA_ADDR)
println("PASS")
print("read hr {5,1}: ")
mbt.pkt_set(MODBUS_FCODE.READ_MUL_HOLD_REGS, {5, 1})
mbt.test_error__handle_packet(MODBUS_EXCODE.ILLEGAL_DATA_ADDR)
println("PASS")
print("write c {5,1}: ")
mbt.pkt_set(MODBUS_FCODE.WRITE_SINGLE_COIL, {5, 1})
mbt.test_error__handle_packet(MODBUS_EXCODE.ILLEGAL_DATA_ADDR)
println("PASS")
print("write mul c {5,1}: ")
mbt.pkt_set(MODBUS_FCODE.WRITE_SINGLE_COIL, {5, 1})
mbt.test_error__handle_packet(MODBUS_EXCODE.ILLEGAL_DATA_ADDR)
println("PASS")
print("write mul c {5,{1}}: ")
mbt.pkt_set(MODBUS_FCODE.WRITE_SINGLE_COIL, {5, {1}})
mbt.test_error__handle_packet(MODBUS_EXCODE.ILLEGAL_DATA_ADDR)
println("PASS")
print("write hr {5,1}: ")
mbt.pkt_set(MODBUS_FCODE.WRITE_SINGLE_HOLD_REG, {5, 1})
mbt.test_error__handle_packet(MODBUS_EXCODE.ILLEGAL_DATA_ADDR)
println("PASS")
print("write mul hr {5,{1}}: ")
mbt.pkt_set(MODBUS_FCODE.WRITE_SINGLE_HOLD_REG, {5, {1}})
mbt.test_error__handle_packet(MODBUS_EXCODE.ILLEGAL_DATA_ADDR)
println("PASS")
testutils.pause()
----------------------
--- READING INPUTS ---
----------------------
println(">>> reading inputs:")
print("read di {1,1}: ")
mbt.pkt_set(MODBUS_FCODE.READ_DISCRETE_INPUTS, {1, 1})
mbt.test_success__handle_packet()
print("read di {2,1}: ")
mbt.pkt_set(MODBUS_FCODE.READ_DISCRETE_INPUTS, {2, 1})
mbt.test_success__handle_packet()
print("read di {1,2}: ")
mbt.pkt_set(MODBUS_FCODE.READ_DISCRETE_INPUTS, {1, 2})
mbt.test_success__handle_packet()
print("read ir {1,1}: ")
mbt.pkt_set(MODBUS_FCODE.READ_INPUT_REGS, {1, 1})
mbt.test_success__handle_packet()
testutils.pause()
-----------------------
--- WRITING OUTPUTS ---
-----------------------
println(">>> writing outputs:")
print("write mul c {1,{LOW,LOW}}: ")
mbt.pkt_set(MODBUS_FCODE.WRITE_MUL_COILS, {1, {rsio.IO_LVL.LOW, rsio.IO_LVL.LOW}})
mbt.test_success__handle_packet()
testutils.pause()
print("write c {1,HIGH}: ")
mbt.pkt_set(MODBUS_FCODE.WRITE_SINGLE_COIL, {1, rsio.IO_LVL.HIGH})
mbt.test_success__handle_packet()
testutils.pause()
print("write c {2,HIGH}: ")
mbt.pkt_set(MODBUS_FCODE.WRITE_SINGLE_COIL, {2, rsio.IO_LVL.HIGH})
mbt.test_success__handle_packet()
testutils.pause()
print("write hr {1,7}: ")
mbt.pkt_set(MODBUS_FCODE.WRITE_SINGLE_HOLD_REG, {1, 7})
mbt.test_success__handle_packet()
testutils.pause()
print("write mul hr {1,{4}}: ")
mbt.pkt_set(MODBUS_FCODE.WRITE_MUL_HOLD_REGS, {1, {4}})
mbt.test_success__handle_packet()
println("PASS")
testutils.pause()
-----------------------
--- READING OUTPUTS ---
-----------------------
println(">>> reading outputs:")
print("read c {1,1}: ")
mbt.pkt_set(MODBUS_FCODE.READ_COILS, {1, 1})
mbt.test_success__handle_packet()
print("read c {2,1}: ")
mbt.pkt_set(MODBUS_FCODE.READ_COILS, {2, 1})
mbt.test_success__handle_packet()
print("read c {1,2}: ")
mbt.pkt_set(MODBUS_FCODE.READ_COILS, {1, 2})
mbt.test_success__handle_packet()
print("read hr {1,1}: ")
mbt.pkt_set(MODBUS_FCODE.READ_MUL_HOLD_REGS, {1, 1})
mbt.test_success__handle_packet()
println("PASS")
println("TEST COMPLETE")

145
test/rstest.lua Normal file
View File

@@ -0,0 +1,145 @@
require("/initenv").init_env()
local rsio = require("scada-common.rsio")
local util = require("scada-common.util")
local testutils = require("test.testutils")
local print = util.print
local println = util.println
local IO = rsio.IO
local IO_LVL = rsio.IO_LVL
local IO_DIR = rsio.IO_DIR
local IO_MODE = rsio.IO_MODE
println("starting RSIO tester")
println("")
println(">>> checking valid channels:")
-- channel function tests
local cid = 0
local max_value = 1
for key, value in pairs(IO) do
if value > max_value then max_value = value end
cid = cid + 1
local c_name = rsio.to_string(value)
local io_mode = rsio.get_io_mode(value)
local mode = ""
if io_mode == IO_MODE.DIGITAL_IN then
mode = " (DIGITAL_IN)"
elseif io_mode == IO_MODE.DIGITAL_OUT then
mode = " (DIGITAL_OUT)"
elseif io_mode == IO_MODE.ANALOG_IN then
mode = " (ANALOG_IN)"
elseif io_mode == IO_MODE.ANALOG_OUT then
mode = " (ANALOG_OUT)"
else
error("unknown mode for channel " .. key)
end
assert(key == c_name, c_name .. " != " .. key .. ": " .. value .. mode)
println(c_name .. ": " .. value .. mode)
end
assert(max_value == cid, "RS_IO last IDx out-of-sync with count: " .. max_value .. " (count " .. cid .. ")")
testutils.pause()
println(">>> checking invalid channels:")
testutils.test_func("rsio.to_string", rsio.to_string, { -1, 100, false }, "")
testutils.test_func_nil("rsio.to_string", rsio.to_string, "")
testutils.test_func("rsio.get_io_mode", rsio.get_io_mode, { -1, 100, false }, IO_MODE.ANALOG_IN)
testutils.test_func_nil("rsio.get_io_mode", rsio.get_io_mode, IO_MODE.ANALOG_IN)
testutils.pause()
println(">>> checking validity checks:")
local ivc_t_list = { 0, -1, 100 }
testutils.test_func("rsio.is_valid_channel", rsio.is_valid_channel, ivc_t_list, false)
testutils.test_func_nil("rsio.is_valid_channel", rsio.is_valid_channel, false)
local ivs_t_list = rs.getSides()
testutils.test_func("rsio.is_valid_side", rsio.is_valid_side, ivs_t_list, true)
testutils.test_func("rsio.is_valid_side", rsio.is_valid_side, { "" }, false)
testutils.test_func_nil("rsio.is_valid_side", rsio.is_valid_side, false)
local ic_t_list = { colors.white, colors.purple, colors.blue, colors.cyan, colors.black }
testutils.test_func("rsio.is_color", rsio.is_color, ic_t_list, true)
testutils.test_func("rsio.is_color", rsio.is_color, { 0, 999999, colors.combine(colors.red, colors.blue, colors.black) }, false)
testutils.test_func_nil("rsio.is_color", rsio.is_color, false)
testutils.pause()
println(">>> checking channel-independent I/O wrappers:")
testutils.test_func("rsio.digital_read", rsio.digital_read, { true, false }, { IO_LVL.HIGH, IO_LVL.LOW })
print("rsio.analog_read(): ")
assert(rsio.analog_read(0, 0, 100) == 0, "RS_READ_0_100")
assert(rsio.analog_read(7.5, 0, 100) == 50, "RS_READ_7_5_100")
assert(rsio.analog_read(15, 0, 100) == 100, "RS_READ_15_100")
assert(rsio.analog_read(4, 0, 15) == 4, "RS_READ_4_15")
assert(rsio.analog_read(12, 0, 15) == 12, "RS_READ_12_15")
println("PASS")
print("rsio.analog_write(): ")
assert(rsio.analog_write(0, 0, 100) == 0, "RS_WRITE_0_100")
assert(rsio.analog_write(100, 0, 100) == 15, "RS_WRITE_100_100")
assert(rsio.analog_write(4, 0, 15) == 4, "RS_WRITE_4_15")
assert(rsio.analog_write(12, 0, 15) == 12, "RS_WRITE_12_15")
println("PASS")
testutils.pause()
println(">>> checking channel I/O:")
print("rsio.digital_is_active(...): ")
-- check input channels
assert(rsio.digital_is_active(IO.F_SCRAM, IO_LVL.LOW) == true, "IO_F_SCRAM_HIGH")
assert(rsio.digital_is_active(IO.F_SCRAM, IO_LVL.HIGH) == false, "IO_F_SCRAM_LOW")
assert(rsio.digital_is_active(IO.R_SCRAM, IO_LVL.LOW) == true, "IO_R_SCRAM_HIGH")
assert(rsio.digital_is_active(IO.R_SCRAM, IO_LVL.HIGH) == false, "IO_R_SCRAM_LOW")
assert(rsio.digital_is_active(IO.R_ENABLE, IO_LVL.LOW) == false, "IO_R_ENABLE_HIGH")
assert(rsio.digital_is_active(IO.R_ENABLE, IO_LVL.HIGH) == true, "IO_R_ENABLE_LOW")
-- non-inputs should always return LOW
assert(rsio.digital_is_active(IO.F_ALARM, IO_LVL.LOW) == false, "IO_OUT_READ_LOW")
assert(rsio.digital_is_active(IO.F_ALARM, IO_LVL.HIGH) == false, "IO_OUT_READ_HIGH")
println("PASS")
-- check output channels
print("rsio.digital_write(...): ")
-- check output channels
assert(rsio.digital_write(IO.F_ALARM, IO_LVL.LOW) == false, "IO_F_ALARM_FALSE")
assert(rsio.digital_write(IO.F_ALARM, IO_LVL.HIGH) == true, "IO_F_ALARM_TRUE")
assert(rsio.digital_write(IO.WASTE_PO, IO_LVL.HIGH) == false, "IO_WASTE_PO_FALSE")
assert(rsio.digital_write(IO.WASTE_PO, IO_LVL.LOW) == true, "IO_WASTE_PO_TRUE")
assert(rsio.digital_write(IO.WASTE_PU, IO_LVL.HIGH) == false, "IO_WASTE_PU_FALSE")
assert(rsio.digital_write(IO.WASTE_PU, IO_LVL.LOW) == true, "IO_WASTE_PU_TRUE")
assert(rsio.digital_write(IO.WASTE_AM, IO_LVL.HIGH) == false, "IO_WASTE_AM_FALSE")
assert(rsio.digital_write(IO.WASTE_AM, IO_LVL.LOW) == true, "IO_WASTE_AM_TRUE")
-- check all reactor output channels (all are active high)
for i = IO.R_ALARM, (IO.R_PLC_TIMEOUT - IO.R_ALARM + 1) do
assert(rsio.to_string(i) ~= "", "REACTOR_IO_BAD_CHANNEL")
assert(rsio.digital_write(i, IO_LVL.LOW) == false, "IO_" .. rsio.to_string(i) .. "_FALSE")
assert(rsio.digital_write(i, IO_LVL.HIGH) == true, "IO_" .. rsio.to_string(i) .. "_TRUE")
end
-- non-outputs should always return false
assert(rsio.digital_write(IO.F_SCRAM, IO_LVL.LOW) == false, "IO_IN_WRITE_LOW")
assert(rsio.digital_write(IO.F_SCRAM, IO_LVL.LOW) == false, "IO_IN_WRITE_HIGH")
println("PASS")
println("TEST COMPLETE")

122
test/testutils.lua Normal file
View File

@@ -0,0 +1,122 @@
local util = require("scada-common.util")
local print = util.print
local println = util.println
local testutils = {}
-- test a function
---@param name string function name
---@param f function function
---@param values table input values, one per function call
---@param results any table of values or a single value for all tests
function testutils.test_func(name, f, values, results)
-- if only one value was given, use that for all checks
if type(results) ~= "table" then
local _r = {}
for _ = 1, #values do
table.insert(_r, results)
end
results = _r
end
assert(#values == #results, "test_func(" .. name .. ") #values ~= #results")
for i = 1, #values do
local check = values[i]
local expect = results[i]
print(name .. "(" .. util.strval(check) .. ") => ")
assert(f(check) == expect, "FAIL")
println("PASS")
end
end
-- test a function with nil as a parameter
---@param name string function name
---@param f function function
---@param result any expected result
function testutils.test_func_nil(name, f, result)
print(name .. "(nil) => ")
assert(f(nil) == result, "FAIL")
println("PASS")
end
-- get something as a string
---@param result any
---@return string
function testutils.stringify(result)
return textutils.serialize(result, { allow_repetitions = true, compact = true })
end
-- pause for 1 second, or the provided seconds
---@param seconds? number
function testutils.pause(seconds)
seconds = seconds or 1.0
---@diagnostic disable-next-line: undefined-field
os.sleep(seconds)
end
-- create a new MODBUS tester
---@param modbus modbus modbus object
---@param error_flag MODBUS_FCODE MODBUS_FCODE.ERROR_FLAG
function testutils.modbus_tester(modbus, error_flag)
-- test packet
---@type modbus_frame
local packet = {
txn_id = 0,
length = 0,
unit_id = 0,
func_code = 0,
data = {},
scada_frame = nil
}
---@class modbus_tester
local public = {}
-- set the packet function and data for the next test
---@param func MODBUS_FCODE function code
---@param data table
function public.pkt_set(func, data)
packet.length = #data
packet.data = data
packet.func_code = func
end
-- check the current packet, expecting an error
---@param excode MODBUS_EXCODE exception code to expect
function public.test_error__check_request(excode)
local rcode, reply = modbus.check_request(packet)
assert(rcode == false, "CHECK_NOT_FAIL")
assert(reply.get().func_code == bit.bor(packet.func_code, error_flag), "WRONG_FCODE")
assert(reply.get().data[1] == excode, "EXCODE_MISMATCH")
end
-- test the current packet, expecting an error
---@param excode MODBUS_EXCODE exception code to expect
function public.test_error__handle_packet(excode)
local rcode, reply = modbus.handle_packet(packet)
assert(rcode == false, "CHECK_NOT_FAIL")
assert(reply.get().func_code == bit.bor(packet.func_code, error_flag), "WRONG_FCODE")
assert(reply.get().data[1] == excode, "EXCODE_MISMATCH")
end
-- check the current packet, expecting success
---@param excode MODBUS_EXCODE exception code to expect
function public.test_success__check_request(excode)
local rcode, reply = modbus.check_request(packet)
assert(rcode, "CHECK_NOT_OK")
assert(reply.get().data[1] == excode, "EXCODE_MISMATCH")
end
-- test the current packet, expecting success
function public.test_success__handle_packet()
local rcode, reply = modbus.handle_packet(packet)
assert(rcode, "CHECK_NOT_OK")
println(testutils.stringify(reply.get().data))
end
return public
end
return testutils

View File

@@ -0,0 +1,68 @@
require("/initenv").init_env()
local log = require("scada-common.log")
local ppm = require("scada-common.ppm")
local types = require("scada-common.types")
local util = require("scada-common.util")
local testutils = require("test.testutils")
local modbus = require("rtu.modbus")
local turbine_rtu = require("rtu.dev.turbine_rtu")
local print = util.print
local println = util.println
local MODBUS_FCODE = types.MODBUS_FCODE
println("starting turbine RTU MODBUS tester")
println("note: use rs_modbustest to fully test RTU/MODBUS")
println(" this only tests a turbine/parallel read")
println("")
-- RTU init --
log.init("/log.txt", log.MODE.NEW)
print(">>> init turbine RTU: ")
ppm.mount_all()
local dev = ppm.get_device("turbine")
assert(dev ~= nil, "NO_TURBINE")
local t_rtu = turbine_rtu.new(dev)
local di, c, ir, hr = t_rtu.io_count()
assert(di == 0, "IOCOUNT_DI")
assert(c == 0, "IOCOUNT_C")
assert(ir == 16, "IOCOUNT_IR")
assert(hr == 0, "IOCOUNT_HR")
println("OK")
local t_modbus = modbus.new(t_rtu, true)
local mbt = testutils.modbus_tester(t_modbus, MODBUS_FCODE.ERROR_FLAG)
----------------------
--- READING INPUTS ---
----------------------
println(">>> reading inputs:")
print("read ir {1,1}: ")
mbt.pkt_set(MODBUS_FCODE.READ_INPUT_REGS, {1, 1})
mbt.test_success__handle_packet()
print("read ir {2,1}: ")
mbt.pkt_set(MODBUS_FCODE.READ_INPUT_REGS, {2, 1})
mbt.test_success__handle_packet()
print("read ir {1,16}: ")
mbt.pkt_set(MODBUS_FCODE.READ_INPUT_REGS, {1, 16})
mbt.test_success__handle_packet()
println("PASS")
println("TEST COMPLETE")