mirror of
https://github.com/MikaylaFischler/cc-mek-scada.git
synced 2024-08-30 18:22:34 +00:00
commit
a3920ec2d8
56
README.md
56
README.md
@ -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.
|
||||
|
8
coordinator/coordinator.lua
Normal file
8
coordinator/coordinator.lua
Normal 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
27
coordinator/startup.lua
Normal 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
0
pocket/config.lua
Normal file
3
pocket/startup.lua
Normal file
3
pocket/startup.lua
Normal file
@ -0,0 +1,3 @@
|
||||
--
|
||||
-- SCADA Coordinator Access on a Pocket Computer
|
||||
--
|
8
reactor-plc/config.lua
Normal file
8
reactor-plc/config.lua
Normal 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
624
reactor-plc/plc.lua
Normal 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
297
reactor-plc/startup.lua
Normal 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
45
rtu/config.lua
Normal 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
51
rtu/dev/boiler_rtu.lua
Normal 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
33
rtu/dev/imatrix_rtu.lua
Normal 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
93
rtu/dev/redstone_rtu.lua
Normal 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
46
rtu/dev/turbine_rtu.lua
Normal 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
269
rtu/rtu.lua
Normal 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
250
rtu/startup.lua
Normal 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
54
scada-common/alarm.lua
Normal 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
180
scada-common/comms.lua
Normal 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
64
scada-common/log.lua
Normal 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
325
scada-common/modbus.lua
Normal 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
236
scada-common/ppm.lua
Normal 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
209
scada-common/rsio.lua
Normal 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
48
scada-common/util.lua
Normal 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
|
@ -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
16
supervisor/config.lua
Normal 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
66
supervisor/startup.lua
Normal 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
15
supervisor/supervisor.lua
Normal 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
|
Loading…
Reference in New Issue
Block a user