Merge pull request #16 from MikaylaFischler/devel

Alpha PLC & RTU Code
This commit is contained in:
Mikayla Fischler 2022-04-18 10:35:15 -04:00 committed by GitHub
commit a3920ec2d8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 3021 additions and 161 deletions

View File

@ -1,2 +1,54 @@
# 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!
## [SCADA](https://en.wikipedia.org/wiki/SCADA)
> Supervisory control and data acquisition (SCADA) is a control system architecture comprising computers, networked data communications and graphical user interfaces for high-level supervision of machines and processes. It also covers sensors and other devices, such as programmable logic controllers, which interface with process plant or machinery.
This project implements concepts of a SCADA system in ComputerCraft (because why not? ..okay don't answer that). I recommend reviewing that linked wikipedia page on SCADA if you want to understand the concepts used here.
![Architecture](https://upload.wikimedia.org/wikipedia/commons/thumb/1/10/Functional_levels_of_a_Distributed_Control_System.svg/1000px-Functional_levels_of_a_Distributed_Control_System.svg.png)
SCADA and industrial automation terminology is used throughout the project, such as:
- Supervisory Computer: Gathers data and control 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
### Coordinating Computers
There can be one or more of these. They can be either an Advanced Computer or a Pocket Computer.
### Supervisory Computers
There can be at most two of these in an active-backup configuration. If a backup is configured, it will act as a hot backup. This means it will be live, all data will be recieved by both it and the active computer, but it will not be commanding anything unless it hears that the active supervisor is shutting down or loses communication with the active supervisor.
### 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.

View File

@ -0,0 +1,8 @@
-- #REQUIRES comms.lua
-- coordinator communications
function coord_comms()
local self = {
reactor_struct_cache = nil
}
end

27
coordinator/startup.lua Normal file
View File

@ -0,0 +1,27 @@
--
-- Nuclear Generation Facility SCADA Coordinator
--
os.loadAPI("scada-common/log.lua")
os.loadAPI("scada-common/util.lua")
os.loadAPI("scada-common/ppm.lua")
os.loadAPI("scada-common/comms.lua")
os.loadAPI("coordinator/config.lua")
os.loadAPI("coordinator/coordinator.lua")
local COORDINATOR_VERSION = "alpha-v0.1.0"
local print_ts = util.print_ts
ppm.mount_all()
local modem = ppm.get_device("modem")
print("| SCADA Coordinator - " .. COORDINATOR_VERSION .. " |")
-- we need a modem
if modem == nil then
print("Please connect a modem.")
return
end

0
pocket/config.lua Normal file
View File

3
pocket/startup.lua Normal file
View File

@ -0,0 +1,3 @@
--
-- SCADA Coordinator Access on a Pocket Computer
--

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

@ -0,0 +1,8 @@
-- set to false to run in standalone mode (safety regulation only)
NETWORKED = true
-- unique reactor ID
REACTOR_ID = 1
-- port to send packets TO server
SERVER_PORT = 16000
-- port to listen to incoming packets FROM server
LISTEN_PORT = 14001

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

@ -0,0 +1,624 @@
-- #REQUIRES comms.lua
-- #REQUIRES ppm.lua
-- Internal Safety System
-- identifies dangerous states and SCRAMs reactor if warranted
-- autonomous from main SCADA supervisor/coordinator control
function iss_init(reactor)
local self = {
reactor = reactor,
timed_out = false,
tripped = false,
trip_cause = ""
}
-- re-link a reactor after a peripheral re-connect
local reconnect_reactor = function (reactor)
self.reactor = reactor
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("ISS: failed to check reactor damage")
return false
else
return damage_percent >= 100
end
end
-- check for heated coolant backup
local excess_heated_coolant = function ()
local hc_needed = self.reactor.getHeatedCoolantNeeded()
if hc_needed == ppm.ACCESS_FAULT then
-- lost the peripheral or terminated, handled later
log._error("ISS: failed to check reactor heated coolant level")
return false
else
return hc_needed == 0
end
end
-- check for excess waste
local excess_waste = function ()
local w_needed = self.reactor.getWasteNeeded()
if w_needed == ppm.ACCESS_FAULT then
-- lost the peripheral or terminated, handled later
log._error("ISS: failed to check reactor waste level")
return false
else
return w_needed == 0
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("ISS: failed to check reactor temperature")
return false
else
return temp >= 1200
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("ISS: failed to check reactor fuel level")
return false
else
return fuel == 0
end
end
-- check if there is no coolant
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("ISS: failed to check reactor coolant level")
return false
else
return coolant_filled < 2
end
end
-- if PLC timed out
local timed_out = function ()
return self.timed_out
end
-- check all safety conditions
local check = function ()
local status = "ok"
local was_tripped = self.tripped
-- check system states in order of severity
if damage_critical() then
log._warning("ISS: damage critical!")
status = "dmg_crit"
elseif high_temp() then
log._warning("ISS: high temperature!")
status = "high_temp"
elseif excess_heated_coolant() then
log._warning("ISS: heated coolant backup!")
status = "heated_coolant_backup"
elseif excess_waste() then
log._warning("ISS: full waste!")
status = "full_waste"
elseif insufficient_fuel() then
log._warning("ISS: no fuel!")
status = "no_fuel"
elseif self.tripped then
status = self.trip_cause
else
self.tripped = false
end
-- if a new trip occured...
if status ~= "ok" then
log._warning("ISS: reactor SCRAM")
self.tripped = true
self.trip_cause = status
if self.reactor.scram() == ppm.ACCESS_FAULT then
log._error("ISS: failed reactor SCRAM")
end
end
local first_trip = not was_tripped and self.tripped
return self.tripped, status, first_trip
end
-- report a PLC comms timeout
local trip_timeout = function ()
self.tripped = false
self.trip_cause = "timeout"
self.timed_out = true
self.reactor.scram()
end
-- reset the ISS
local reset = function ()
self.timed_out = false
self.tripped = false
self.trip_cause = ""
end
-- get the ISS status
local status = function (named)
if named then
return {
damage_critical = damage_critical(),
excess_heated_coolant = excess_heated_coolant(),
excess_waste = excess_waste(),
high_temp = high_temp(),
insufficient_fuel = insufficient_fuel(),
no_coolant = no_coolant(),
timed_out = timed_out()
}
else
return {
damage_critical(),
excess_heated_coolant(),
excess_waste(),
high_temp(),
insufficient_fuel(),
no_coolant(),
timed_out()
}
end
end
return {
reconnect_reactor = reconnect_reactor,
check = check,
trip_timeout = trip_timeout,
reset = reset,
status = status,
damage_critical = damage_critical,
excess_heated_coolant = excess_heated_coolant,
excess_waste = excess_waste,
high_temp = high_temp,
insufficient_fuel = insufficient_fuel,
no_coolant = no_coolant,
timed_out = timed_out
}
end
function rplc_packet()
local self = {
frame = nil,
id = nil,
type = nil,
length = nil,
body = nil
}
local _rplc_type_valid = function ()
return self.type == RPLC_TYPES.KEEP_ALIVE or
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_SCRAM or
self.type == RPLC_TYPES.MEK_ENABLE or
self.type == RPLC_TYPES.MEK_BURN_RATE or
self.type == RPLC_TYPES.ISS_ALARM or
self.type == RPLC_TYPES.ISS_GET or
self.type == RPLC_TYPES.ISS_CLEAR
end
-- make an RPLC packet
local make = function (id, packet_type, length, data)
self.id = id
self.type = packet_type
self.length = length
self.data = data
end
-- decode an RPLC packet from a SCADA frame
local decode = function (frame)
if frame then
self.frame = frame
if frame.protocol() == comms.PROTOCOLS.RPLC then
local data = frame.data()
local ok = #data > 2
if ok then
make(data[1], data[2], data[3], { table.unpack(data, 4, #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
local get = function ()
return {
scada_frame = self.frame,
id = self.id,
type = self.type,
length = self.length,
data = self.data
}
end
return {
make = make,
decode = decode,
get = get
}
end
-- reactor PLC communications
function comms_init(id, modem, local_port, server_port, reactor, iss)
local self = {
id = id,
seq_num = 0,
modem = modem,
s_port = server_port,
l_port = local_port,
reactor = reactor,
iss = iss,
status_cache = nil,
scrammed = false,
linked = false
}
-- open modem
if not self.modem.isOpen(self.l_port) then
self.modem.open(self.l_port)
end
-- PRIVATE FUNCTIONS --
local _send = function (msg)
local packet = scada_packet()
packet.make(self.seq_num, PROTOCOLS.RPLC, msg)
self.modem.transmit(self.s_port, self.l_port, packet.raw())
self.seq_num = self.seq_num + 1
end
-- variable reactor status information, excluding heating rate
local _reactor_status = function ()
ppm.clear_fault()
return {
status = self.reactor.getStatus(),
burn_rate = self.reactor.getBurnRate(),
act_burn_r = self.reactor.getActualBurnRate(),
temp = self.reactor.getTemperature(),
damage = self.reactor.getDamagePercent(),
boil_eff = self.reactor.getBoilEfficiency(),
env_loss = self.reactor.getEnvironmentalLoss(),
fuel = self.reactor.getFuel(),
fuel_need = self.reactor.getFuelNeeded(),
fuel_fill = self.reactor.getFuelFilledPercentage(),
waste = self.reactor.getWaste(),
waste_need = self.reactor.getWasteNeeded(),
waste_fill = self.reactor.getWasteFilledPercentage(),
cool_type = self.reactor.getCoolant()['name'],
cool_amnt = self.reactor.getCoolant()['amount'],
cool_need = self.reactor.getCoolantNeeded(),
cool_fill = self.reactor.getCoolantFilledPercentage(),
hcool_type = self.reactor.getHeatedCoolant()['name'],
hcool_amnt = self.reactor.getHeatedCoolant()['amount'],
hcool_need = self.reactor.getHeatedCoolantNeeded(),
hcool_fill = self.reactor.getHeatedCoolantFilledPercentage()
}, ppm.faulted()
end
local _update_status_cache = function ()
local status, faulted = _reactor_status()
local changed = false
if not faulted then
for key, value in pairs(status) do
if value ~= self.status_cache[key] then
changed = true
break
end
end
end
if changed then
self.status_cache = status
end
return changed
end
-- keep alive ack
local _send_keep_alive_ack = function ()
local keep_alive_data = {
id = self.id,
timestamp = os.time(),
type = RPLC_TYPES.KEEP_ALIVE
}
_send(keep_alive_data)
end
-- general ack
local _send_ack = function (type, succeeded)
local ack_data = {
id = self.id,
type = type,
ack = succeeded
}
_send(ack_data)
end
-- send structure properties (these should not change)
-- (server will cache these)
local _send_struct = function ()
ppm.clear_fault()
local mek_data = {
heat_cap = self.reactor.getHeatCapacity(),
fuel_asm = self.reactor.getFuelAssemblies(),
fuel_sa = self.reactor.getFuelSurfaceArea(),
fuel_cap = self.reactor.getFuelCapacity(),
waste_cap = self.reactor.getWasteCapacity(),
cool_cap = self.reactor.getCoolantCapacity(),
hcool_cap = self.reactor.getHeatedCoolantCapacity(),
max_burn = self.reactor.getMaxBurnRate()
}
if not faulted then
local struct_packet = {
id = self.id,
type = RPLC_TYPES.MEK_STRUCT,
mek_data = mek_data
}
_send(struct_packet)
else
log._error("failed to send structure: PPM fault")
end
end
local _send_iss_status = function ()
local iss_status = {
id = self.id,
type = RPLC_TYPES.ISS_GET,
status = iss.status()
}
_send(iss_status)
end
-- PUBLIC FUNCTIONS --
-- reconnect a newly connected modem
local 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
local reconnect_reactor = function (reactor)
self.reactor = reactor
_update_status_cache()
end
-- parse an RPLC packet
local parse_packet = function(side, sender, reply_to, message, distance)
local pkt = nil
local s_pkt = scada_packet()
-- parse packet as generic SCADA packet
s_pkt.recieve(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 = 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 = mgmt_packet()
if mgmt_pkt.decode(s_pkt) then
pkt = mgmt_packet.get()
end
else
log._error("illegal packet type " .. s_pkt.protocol(), true)
end
end
return pkt
end
-- handle an RPLC packet
local handle_packet = function (packet, plc_state)
if packet ~= nil then
if packet.scada_frame.protocol() == PROTOCOLS.RPLC then
if self.linked then
if packet.type == RPLC_TYPES.KEEP_ALIVE then
-- keep alive request received, echo back
local timestamp = packet.data[1]
local trip_time = os.time() - ts
if trip_time < 0 then
log._warning("PLC KEEP_ALIVE trip time less than 0 (" .. trip_time .. ")")
elseif trip_time > 1 then
log._warning("PLC KEEP_ALIVE trip time > 1s (" .. trip_time .. ")")
end
_send_keep_alive_ack()
elseif packet.type == RPLC_TYPES.LINK_REQ then
-- link request confirmation
log._debug("received unsolicited link request response")
local link_ack = packet.data[1]
if link_ack == RPLC_LINKING.ALLOW then
_send_struct()
send_status()
log._debug("re-sent initial status data")
elseif link_ack == RPLC_LINKING.DENY then
-- @todo: make sure this doesn't become an MITM security risk
print_ts("received unsolicited link denial, unlinking\n")
log._debug("unsolicited rplc link request denied")
elseif link_ack == RPLC_LINKING.COLLISION then
-- @todo: make sure this doesn't become an MITM security risk
print_ts("received unsolicited link collision, unlinking\n")
log._warning("unsolicited rplc link request collision")
else
print_ts("invalid unsolicited link response\n")
log._error("unsolicited unknown rplc link request response")
end
self.linked = link_ack == RPLC_LINKING.ALLOW
elseif packet.type == RPLC_TYPES.MEK_STRUCT then
-- request for physical structure
_send_struct()
elseif packet.type == RPLC_TYPES.MEK_SCRAM then
-- disable the reactor
self.scrammed = true
plc_state.scram = true
_send_ack(packet.type, self.reactor.scram() == ppm.ACCESS_OK)
elseif packet.type == RPLC_TYPES.MEK_ENABLE then
-- enable the reactor
self.scrammed = false
plc_state.scram = false
_send_ack(packet.type, self.reactor.activate() == ppm.ACCESS_OK)
elseif packet.type == RPLC_TYPES.MEK_BURN_RATE then
-- set the burn rate
local burn_rate = packet.data[1]
local max_burn_rate = self.reactor.getMaxBurnRate()
local success = false
if max_burn_rate ~= ppm.ACCESS_FAULT then
if burn_rate > 0 and burn_rate <= max_burn_rate then
success = self.reactor.setBurnRate(burn_rate)
end
end
_send_ack(packet.type, success == ppm.ACCESS_OK)
elseif packet.type == RPLC_TYPES.ISS_GET then
-- get the ISS status
_send_iss_status(iss.status())
elseif packet.type == RPLC_TYPES.ISS_CLEAR then
-- clear the ISS status
iss.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
local link_ack = packet.data[1]
if link_ack == RPLC_LINKING.ALLOW then
print_ts("...linked!\n")
log._debug("rplc link request approved")
_send_struct()
send_status()
log._debug("sent initial status data")
elseif link_ack == RPLC_LINKING.DENY then
print_ts("...denied, retrying...\n")
log._debug("rplc link request denied")
elseif link_ack == RPLC_LINKING.COLLISION then
print_ts("reactor PLC ID collision (check config), retrying...\n")
log._warning("rplc link request collision")
else
print_ts("invalid link response, bad channel? retrying...\n")
log._error("unknown rplc link request response")
end
self.linked = link_ack == RPLC_LINKING.ALLOW
else
log._debug("discarding non-link packet before linked")
end
elseif packet.scada_frame.protocol() == PROTOCOLS.SCADA_MGMT then
-- todo
end
end
end
-- attempt to establish link with supervisor
local send_link_req = function ()
local linking_data = {
id = self.id,
type = RPLC_TYPES.LINK_REQ
}
_send(linking_data)
end
-- send live status information
-- overridden : if ISS force disabled reactor
-- degraded : if PLC status is degraded
local send_status = function (overridden, degraded)
local mek_data = nil
if _update_status_cache() then
mek_data = self.status_cache
end
local sys_status = {
id = self.id,
type = RPLC_TYPES.STATUS,
timestamp = os.time(),
control_state = not self.scrammed,
overridden = overridden,
degraded = degraded,
heating_rate = self.reactor.getHeatingRate(),
mek_data = mek_data
}
_send(sys_status)
end
local send_iss_alarm = function (cause)
local iss_alarm = {
id = self.id,
type = RPLC_TYPES.ISS_ALARM,
cause = cause,
status = iss.status()
}
_send(iss_alarm)
end
local is_scrammed = function () return self.scrammed end
local is_linked = function () return self.linked end
local unlink = function () self.linked = false end
return {
reconnect_modem = reconnect_modem,
reconnect_reactor = reconnect_reactor,
parse_packet = parse_packet,
handle_packet = handle_packet,
send_link_req = send_link_req,
send_status = send_status,
send_iss_alarm = send_iss_alarm,
is_scrammed = is_scrammed,
is_linked = is_linked,
unlink = unlink
}
end

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

@ -0,0 +1,297 @@
--
-- Reactor Programmable Logic Controller
--
os.loadAPI("scada-common/log.lua")
os.loadAPI("scada-common/util.lua")
os.loadAPI("scada-common/ppm.lua")
os.loadAPI("scada-common/comms.lua")
os.loadAPI("config.lua")
os.loadAPI("plc.lua")
local R_PLC_VERSION = "alpha-v0.1.6"
local print = util.print
local println = util.println
local print_ts = util.print_ts
local println_ts = util.println_ts
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()
local reactor = ppm.get_fission_reactor()
local modem = ppm.get_wireless_modem()
local networked = config.NETWORKED
local plc_state = {
init_ok = true,
scram = true,
degraded = false,
no_reactor = false,
no_modem = false
}
-- we need a reactor and a modem
if 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 networked and modem == nil then
println("boot> wireless modem not found")
log._warning("no wireless modem on startup")
if reactor ~= nil then
reactor.scram()
end
plc_state.init_ok = false
plc_state.degraded = true
plc_state.no_modem = true
end
local iss = nil
local plc_comms = nil
local conn_watchdog = nil
-- send status updates at ~3.33Hz (every 6 server ticks) (every 3 loop ticks)
-- send link requests at 0.5Hz (every 40 server ticks) (every 20 loop ticks)
local UPDATE_TICKS = 3
local LINK_TICKS = 20
local loop_clock = nil
local ticks_to_update = LINK_TICKS -- start by linking
function init()
if plc_state.init_ok then
-- just booting up, no fission allowed (neutrons stay put thanks)
reactor.scram()
-- init internal safety system
iss = plc.iss_init(reactor)
log._debug("iss init")
if networked then
-- start comms
plc_comms = plc.comms_init(config.REACTOR_ID, modem, config.LISTEN_PORT, config.SERVER_PORT, reactor, iss)
log._debug("comms init")
-- comms watchdog, 3 second timeout
conn_watchdog = util.new_watchdog(3)
log._debug("conn watchdog started")
else
log._debug("running without networking")
end
-- loop clock (10Hz, 2 ticks)
loop_clock = os.startTimer(0.05)
log._debug("loop clock started")
println("boot> completed");
else
println("boot> system in degraded state, awaiting devices...")
log._warning("booted in a degraded state, awaiting peripheral connections...")
end
end
-- initialize PLC
init()
-- event loop
while true do
local event, param1, param2, param3, param4, param5 = os.pullEventRaw()
if plc_state.init_ok then
-- if we tried to SCRAM but failed, keep trying
-- if it disconnected, isPowered will return nil (and error logs will get spammed at 10Hz, so disable reporting)
-- in that case, SCRAM won't be called until it reconnects (this is the expected use of this check)
ppm.disable_reporting()
if plc_state.scram and reactor.getStatus() then
reactor.scram()
end
ppm.enable_reporting()
end
-- check for peripheral changes before ISS checks
if event == "peripheral_detach" then
local device = ppm.handle_unmount(param1)
if device.type == "fissionReactor" then
println_ts("reactor disconnected!")
log._error("reactor disconnected!")
plc_state.no_reactor = true
plc_state.degraded = true
-- send an alarm: plc_comms.send_alarm(ALARMS.PLC_PERI_DC) ?
elseif networked and device.type == "modem" then
-- we only care if this is our wireless modem
if device.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
plc_state.scram = true
if reactor.scram() then
println_ts("successful reactor SCRAM")
log._error("successful reactor SCRAM")
else
println_ts("failed reactor SCRAM")
log._error("failed reactor SCRAM")
end
end
plc_state.degraded = true
else
log._warning("non-comms modem disconnected")
end
end
elseif event == "peripheral" then
local type, device = ppm.mount(param1)
if type == "fissionReactor" then
-- reconnected reactor
reactor = device
plc_state.scram = true
reactor.scram()
println_ts("reactor reconnected.")
log._info("reactor reconnected.")
plc_state.no_reactor = false
if plc_state.init_ok then
iss.reconnect_reactor(reactor)
if networked then
plc_comms.reconnect_reactor(reactor)
end
end
-- determine if we are still in a degraded state
if not networked or ppm.get_device("modem") ~= nil then
plc_state.degraded = false
end
elseif networked and type == "modem" then
if device.isWireless() then
-- reconnected modem
modem = device
if plc_state.init_ok then
plc_comms.reconnect_modem(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 ppm.get_device("fissionReactor") ~= nil then
plc_state.degraded = false
end
else
log._info("wired modem reconnected.")
end
end
if not plc_state.init_ok and not plc_state.degraded then
plc_state.init_ok = true
init()
end
end
-- ISS
if plc_state.init_ok then
-- if we are in standalone mode, continuously reset ISS
-- ISS will trip again if there are faults, but if it isn't cleared, the user can't re-enable
if not networked then
plc_state.scram = false
iss.reset()
end
-- check safety (SCRAM occurs if tripped)
if not plc_state.degraded then
local iss_tripped, iss_status, iss_first = iss.check()
plc_state.scram = plc_state.scram or iss_tripped
if iss_first then
println_ts("[ISS] reactor shutdown, safety tripped: " .. iss_status)
if networked then
plc_comms.send_iss_alarm(iss_status)
end
end
else
reactor.scram()
end
end
-- handle event
if event == "timer" and param1 == loop_clock then
-- basic event tick, send updated data if it is time (~3.33Hz)
-- iss was already checked (that's the main reason for this tick rate)
if networked and not plc_state.no_modem then
ticks_to_update = ticks_to_update - 1
if plc_comms.is_linked() then
if ticks_to_update <= 0 then
plc_comms.send_status(iss_tripped, plc_state.degraded)
ticks_to_update = UPDATE_TICKS
end
else
if ticks_to_update <= 0 then
plc_comms.send_link_req()
ticks_to_update = LINK_TICKS
end
end
end
-- start next clock timer
loop_clock = os.startTimer(0.05)
elseif event == "modem_message" and networked and not plc_state.no_modem then
-- got a packet
-- feed the watchdog first so it doesn't uhh...eat our packets
conn_watchdog.feed()
-- handle the packet (plc_state passed to allow clearing SCRAM flag)
local packet = plc_comms.parse_packet(p1, p2, p3, p4, p5)
plc_comms.handle_packet(packet, plc_state)
elseif event == "timer" and networked and param1 == conn_watchdog.get_timer() then
-- haven't heard from server recently? shutdown reactor
plc_state.scram = true
plc_comms.unlink()
iss.trip_timeout()
println_ts("server timeout, reactor disabled")
log._warning("server timeout, reactor disabled")
end
-- check for termination request
if event == "terminate" or ppm.should_terminate() then
log._warning("terminate requested, exiting...")
-- safe exit
if plc_state.init_ok then
plc_state.scram = true
if reactor.scram() ~= ppm.ACCESS_FAULT then
println_ts("reactor disabled")
else
-- send an alarm: plc_comms.send_alarm(ALARMS.PLC_LOST_CONTROL) ?
println_ts("exiting, reactor failed to disable")
end
end
break
end
end
-- send an alarm: plc_comms.send_alarm(ALARMS.PLC_SHUTDOWN) ?
println_ts("exited")
log._info("exited")

45
rtu/config.lua Normal file
View File

@ -0,0 +1,45 @@
-- #REQUIRES rsio.lua
-- port to send packets TO server
SERVER_PORT = 16000
-- port to listen to incoming packets FROM server
LISTEN_PORT = 15001
-- RTU peripheral devices (named: side/network device name)
RTU_DEVICES = {
{
name = "boiler_0",
index = 1,
for_reactor = 1
},
{
name = "turbine_0",
index = 1,
for_reactor = 1
}
}
-- RTU redstone interface definitions
RTU_REDSTONE = {
{
for_reactor = 1,
io = {
{
channel = rsio.RS_IO.WASTE_PO,
side = "top",
bundled_color = colors.blue,
for_reactor = 1
},
{
channel = rsio.RS_IO.WASTE_PU,
side = "top",
bundled_color = colors.cyan,
for_reactor = 1
},
{
channel = rsio.RS_IO.WASTE_AM,
side = "top",
bundled_color = colors.purple,
for_reactor = 1
}
}
}
}

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

@ -0,0 +1,51 @@
-- #REQUIRES rtu.lua
function new(boiler)
local self = {
rtu = rtu.rtu_init(),
boiler = boiler
}
local rtu_interface = function ()
return self.rtu
end
-- 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 {
rtu_interface = rtu_interface
}
end

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

@ -0,0 +1,33 @@
-- #REQUIRES rtu.lua
function new(imatrix)
local self = {
rtu = rtu.rtu_init(),
imatrix = imatrix
}
local rtu_interface = function ()
return self.rtu
end
-- discrete inputs --
-- none
-- coils --
-- none
-- input registers --
-- build properties
self.rtu.connect_input_reg(self.imatrix.getTotalMaxEnergy)
-- containers
self.rtu.connect_input_reg(self.imatrix.getTotalEnergy)
self.rtu.connect_input_reg(self.imatrix.getTotalEnergyNeeded)
self.rtu.connect_input_reg(self.imatrix.getTotalEnergyFilledPercentage)
-- holding registers --
-- none
return {
rtu_interface = rtu_interface
}
end

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

@ -0,0 +1,93 @@
-- #REQUIRES rtu.lua
-- #REQUIRES rsio.lua
-- note: this RTU makes extensive use of the programming concept of closures
local digital_read = rsio.digital_read
local digital_is_active = rsio.digital_is_active
function new()
local self = {
rtu = rtu.rtu_init()
}
local rtu_interface = function ()
return self.rtu
end
local link_di = function (channel, 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
local 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)
local active = digital_is_active(channel, level)
if active then
colors.combine(output, color)
else
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
local link_ai = function (channel, side)
self.rtu.connect_input_reg(
function ()
return rs.getAnalogInput(side)
end
)
end
local link_ao = function (channel, side)
self.rtu.connect_holding_reg(
function ()
return rs.getAnalogOutput(side)
end,
function (value)
rs.setAnalogOutput(side, value)
end
)
end
return {
rtu_interface = rtu_interface,
link_di = link_di,
link_do = link_do,
link_ai = link_ai,
link_ao = link_ao
}
end

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

@ -0,0 +1,46 @@
-- #REQUIRES rtu.lua
function new(turbine)
local self = {
rtu = rtu.rtu_init(),
turbine = turbine
}
local rtu_interface = function ()
return self.rtu
end
-- 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.getDumpingMode)
self.rtu.connect_input_reg(self.turbine.getSteamCapacity)
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
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 {
rtu_interface = rtu_interface
}
end

269
rtu/rtu.lua Normal file
View File

@ -0,0 +1,269 @@
-- #REQUIRES comms.lua
-- #REQUIRES modbus.lua
-- #REQUIRES ppm.lua
local PROTOCOLS = comms.PROTOCOLS
local SCADA_MGMT_TYPES = comms.SCADA_MGMT_TYPES
local RTU_ADVERT_TYPES = comms.RTU_ADVERT_TYPES
function rtu_init()
local self = {
discrete_inputs = {},
coils = {},
input_regs = {},
holding_regs = {},
io_count_cache = { 0, 0, 0, 0 }
}
local _count_io = function ()
self.io_count_cache = { #self.discrete_inputs, #self.coils, #self.input_regs, #self.holding_regs }
end
-- return : IO count table
local io_count = function ()
return self.io_count_cache[0], self.io_count_cache[1], self.io_count_cache[2], self.io_count_cache[3]
end
-- discrete inputs: single bit read-only
-- return : count of discrete inputs
local connect_di = function (f)
table.insert(self.discrete_inputs, f)
_count_io()
return #self.discrete_inputs
end
-- return : value, access fault
local read_di = function (di_addr)
ppm.clear_fault()
local value = self.discrete_inputs[di_addr]()
return value, ppm.is_faulted()
end
-- coils: single bit read-write
-- return : count of coils
local connect_coil = function (f_read, f_write)
table.insert(self.coils, { read = f_read, write = f_write })
_count_io()
return #self.coils
end
-- return : value, access fault
local read_coil = function (coil_addr)
ppm.clear_fault()
local value = self.coils[coil_addr].read()
return value, ppm.is_faulted()
end
-- return : access fault
local 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
-- return : count of input registers
local connect_input_reg = function (f)
table.insert(self.input_regs, f)
_count_io()
return #self.input_regs
end
-- return : value, access fault
local read_input_reg = function (reg_addr)
ppm.clear_fault()
local value = self.coils[reg_addr]()
return value, ppm.is_faulted()
end
-- holding registers: multi-bit read-write
-- return : count of holding registers
local connect_holding_reg = function (f_read, f_write)
table.insert(self.holding_regs, { read = f_read, write = f_write })
_count_io()
return #self.holding_regs
end
-- return : value, access fault
local read_holding_reg = function (reg_addr)
ppm.clear_fault()
local value = self.coils[reg_addr].read()
return value, ppm.is_faulted()
end
-- return : access fault
local write_holding_reg = function (reg_addr, value)
ppm.clear_fault()
self.coils[reg_addr].write(value)
return ppm.is_faulted()
end
return {
io_count = io_count,
connect_di = connect_di,
read_di = read_di,
connect_coil = connect_coil,
read_coil = read_coil,
write_coil = write_coil,
connect_input_reg = connect_input_reg,
read_input_reg = read_input_reg,
connect_holding_reg = connect_holding_reg,
read_holding_reg = read_holding_reg,
write_holding_reg = write_holding_reg
}
end
function rtu_comms(modem, local_port, server_port)
local self = {
seq_num = 0,
txn_id = 0,
modem = modem,
s_port = server_port,
l_port = local_port
}
-- open modem
if not self.modem.isOpen(self.l_port) then
self.modem.open(self.l_port)
end
-- PRIVATE FUNCTIONS --
local _send = function (protocol, msg)
local packet = comms.scada_packet()
packet.make(self.seq_num, protocol, msg)
self.modem.transmit(self.s_port, self.l_port, packet.raw())
self.seq_num = self.seq_num + 1
end
-- PUBLIC FUNCTIONS --
-- parse a MODBUS/SCADA packet
local 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.recieve(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 = 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_packet.get()
end
else
log._error("illegal packet type " .. s_pkt.protocol(), true)
end
end
return pkt
end
local handle_packet = function(packet, units, ref)
if packet ~= nil then
local protocol = packet.scada_frame.protocol()
if protocol == PROTOCOLS.MODBUS_TCP then
-- MODBUS instruction
if packet.unit_id <= #units then
local unit = units[packet.unit_id]
local return_code, response = unit.modbus_io.handle_packet(packet)
_send(PROTOCOLS.MODBUS_TCP, response)
if not return_code then
log._warning("MODBUS operation failed")
end
else
-- unit ID out of range?
log._error("MODBUS packet requesting non-existent unit")
end
elseif protocol == PROTOCOLS.SCADA_MGMT then
-- SCADA management packet
if packet.type == SCADA_MGMT_TYPES.REMOTE_LINKED then
-- acknowledgement
ref.linked = true
elseif packet.type == SCADA_MGMT_TYPES.RTU_ADVERT then
-- request for capabilities again
send_advertisement(units)
else
-- not supported
log._warning("RTU got unexpected SCADA message type " .. packet.type, true)
end
else
-- should be unreachable assuming packet is from parse_packet()
log._error("illegal packet type " .. protocol, true)
end
end
end
-- send capability advertisement
local send_advertisement = function (units)
local advertisement = {
type = SCADA_MGMT_TYPES.RTU_ADVERT,
units = {}
}
for i = 1, #units do
local type = nil
if units[i].type == "boiler" then
type = RTU_ADVERT_TYPES.BOILER
elseif units[i].type == "turbine" then
type = RTU_ADVERT_TYPES.TURBINE
elseif units[i].type == "imatrix" then
type = RTU_ADVERT_TYPES.IMATRIX
elseif units[i].type == "redstone" then
type = RTU_ADVERT_TYPES.REDSTONE
end
if type ~= nil then
if type == RTU_ADVERT_TYPES.REDSTONE then
table.insert(advertisement.units, {
unit = i,
type = type,
index = units[i].index,
reactor = units[i].for_reactor,
rsio = units[i].device
})
else
table.insert(advertisement.units, {
unit = i,
type = type,
index = units[i].index,
reactor = units[i].for_reactor,
rsio = nil
})
end
end
end
_send(PROTOCOLS.SCADA_MGMT, advertisement)
end
local send_heartbeat = function ()
local heartbeat = {
type = SCADA_MGMT_TYPES.RTU_HEARTBEAT
}
_send(PROTOCOLS.SCADA_MGMT, heartbeat)
end
return {
parse_packet = parse_packet,
handle_packet = handle_packet,
send_advertisement = send_advertisement,
send_heartbeat = send_heartbeat
}
end

250
rtu/startup.lua Normal file
View File

@ -0,0 +1,250 @@
--
-- RTU: Remote Terminal Unit
--
os.loadAPI("scada-common/log.lua")
os.loadAPI("scada-common/util.lua")
os.loadAPI("scada-common/ppm.lua")
os.loadAPI("scada-common/comms.lua")
os.loadAPI("scada-common/modbus.lua")
os.loadAPI("scada-common/rsio.lua")
os.loadAPI("config.lua")
os.loadAPI("rtu.lua")
os.loadAPI("dev/redstone_rtu.lua")
os.loadAPI("dev/boiler_rtu.lua")
os.loadAPI("dev/imatrix_rtu.lua")
os.loadAPI("dev/turbine_rtu.lua")
local RTU_VERSION = "alpha-v0.1.5"
local print = util.print
local println = util.println
local print_ts = util.print_ts
local println_ts = util.println_ts
log._info("========================================")
log._info("BOOTING rtu.startup " .. RTU_VERSION)
log._info("========================================")
println(">> RTU " .. RTU_VERSION .. " <<")
----------------------------------------
-- startup
----------------------------------------
local units = {}
local linked = false
-- mount connected devices
ppm.mount_all()
-- get modem
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
local rtu_comms = rtu.rtu_comms(modem, config.LISTEN_PORT, config.SERVER_PORT)
----------------------------------------
-- determine configuration
----------------------------------------
local rtu_redstone = config.RTU_REDSTONE
local rtu_devices = config.RTU_DEVICES
-- redstone interfaces
for reactor_idx = 1, #rtu_redstone do
local rs_rtu = redstone_rtu.new()
local io_table = rtu_redstone[reactor_idx].io
local capabilities = {}
log._debug("init> starting redstone RTU I/O linking for reactor " .. rtu_redstone[reactor_idx].for_reactor .. "...")
for i = 1, #io_table do
local valid = false
local config = io_table[i]
-- verify configuration
if rsio.is_valid_channel(config.channel) and rsio.is_valid_side(config.side) then
if config.bundled_color then
valid = rsio.is_color(config.bundled_color)
else
valid = true
end
end
if not valid then
local message = "init> invalid redstone definition at index " .. i .. " in definition block #" .. reactor_idx ..
" (for reactor " .. rtu_redstone[reactor_idx].for_reactor .. ")"
println_ts(message)
log._warning(message)
else
-- link redstone in RTU
local mode = rsio.get_io_mode(config.channel)
if mode == rsio.IO_MODE.DIGITAL_IN then
rs_rtu.link_di(config.channel, config.side, config.bundled_color)
elseif mode == rsio.IO_MODE.DIGITAL_OUT then
rs_rtu.link_do(config.channel, config.side, config.bundled_color)
elseif mode == rsio.IO_MODE.ANALOG_IN then
rs_rtu.link_ai(config.channel, config.side)
elseif mode == rsio.IO_MODE.ANALOG_OUT then
rs_rtu.link_ao(config.channel, config.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, config.channel)
log._debug("init> linked redstone " .. #capabilities .. ": " .. rsio.to_string(config.channel) .. " (" .. config.side ..
") for reactor " .. rtu_redstone[reactor_idx].for_reactor)
end
end
table.insert(units, {
name = "redstone_io",
type = "redstone",
index = 1,
reactor = rtu_redstone[reactor_idx].for_reactor,
device = capabilities, -- use device field for redstone channels
rtu = rs_rtu,
modbus_io = modbus.new(rs_rtu)
})
log._debug("init> initialized RTU unit #" .. #units .. ": redstone_io (redstone) [1] for reactor " .. rtu_redstone[reactor_idx].for_reactor)
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
local rtu_type = ""
if type == "boiler" then
-- boiler multiblock
rtu_type = "boiler"
rtu_iface = boiler_rtu.new(device)
elseif type == "turbine" then
-- turbine multiblock
rtu_type = "turbine"
rtu_iface = turbine_rtu.new(device)
elseif type == "mekanismMachine" then
-- assumed to be an induction matrix multiblock
rtu_type = "imatrix"
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
table.insert(units, {
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)
})
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
----------------------------------------
-- main loop
----------------------------------------
-- advertisement/heartbeat clock (every 2 seconds)
local loop_clock = os.startTimer(2)
-- event loop
while true do
local event, param1, param2, param3, param4, param5 = os.pullEventRaw()
if event == "peripheral_detach" then
-- handle loss of a device
local device = ppm.handle_unmount(param1)
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
elseif event == "peripheral" then
-- relink lost peripheral to correct unit entry
local type, device = ppm.mount(param1)
for i = 1, #units do
local unit = units[i]
-- find disconnected device to reconnect
if unit.name == param1 then
-- found, re-link
unit.device = device
if unit.type == "boiler" then
unit.rtu = boiler_rtu.new(device)
elseif unit.type == "turbine" then
unit.rtu = turbine_rtu.new(device)
elseif unit.type == "imatrix" then
unit.rtu = imatrix_rtu.new(device)
end
unit.modbus_io = modbus.new(unit.rtu)
println_ts("reconnected the " .. unit.type .. " on interface " .. unit.name)
end
end
elseif event == "timer" and param1 == loop_clock then
-- period tick, if we are linked send heartbeat, if not send advertisement
if linked then
rtu_comms.send_heartbeat()
else
-- advertise units
rtu_comms.send_advertisement(units)
end
-- start next clock timer
loop_clock = os.startTimer(2)
elseif event == "modem_message" then
-- got a packet
local link_ref = { linked = linked }
local packet = rtu_comms.parse_packet(p1, p2, p3, p4, p5)
rtu_comms.handle_packet(packet, units, link_ref)
-- if linked, stop sending advertisements
linked = link_ref.linked
end
-- check for termination request
if event == "terminate" or ppm.should_terminate() then
log._warning("terminate requested, exiting...")
break
end
end
println_ts("exited")
log._info("exited")

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

@ -0,0 +1,54 @@
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
}
function scada_alarm(severity, device, message)
local self = {
time = os.time(),
ts_string = os.date("[%H:%M:%S]"),
severity = severity,
device = device,
message = message
}
local format = function ()
return self.ts_string .. " [" .. severity_to_string(self.severity) .. "] (" .. self.device ") >> " .. self.message
end
local properties = function ()
return {
time = self.time,
severity = self.severity,
device = self.device,
message = self.message
}
end
return {
format = format,
properties = properties
}
end
function severity_to_string(severity)
if severity == SEVERITY.INFO then
return "INFO"
elseif severity == SEVERITY.WARNING then
return "WARNING"
elseif severity == SEVERITY.ALERT then
return "ALERT"
elseif severity == SEVERITY.FACILITY then
return "FACILITY"
elseif severity == SEVERITY.SAFETY then
return "SAFETY"
elseif severity == SEVERITY.EMERGENCY then
return "EMERGENCY"
else
return "UNKNOWN"
end
end

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

@ -0,0 +1,180 @@
PROTOCOLS = {
MODBUS_TCP = 0, -- our "MODBUS TCP"-esque protocol
RPLC = 1, -- reactor PLC protocol
SCADA_MGMT = 2, -- SCADA supervisor intercommunication, device advertisements, etc
COORD_DATA = 3 -- data packets for coordinators to/from supervisory controller
}
SCADA_SV_MODES = {
ACTIVE = 0, -- supervisor running as primary
BACKUP = 1 -- supervisor running as hot backup
}
RPLC_TYPES = {
KEEP_ALIVE = 0, -- keep alive packets
LINK_REQ = 1, -- linking requests
STATUS = 2, -- reactor/system status
MEK_STRUCT = 3, -- mekanism build structure
MEK_SCRAM = 4, -- SCRAM reactor
MEK_ENABLE = 5, -- enable reactor
MEK_BURN_RATE = 6, -- set burn rate
ISS_ALARM = 7, -- ISS alarm broadcast
ISS_GET = 8, -- get ISS status
ISS_CLEAR = 9 -- clear ISS trip (if in bad state, will trip immediately)
}
RPLC_LINKING = {
ALLOW = 0, -- link approved
DENY = 1, -- link denied
COLLISION = 2 -- link denied due to existing active link
}
SCADA_MGMT_TYPES = {
PING = 0, -- generic ping
SV_HEARTBEAT = 1, -- supervisor heartbeat
REMOTE_LINKED = 2, -- remote device linked
RTU_ADVERT = 3, -- RTU capability advertisement
RTU_HEARTBEAT = 4, -- RTU heartbeat
}
RTU_ADVERT_TYPES = {
BOILER = 0, -- boiler
TURBINE = 1, -- turbine
IMATRIX = 2, -- induction matrix
REDSTONE = 3 -- redstone I/O
}
-- generic SCADA packet object
function scada_packet()
local self = {
modem_msg_in = nil,
valid = false,
seq_num = nil,
protocol = nil,
length = nil,
raw = nil
}
local make = function (seq_num, protocol, payload)
self.valid = true
self.seq_num = seq_num
self.protocol = protocol
self.length = #payload
self.raw = { self.seq_num, self.protocol, self.length, payload }
end
local 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 #self.raw < 3 then
-- malformed
return false
else
self.valid = true
self.seq_num = self.raw[1]
self.protocol = self.raw[2]
self.length = self.raw[3]
end
end
local modem_event = function () return self.modem_msg_in end
local raw = function () return self.raw end
local is_valid = function () return self.valid end
local seq_num = function () return self.seq_num end
local protocol = function () return self.protocol end
local length = function () return self.length end
local data = function ()
local subset = nil
if self.valid then
subset = { table.unpack(self.raw, 4, 3 + self.length) }
end
return subset
end
return {
make = make,
receive = receive,
modem_event = modem_event,
raw = raw,
is_valid = is_valid,
seq_num = seq_num,
protocol = protocol,
length = length,
data = data
}
end
function mgmt_packet()
local self = {
frame = nil,
type = nil,
length = nil,
data = nil
}
local _scada_type_valid = function ()
return self.type == SCADA_MGMT_TYPES.PING or
self.type == SCADA_MGMT_TYPES.SV_HEARTBEAT or
self.type == SCADA_MGMT_TYPES.REMOTE_LINKED or
self.type == SCADA_MGMT_TYPES.RTU_ADVERT or
self.type == SCADA_MGMT_TYPES.RTU_HEARTBEAT
end
-- make a SCADA management packet
local make = function (packet_type, length, data)
self.type = packet_type
self.length = length
self.data = data
end
-- decode a SCADA management packet from a SCADA frame
local decode = function (frame)
if frame then
self.frame = frame
if frame.protocol() == comms.PROTOCOLS.SCADA_MGMT then
local data = frame.data()
local ok = #data > 1
if ok then
make(data[1], data[2], { table.unpack(data, 3, #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
local get = function ()
return {
scada_frame = self.frame,
type = self.type,
length = self.length,
data = self.data
}
end
return {
make = make,
decode = decode,
get = get
}
end

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

@ -0,0 +1,64 @@
--
-- File System Logger
--
-- we use extra short abbreviations since computer craft screens are very small
-- underscores are used since some of these names are used elsewhere (e.g. 'debug' is a lua table)
local LOG_DEBUG = true
local file_handle = fs.open("/log.txt", "a")
local _log = function (msg)
local stamped = os.date("[%c] ") .. msg
file_handle.writeLine(stamped)
file_handle.flush()
end
function _debug(msg, trace)
if LOG_DEBUG then
local dbg_info = ""
if trace then
local name = ""
if debug.getinfo(2).name ~= nil then
name = ":" .. debug.getinfo(2).name .. "():"
end
dbg_info = debug.getinfo(2).short_src .. ":" .. name ..
debug.getinfo(2).currentline .. " > "
end
_log("[DBG] " .. dbg_info .. msg)
end
end
function _info(msg)
_log("[INF] " .. msg)
end
function _warning(msg)
_log("[WRN] " .. msg)
end
function _error(msg, trace)
local dbg_info = ""
if trace then
local name = ""
if debug.getinfo(2).name ~= nil then
name = ":" .. debug.getinfo(2).name .. "():"
end
dbg_info = debug.getinfo(2).short_src .. ":" .. name ..
debug.getinfo(2).currentline .. " > "
end
_log("[ERR] " .. dbg_info .. msg)
end
function _fatal(msg)
_log("[FTL] " .. msg)
end

325
scada-common/modbus.lua Normal file
View File

@ -0,0 +1,325 @@
-- modbus function codes
local 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
local 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
}
-- new modbus comms handler object
function new(rtu_dev)
local self = {
rtu = rtu_dev
}
local _1_read_coils = function (c_addr_start, count)
local readings = {}
local access_fault = false
local _, coils, _, _ = self.rtu.io_count()
local return_ok = (c_addr_start + count) <= coils
if return_ok then
for i = 0, (count - 1) do
readings[i], access_fault = self.rtu.read_coil(c_addr_start + i)
if access_fault then
return_ok = false
readings = MODBUS_EXCODE.SERVER_DEVICE_FAIL
break
end
end
else
readings = MODBUS_EXCODE.ILLEGAL_DATA_ADDR
end
return return_ok, readings
end
local _2_read_discrete_inputs = function (di_addr_start, count)
local readings = {}
local access_fault = false
local discrete_inputs, _, _, _ = self.rtu.io_count()
local return_ok = (di_addr_start + count) <= discrete_inputs
if return_ok then
for i = 0, (count - 1) do
readings[i], access_fault = self.rtu.read_di(di_addr_start + i)
if access_fault then
return_ok = false
readings = MODBUS_EXCODE.SERVER_DEVICE_FAIL
break
end
end
else
readings = MODBUS_EXCODE.ILLEGAL_DATA_ADDR
end
return return_ok, readings
end
local _3_read_multiple_holding_registers = function (hr_addr_start, count)
local readings = {}
local access_fault = false
local _, _, _, hold_regs = self.rtu.io_count()
local return_ok = (hr_addr_start + count) <= hold_regs
if return_ok then
for i = 0, (count - 1) do
readings[i], access_fault = self.rtu.read_holding_reg(hr_addr_start + i)
if access_fault then
return_ok = false
readings = MODBUS_EXCODE.SERVER_DEVICE_FAIL
break
end
end
else
readings = MODBUS_EXCODE.ILLEGAL_DATA_ADDR
end
return return_ok, readings
end
local _4_read_input_registers = function (ir_addr_start, count)
local readings = {}
local access_fault = false
local _, _, input_regs, _ = self.rtu.io_count()
local return_ok = (ir_addr_start + count) <= input_regs
if return_ok then
for i = 0, (count - 1) do
readings[i], access_fault = self.rtu.read_input_reg(ir_addr_start + i)
if access_fault then
return_ok = false
readings = MODBUS_EXCODE.SERVER_DEVICE_FAIL
break
end
end
else
readings = MODBUS_EXCODE.ILLEGAL_DATA_ADDR
end
return return_ok, readings
end
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
readings = MODBUS_EXCODE.SERVER_DEVICE_FAIL
end
else
response = MODBUS_EXCODE.ILLEGAL_DATA_ADDR
end
return return_ok, response
end
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
readings = MODBUS_EXCODE.SERVER_DEVICE_FAIL
end
end
return return_ok
end
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
if return_ok then
for i = 0, (count - 1) do
local access_fault = self.rtu.write_coil(c_addr_start + i, values[i + 1])
if access_fault then
return_ok = false
readings = MODBUS_EXCODE.SERVER_DEVICE_FAIL
break
end
end
end
return return_ok, response
end
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
if return_ok then
for i = 0, (count - 1) do
local access_fault = self.rtu.write_coil(hr_addr_start + i, values[i + 1])
if access_fault then
return_ok = false
readings = MODBUS_EXCODE.SERVER_DEVICE_FAIL
break
end
end
end
return return_ok, response
end
local handle_packet = function (packet)
local return_code = true
local response = nil
local reply = packet
if #packet.data == 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_REGISTERS 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
if return_code then
-- default is to echo back
if type(response) == "table" then
reply.length = #response
reply.data = response
end
else
-- echo back with error flag
reply.func_code = bit.bor(packet.func_code, MODBUS_FCODE.ERROR_FLAG)
if type(response) == "nil" then
reply.length = 0
reply.data = {}
elseif type(response) == "number" then
reply.length = 1
reply.data = { response }
elseif type(response) == "table" then
reply.length = #response
reply.data = response
end
end
return return_code, reply
end
return {
handle_packet = handle_packet
}
end
function packet()
local self = {
frame = nil,
txn_id = txn_id,
protocol = protocol,
length = length,
unit_id = unit_id,
func_code = func_code,
data = data
}
-- make a MODBUS packet
local make = function (txn_id, protocol, length, unit_id, func_code, data)
self.txn_id = txn_id
self.protocol = protocol
self.length = length
self.unit_id = unit_id
self.func_code = func_code
self.data = data
end
-- decode a MODBUS packet from a SCADA frame
local decode = function (frame)
if frame then
self.frame = frame
local data = frame.data()
local size_ok = #data ~= 6
if size_ok then
make(data[1], data[2], data[3], data[4], data[5], data[6])
end
return size_ok and self.protocol == comms.PROTOCOLS.MODBUS_TCP
else
log._debug("nil frame encountered", true)
return false
end
end
-- get this packet
local get = function ()
return {
scada_frame = self.frame,
txn_id = self.txn_id,
protocol = self.protocol,
length = self.length,
unit_id = self.unit_id,
func_code = self.func_code,
data = self.data
}
end
return {
make = make,
decode = decode,
get = get
}
end

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

@ -0,0 +1,236 @@
-- #REQUIRES log.lua
--
-- Protected Peripheral Manager
--
ACCESS_OK = true
ACCESS_FAULT = nil
----------------------------
-- PRIVATE DATA/FUNCTIONS --
----------------------------
local self = {
mounts = {},
auto_cf = false,
faulted = false,
terminate = false,
mute = false
}
-- wrap peripheral calls with lua protected call
-- ex. reason: we don't want a disconnect to crash the program before a SCRAM
local peri_init = function (device)
for key, func in pairs(device) do
device[key] = function (...)
local status, result = pcall(func, ...)
if status then
-- auto fault clear
if self.auto_cf then self.faulted = false end
-- assume nil is only for functions with no return, so return status
if result == nil then
return ACCESS_OK
else
return result
end
else
-- function failed
self.faulted = true
if not mute then
log._error("PPM: protected " .. key .. "() -> " .. result)
end
if result == "Terminated" then
self.terminate = true
end
return ACCESS_FAULT
end
end
end
end
----------------------
-- PUBLIC FUNCTIONS --
----------------------
-- REPORTING --
-- silence error prints
function disable_reporting()
self.mute = true
end
-- allow error prints
function enable_reporting()
self.mute = false
end
-- FAULT MEMORY --
-- enable automatically clearing fault flag
function enable_afc()
self.auto_cf = true
end
-- disable automatically clearing fault flag
function disable_afc()
self.auto_cf = false
end
-- check fault flag
function is_faulted()
return self.faulted
end
-- clear fault flag
function clear_fault()
self.faulted = false
end
-- TERMINATION --
-- if a caught error was a termination request
function should_terminate()
return self.terminate
end
-- MOUNTING --
-- mount all available peripherals (clears mounts first)
function mount_all()
local ifaces = peripheral.getNames()
self.mounts = {}
for i = 1, #ifaces do
local pm_dev = peripheral.wrap(ifaces[i])
peri_init(pm_dev)
self.mounts[ifaces[i]] = {
type = peripheral.getType(ifaces[i]),
dev = pm_dev
}
log._debug("PPM: found a " .. self.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
function mount(iface)
local ifaces = peripheral.getNames()
local pm_dev = nil
local type = nil
for i = 1, #ifaces do
if iface == ifaces[i] then
log._debug("PPM: mount(" .. iface .. ") -> found a " .. peripheral.getType(iface))
type = peripheral.getType(iface)
pm_dev = peripheral.wrap(iface)
peri_init(pm_dev)
self.mounts[iface] = {
type = peripheral.getType(iface),
dev = pm_dev
}
break
end
end
return type, pm_dev
end
-- handle peripheral_detach event
function handle_unmount(iface)
-- what got disconnected?
local lost_dev = self.mounts[iface]
local type = lost_dev.type
log._warning("PPM: lost device " .. type .. " mounted to " .. iface)
return lost_dev
end
-- GENERAL ACCESSORS --
-- list all available peripherals
function list_avail()
return peripheral.getNames()
end
-- list mounted peripherals
function list_mounts()
return self.mounts
end
-- get a mounted peripheral by side/interface
function get_periph(iface)
return self.mounts[iface].dev
end
-- get a mounted peripheral type by side/interface
function get_type(iface)
return self.mounts[iface].type
end
-- get all mounted peripherals by type
function get_all_devices(name)
local devices = {}
for side, data in pairs(self.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)
function get_device(name)
local device = nil
for side, data in pairs(self.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)
function get_fission_reactor()
return get_device("fissionReactor")
end
-- get the wireless modem (if multiple, returns the first)
function get_wireless_modem()
local w_modem = nil
for side, device in pairs(self.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
function list_monitors()
return get_all_devices("monitor")
end

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

@ -0,0 +1,209 @@
IO_LVL = {
LOW = 0,
HIGH = 1
}
IO_DIR = {
IN = 0,
OUT = 1
}
IO_MODE = {
DIGITAL_OUT = 0,
DIGITAL_IN = 1,
ANALOG_OUT = 2,
ANALOG_IN = 3
}
RS_IO = {
-- digital inputs --
-- facility
F_SCRAM = 1, -- active low, facility-wide scram
F_AE2_LIVE = 2, -- active high, indicates whether AE2 network is online (hint: use redstone P2P)
-- reactor
R_SCRAM = 3, -- active low, reactor scram
R_ENABLE = 4, -- active high, reactor enable
-- digital outputs --
-- waste
WASTE_PO = 5, -- active low, polonium routing
WASTE_PU = 6, -- active low, plutonium routing
WASTE_AM = 7, -- active low, antimatter routing
-- reactor
R_SCRAMMED = 8, -- active high, if the reactor is scrammed
R_AUTO_SCRAM = 9, -- active high, if the reactor was automatically scrammed
R_ACTIVE = 10, -- active high, if the reactor is active
R_AUTO_CTRL = 11, -- active high, if the reactor burn rate is automatic
R_DMG_CRIT = 12, -- active high, if the reactor damage is critical
R_HIGH_TEMP = 13, -- active high, if the reactor is at a high temperature
R_NO_COOLANT = 14, -- active high, if the reactor has no coolant
R_EXCESS_HC = 15, -- active high, if the reactor has excess heated coolant
R_EXCESS_WS = 16, -- active high, if the reactor has excess waste
R_INSUFF_FUEL = 17, -- active high, if the reactor has insufficent fuel
R_PLC_TIMEOUT = 18, -- active high, if the reactor PLC has not been heard from
-- analog outputs --
A_R_BURN_RATE = 19, -- reactor burn rate percentage
A_B_BOIL_RATE = 20, -- boiler boil rate percentage
A_T_FLOW_RATE = 21 -- turbine flow rate percentage
}
function to_string(channel)
local names = {
"F_SCRAM",
"F_AE2_LIVE",
"R_SCRAM",
"R_ENABLE",
"WASTE_PO",
"WASTE_PU",
"WASTE_AM",
"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_TIMEOUT",
"A_R_BURN_RATE",
"A_B_BOIL_RATE",
"A_T_FLOW_RATE"
}
if channel > 0 and channel <= #names then
return names[channel]
else
return ""
end
end
function is_valid_channel(channel)
return channel ~= nil and channel > 0 and channel <= RS_IO.A_T_FLOW_RATE
end
function is_valid_side(side)
if side ~= nil then
for _, s in pairs(rs.getSides()) do
if s == side then return true end
end
end
return false
end
function is_color(color)
return (color > 0) and (bit.band(color, (color - 1)) == 0);
end
local _TRINARY = function (cond, t, f) if cond then return t else return f end end
local _DI_ACTIVE_HIGH = function (level) return level == IO_LVL.HIGH end
local _DI_ACTIVE_LOW = function (level) return level == IO_LVL.LOW end
local _DO_ACTIVE_HIGH = function (on) return _TRINARY(on, IO_LVL.HIGH, IO_LVL.LOW) end
local _DO_ACTIVE_LOW = function (on) return _TRINARY(on, IO_LVL.LOW, IO_LVL.HIGH) end
-- I/O mappings to I/O function and I/O mode
local RS_DIO_MAP = {
-- F_SCRAM
{ _f = _DI_ACTIVE_LOW, mode = IO_DIR.IN },
-- F_AE2_LIVE
{ _f = _DI_ACTIVE_HIGH, mode = IO_DIR.IN },
-- R_SCRAM
{ _f = _DI_ACTIVE_LOW, mode = IO_DIR.IN },
-- R_ENABLE
{ _f = _DI_ACTIVE_HIGH, mode = IO_DIR.IN },
-- WASTE_PO
{ _f = _DO_ACTIVE_LOW, mode = IO_DIR.OUT },
-- WASTE_PU
{ _f = _DO_ACTIVE_LOW, mode = IO_DIR.OUT },
-- WASTE_AM
{ _f = _DO_ACTIVE_LOW, mode = IO_DIR.OUT },
-- R_SCRAMMED
{ _f = _DO_ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_AUTO_SCRAM
{ _f = _DO_ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_ACTIVE
{ _f = _DO_ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_AUTO_CTRL
{ _f = _DO_ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_DMG_CRIT
{ _f = _DO_ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_HIGH_TEMP
{ _f = _DO_ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_NO_COOLANT
{ _f = _DO_ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_EXCESS_HC
{ _f = _DO_ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_EXCESS_WS
{ _f = _DO_ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_INSUFF_FUEL
{ _f = _DO_ACTIVE_HIGH, mode = IO_DIR.OUT },
-- R_PLC_TIMEOUT
{ _f = _DO_ACTIVE_HIGH, mode = IO_DIR.OUT }
}
function get_io_mode(channel)
local modes = {
IO_MODE.DIGITAL_IN, -- F_SCRAM
IO_MODE.DIGITAL_IN, -- F_AE2_LIVE
IO_MODE.DIGITAL_IN, -- R_SCRAM
IO_MODE.DIGITAL_IN, -- R_ENABLE
IO_MODE.DIGITAL_OUT, -- WASTE_PO
IO_MODE.DIGITAL_OUT, -- WASTE_PU
IO_MODE.DIGITAL_OUT, -- WASTE_AM
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_TIMEOUT
IO_MODE.ANALOG_OUT, -- A_R_BURN_RATE
IO_MODE.ANALOG_OUT, -- A_B_BOIL_RATE
IO_MODE.ANALOG_OUT -- A_T_FLOW_RATE
}
if channel > 0 and channel <= #modes then
return modes[channel]
else
return IO_MODE.ANALOG_IN
end
end
-- get digital IO level reading
function digital_read(rs_value)
if rs_value then
return IO_LVL.HIGH
else
return IO_LVL.LOW
end
end
-- returns the level corresponding to active
function digital_write(channel, active)
if channel < RS_IO.WASTE_PO or channel > RS_IO.R_PLC_TIMEOUT then
return IO_LVL.LOW
else
return RS_DIO_MAP[channel]._f(level)
end
end
-- returns true if the level corresponds to active
function digital_is_active(channel, level)
if channel > RS_IO.R_ENABLE or channel > RS_IO.R_PLC_TIMEOUT then
return false
else
return RS_DIO_MAP[channel]._f(level)
end
end

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

@ -0,0 +1,48 @@
-- we are overwriting 'print' so save it first
local _print = print
-- print
function print(message)
term.write(message)
end
-- print line
function println(message)
_print(message)
end
-- timestamped print
function print_ts(message)
term.write(os.date("[%H:%M:%S] ") .. message)
end
-- timestamped print line
function println_ts(message)
_print(os.date("[%H:%M:%S] ") .. message)
end
-- ComputerCraft OS Timer based Watchdog
-- triggers a timer event if not fed within 'timeout' seconds
function new_watchdog(timeout)
local self = {
_timeout = timeout,
_wd_timer = os.startTimer(timeout)
}
local get_timer = function ()
return self._wd_timer
end
local feed = function ()
if self._wd_timer ~= nil then
os.cancelTimer(self._wd_timer)
end
self._wd_timer = os.startTimer(self._timeout)
end
return {
get_timer = get_timer,
feed = feed
}
end

View File

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

16
supervisor/config.lua Normal file
View File

@ -0,0 +1,16 @@
-- type ('active','backup')
-- 'active' system carries through instructions and control
-- 'backup' system serves as a hot backup, still recieving data
-- from all PLCs and coordinator(s) while in backup to allow
-- instant failover if active goes offline without re-sync
SYSTEM_TYPE = 'active'
-- scada network listen for PLC's and RTU's
SCADA_DEV_LISTEN = 16000
-- failover synchronization
SCADA_FO_CHANNEL = 16001
-- listen port for SCADA supervisor access by coordinators
SCADA_SV_CHANNEL = 16002
-- expected number of reactors
NUM_REACTORS = 4

66
supervisor/startup.lua Normal file
View File

@ -0,0 +1,66 @@
--
-- Nuclear Generation Facility SCADA Supervisor
--
os.loadAPI("scada-common/log.lua")
os.loadAPI("scada-common/util.lua")
os.loadAPI("scada-common/ppm.lua")
os.loadAPI("scada-common/comms.lua")
os.loadAPI("supervisor/config.lua")
os.loadAPI("supervisor/supervisor.lua")
local SUPERVISOR_VERSION = "alpha-v0.1.0"
local print_ts = util.print_ts
ppm.mount_all()
local modem = ppm.get_device("modem")
print("| SCADA Supervisor - " .. SUPERVISOR_VERSION .. " |")
-- we need a modem
if modem == nil then
print("Please connect a modem.")
return
end
-- determine active/backup mode
local mode = comms.SCADA_SV_MODES.BACKUP
if config.SYSTEM_TYPE == "active" then
mode = comms.SCADA_SV_MODES.ACTIVE
end
-- start comms, open all channels
if not modem.isOpen(config.SCADA_DEV_LISTEN) then
modem.open(config.SCADA_DEV_LISTEN)
end
if not modem.isOpen(config.SCADA_FO_CHANNEL) then
modem.open(config.SCADA_FO_CHANNEL)
end
if not modem.isOpen(config.SCADA_SV_CHANNEL) then
modem.open(config.SCADA_SV_CHANNEL)
end
local comms = supervisor.superv_comms(config.NUM_REACTORS, modem, config.SCADA_DEV_LISTEN, config.SCADA_FO_CHANNEL, config.SCADA_SV_CHANNEL)
-- base loop clock (4Hz, 5 ticks)
local loop_tick = os.startTimer(0.25)
-- event loop
while true do
local event, param1, param2, param3, param4, param5 = os.pullEventRaw()
-- handle event
if event == "timer" and param1 == loop_tick then
-- basic event tick, send keep-alives
elseif event == "modem_message" then
-- got a packet
elseif event == "terminate" then
-- safe exit
print_ts("[alert] terminated\n")
-- todo: attempt failover, alert hot backup
return
end
end

15
supervisor/supervisor.lua Normal file
View File

@ -0,0 +1,15 @@
-- #REQUIRES comms.lua
-- supervisory controller communications
function superv_comms(mode, num_reactors, modem, dev_listen, fo_channel, sv_channel)
local self = {
mode = mode,
seq_num = 0,
num_reactors = num_reactors,
modem = modem,
dev_listen = dev_listen,
fo_channel = fo_channel,
sv_channel = sv_channel,
reactor_struct_cache = nil
}
end