Compare commits
234 Commits
v0.0.7-pre
...
v0.1.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e21c7d92fe | ||
|
|
706bf4d3ba | ||
|
|
4d16d64cdc | ||
|
|
6df0a1d149 | ||
|
|
51111f707f | ||
|
|
214f2d9028 | ||
|
|
78ddd4d782 | ||
|
|
7d7eecaa5e | ||
|
|
4d7d3be93b | ||
|
|
ffc997b84e | ||
|
|
4b6a1c5902 | ||
|
|
0cf81040fb | ||
|
|
9fb6b7a880 | ||
|
|
a93f0a4452 | ||
|
|
26c6010ce0 | ||
|
|
3b16d783d3 | ||
|
|
940ddf0d00 | ||
|
|
3f4fb63029 | ||
|
|
61965f295d | ||
|
|
44d30ae583 | ||
|
|
6a168c884d | ||
|
|
dd553125d6 | ||
|
|
62d5490dc8 | ||
|
|
790571b6fc | ||
|
|
cc856d4d80 | ||
|
|
6184078c3f | ||
|
|
9c034c366b | ||
|
|
31ede51c42 | ||
|
|
0eff8a3e6a | ||
|
|
136b09d7f2 | ||
|
|
bdd8af1873 | ||
|
|
11b89b679d | ||
|
|
530a40b0aa | ||
|
|
4834dbf781 | ||
|
|
374bfb7a19 | ||
|
|
94931ef5a2 | ||
|
|
bc6453de2b | ||
|
|
533d398f9d | ||
|
|
d3a926b25a | ||
|
|
6b74db70bd | ||
|
|
282d3fcd99 | ||
|
|
72da718015 | ||
|
|
bf0e92d6e4 | ||
|
|
c53ddf1638 | ||
|
|
45f5843598 | ||
|
|
fc39588b2e | ||
|
|
635e7b7f59 | ||
|
|
13fcf265b7 | ||
|
|
8b43c81fc0 | ||
|
|
e624dd431b | ||
|
|
969abca95d | ||
|
|
9695e94608 | ||
|
|
b985362757 | ||
|
|
0d090fe9e2 | ||
|
|
95c4d51e67 | ||
|
|
c6987f6f67 | ||
|
|
5ad14205f3 | ||
|
|
02541184bd | ||
|
|
bced8bf566 | ||
|
|
faac421b63 | ||
|
|
22a6159520 | ||
|
|
f4e397ebb1 | ||
|
|
87de804a9e | ||
|
|
e3a4ed5363 | ||
|
|
3c688bfafa | ||
|
|
6e1ece8183 | ||
|
|
168341db39 | ||
|
|
d7e38d6393 | ||
|
|
cd0d7aa5a3 | ||
|
|
25558df22d | ||
|
|
679d98c8bf | ||
|
|
469ee29b5a | ||
|
|
96e535fdc4 | ||
|
|
4aab75b842 | ||
|
|
17a46ae642 | ||
|
|
d0b2820160 | ||
|
|
b7e5ced2e8 | ||
|
|
eaabe51537 | ||
|
|
83fa41bbd0 | ||
|
|
89be79192f | ||
|
|
c4df8eabf9 | ||
|
|
b575899d46 | ||
|
|
7bcb260712 | ||
|
|
1cb5a0789e | ||
|
|
8b7ef47aad | ||
|
|
e253a7b4ff | ||
|
|
fe5059dd51 | ||
|
|
25c6b311f5 | ||
|
|
665b33fa05 | ||
|
|
635c70cffd | ||
|
|
dc1c1db5e6 | ||
|
|
e2f7318922 | ||
|
|
c76200b0e3 | ||
|
|
62b4b63f4a | ||
|
|
574b85e177 | ||
|
|
e3e370d3ab | ||
|
|
1ac4de65a9 | ||
|
|
c19a58380c | ||
|
|
4bc50e4bad | ||
|
|
5ce3f84dfa | ||
|
|
b280201446 | ||
|
|
76c81395b7 | ||
|
|
7ff0e25711 | ||
|
|
cd46c69a66 | ||
|
|
b76871aa07 | ||
|
|
479194b589 | ||
|
|
3fe47f99a9 | ||
|
|
aeda38fa01 | ||
|
|
10aa34a8e8 | ||
|
|
e1135eac01 | ||
|
|
c805b6e0c5 | ||
|
|
3587352219 | ||
|
|
84e7ad43bc | ||
|
|
e833176c65 | ||
|
|
ef1fdc7f39 | ||
|
|
07e9101ac7 | ||
|
|
4d5cbcf475 | ||
|
|
d688f9a1c6 | ||
|
|
67ec8fbd91 | ||
|
|
aff166e27d | ||
|
|
f14d715070 | ||
|
|
7f0f423450 | ||
|
|
f067da31b4 | ||
|
|
fe3b8e6f88 | ||
|
|
46a27a3f3a | ||
|
|
82726520b8 | ||
|
|
d40937b467 | ||
|
|
f996b9414a | ||
|
|
146e0bf569 | ||
|
|
67a93016c0 | ||
|
|
14377e7348 | ||
|
|
8c4598e7a6 | ||
|
|
71be6aca1a | ||
|
|
ccf06956f9 | ||
|
|
1ba5c7f828 | ||
|
|
68011d6734 | ||
|
|
f7f723829c | ||
|
|
19a4b3c0ef | ||
|
|
3ef2902829 | ||
|
|
b861d3f668 | ||
|
|
e119c11204 | ||
|
|
b1998b61bc | ||
|
|
0fc49d312d | ||
|
|
c46a7b2486 | ||
|
|
1744527a41 | ||
|
|
074f6448e1 | ||
|
|
74168707c6 | ||
|
|
86b0d155fa | ||
|
|
416255f41a | ||
|
|
fa19af308d | ||
|
|
852161317d | ||
|
|
3285f829f6 | ||
|
|
812d10f374 | ||
|
|
cd289ffb1e | ||
|
|
89ff502964 | ||
|
|
b25d95eeb7 | ||
|
|
554f09c817 | ||
|
|
912011bfed | ||
|
|
78a1073e2a | ||
|
|
6daf6df2d0 | ||
|
|
1bf0d352a1 | ||
|
|
17d0213d58 | ||
|
|
f7c11febe5 | ||
|
|
991c855c11 | ||
|
|
b10a8d9479 | ||
|
|
0c132f6e43 | ||
|
|
4842f9cb0d | ||
|
|
04f8dc7d75 | ||
|
|
3da7b74cfb | ||
|
|
b89724ad59 | ||
|
|
a3920ec2d8 | ||
|
|
6a5e0243be | ||
|
|
91079eeb78 | ||
|
|
2278469a8b | ||
|
|
377cf8e6fc | ||
|
|
7d9a664d38 | ||
|
|
a6e1134dc3 | ||
|
|
6d6953d795 | ||
|
|
0c5eb77cba | ||
|
|
ba5975f29b | ||
|
|
2a21d7d0be | ||
|
|
945b761fc2 | ||
|
|
203d868aeb | ||
|
|
28b1c03e03 | ||
|
|
b085baf91b | ||
|
|
03f9284f30 | ||
|
|
7e7e98ff6b | ||
|
|
ba1dd1b50e | ||
|
|
895750ea14 | ||
|
|
c47f45ea46 | ||
|
|
f24b214229 | ||
|
|
5b32f83890 | ||
|
|
dbf7377c02 | ||
|
|
13b0fcf65f | ||
|
|
02763c9cb3 | ||
|
|
34fc625602 | ||
|
|
ed997d53e1 | ||
|
|
7c2d89e70f | ||
|
|
a77946ce2c | ||
|
|
36fb4587a1 | ||
|
|
013656bc4d | ||
|
|
5eaeb50000 | ||
|
|
2ee503946c | ||
|
|
be73b17d46 | ||
|
|
60674ec95c | ||
|
|
74ae57324b | ||
|
|
1e23a2fd67 | ||
|
|
5642e3283d | ||
|
|
6e1e4c4685 | ||
|
|
a9d4458103 | ||
|
|
17874c4658 | ||
|
|
ac4ca3e56e | ||
|
|
5cff346cb5 | ||
|
|
a0b2c1f3e2 | ||
|
|
ea84563bb4 | ||
|
|
3c67ee08a8 | ||
|
|
1c6244d235 | ||
|
|
9cd0079d9e | ||
|
|
d6a68ee3d9 | ||
|
|
8429cbfd6e | ||
|
|
14cb7f96fc | ||
|
|
ffca88845b | ||
|
|
c6722c4cbe | ||
|
|
b3a2cfabc6 | ||
|
|
018b228976 | ||
|
|
00a81ab4f0 | ||
|
|
e47b4d7959 | ||
|
|
4dfdb218e2 | ||
|
|
78cbb9e67d | ||
|
|
c78db71b14 | ||
|
|
3b492ead92 | ||
|
|
ab49322fec | ||
|
|
26cce3a46a | ||
|
|
0dac25d9e7 |
13
.vscode/settings.json
vendored
Normal file
13
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"Lua.diagnostics.globals": [
|
||||
"term",
|
||||
"fs",
|
||||
"peripheral",
|
||||
"rs",
|
||||
"bit",
|
||||
"parallel",
|
||||
"colors",
|
||||
"textutils",
|
||||
"shell"
|
||||
]
|
||||
}
|
||||
63
README.md
63
README.md
@@ -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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||
12
coordinator/coordinator.lua
Normal file
12
coordinator/coordinator.lua
Normal 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
37
coordinator/startup.lua
Normal 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
18
initenv.lua
Normal 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 }
|
||||
@@ -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
|
||||
@@ -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
|
||||
52
main/log.lua
52
main/log.lua
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
370
main/render.lua
370
main/render.lua
@@ -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
|
||||
109
main/server.lua
109
main/server.lua
@@ -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
0
pocket/config.lua
Normal file
5
pocket/startup.lua
Normal file
5
pocket/startup.lua
Normal file
@@ -0,0 +1,5 @@
|
||||
--
|
||||
-- SCADA Coordinator Access on a Pocket Computer
|
||||
--
|
||||
|
||||
require("/initenv").init_env()
|
||||
18
reactor-plc/config.lua
Normal file
18
reactor-plc/config.lua
Normal 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
769
reactor-plc/plc.lua
Normal 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
175
reactor-plc/startup.lua
Normal 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
627
reactor-plc/threads.lua
Normal 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
52
rtu/config.lua
Normal 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
51
rtu/dev/boiler_rtu.lua
Normal 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
58
rtu/dev/boilerv_rtu.lua
Normal 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
|
||||
39
rtu/dev/energymachine_rtu.lua
Normal file
39
rtu/dev/energymachine_rtu.lua
Normal 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
45
rtu/dev/imatrix_rtu.lua
Normal 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
113
rtu/dev/redstone_rtu.lua
Normal 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
46
rtu/dev/turbine_rtu.lua
Normal 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
57
rtu/dev/turbinev_rtu.lua
Normal 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
439
rtu/modbus.lua
Normal 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
419
rtu/rtu.lua
Normal 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
286
rtu/startup.lua
Normal 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
319
rtu/threads.lua
Normal 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
73
scada-common/alarm.lua
Normal 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
609
scada-common/comms.lua
Normal 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
205
scada-common/log.lua
Normal 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
83
scada-common/mqueue.lua
Normal 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
319
scada-common/ppm.lua
Normal 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
289
scada-common/rsio.lua
Normal 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
100
scada-common/types.lua
Normal 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
262
scada-common/util.lua
Normal 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
|
||||
@@ -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
50
startup.lua
Normal 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
23
supervisor/config.lua
Normal 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
|
||||
3
supervisor/session/coordinator.lua
Normal file
3
supervisor/session/coordinator.lua
Normal file
@@ -0,0 +1,3 @@
|
||||
local coordinator = {}
|
||||
|
||||
return coordinator
|
||||
621
supervisor/session/plc.lua
Normal file
621
supervisor/session/plc.lua
Normal 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
325
supervisor/session/rtu.lua
Normal 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
|
||||
188
supervisor/session/rtu/boiler.lua
Normal file
188
supervisor/session/rtu/boiler.lua
Normal 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
|
||||
128
supervisor/session/rtu/emachine.lua
Normal file
128
supervisor/session/rtu/emachine.lua
Normal 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
|
||||
254
supervisor/session/rtu/redstone.lua
Normal file
254
supervisor/session/rtu/redstone.lua
Normal 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
|
||||
176
supervisor/session/rtu/turbine.lua
Normal file
176
supervisor/session/rtu/turbine.lua
Normal 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
|
||||
91
supervisor/session/rtu/txnctrl.lua
Normal file
91
supervisor/session/rtu/txnctrl.lua
Normal 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
|
||||
155
supervisor/session/rtu/unit_session.lua
Normal file
155
supervisor/session/rtu/unit_session.lua
Normal 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
|
||||
294
supervisor/session/svsessions.lua
Normal file
294
supervisor/session/svsessions.lua
Normal 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
117
supervisor/startup.lua
Normal 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
255
supervisor/supervisor.lua
Normal 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
460
supervisor/unit.lua
Normal 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
236
test/modbustest.lua
Normal 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
145
test/rstest.lua
Normal 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
122
test/testutils.lua
Normal 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
|
||||
68
test/turbine_modbustest.lua
Normal file
68
test/turbine_modbustest.lua
Normal 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")
|
||||
Reference in New Issue
Block a user