From 2a21d7d0be1c8ec4d8b14a200efce10022fbeac8 Mon Sep 17 00:00:00 2001 From: Mikayla Fischler Date: Sun, 17 Apr 2022 21:12:25 -0400 Subject: [PATCH] #14, #15 ppm access fault handling, report modbus exceptions, handle ppm faults in PLC/RTU code --- reactor-plc/plc.lua | 65 +++++++++++------- reactor-plc/startup.lua | 6 +- rtu/rtu.lua | 42 +++++++++--- scada-common/modbus.lua | 147 ++++++++++++++++++++++++++++++++-------- scada-common/ppm.lua | 35 +++++++++- 5 files changed, 227 insertions(+), 68 deletions(-) diff --git a/reactor-plc/plc.lua b/reactor-plc/plc.lua index c5f1bf2..6843303 100644 --- a/reactor-plc/plc.lua +++ b/reactor-plc/plc.lua @@ -1,4 +1,5 @@ -- #REQUIRES comms.lua +-- #REQUIRES ppm.lua -- Internal Safety System -- identifies dangerous states and SCRAMs reactor if warranted @@ -19,7 +20,7 @@ function iss_init(reactor) -- check for critical damage local damage_critical = function () local damage_percent = self.reactor.getDamagePercent() - if damage_percent == nil then + if damage_percent == ppm.ACCESS_FAULT then -- lost the peripheral or terminated, handled later log._error("ISS: failed to check reactor damage") return false @@ -31,7 +32,7 @@ function iss_init(reactor) -- check for heated coolant backup local excess_heated_coolant = function () local hc_needed = self.reactor.getHeatedCoolantNeeded() - if hc_needed == nil then + 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 @@ -43,7 +44,7 @@ function iss_init(reactor) -- check for excess waste local excess_waste = function () local w_needed = self.reactor.getWasteNeeded() - if w_needed == nil then + 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 @@ -56,7 +57,7 @@ function iss_init(reactor) local high_temp = function () -- mekanism: MAX_DAMAGE_TEMPERATURE = 1_200 local temp = self.reactor.getTemperature() - if temp == nil then + if temp == ppm.ACCESS_FAULT then -- lost the peripheral or terminated, handled later log._error("ISS: failed to check reactor temperature") return false @@ -68,7 +69,7 @@ function iss_init(reactor) -- check if there is no fuel local insufficient_fuel = function () local fuel = self.reactor.getFuel() - if fuel == nil then + if fuel == ppm.ACCESS_FAULT then -- lost the peripheral or terminated, handled later log._error("ISS: failed to check reactor fuel level") return false @@ -80,7 +81,7 @@ function iss_init(reactor) -- check if there is no coolant local no_coolant = function () local coolant_filled = self.reactor.getCoolantFilledPercentage() - if coolant_filled == nil then + 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 @@ -126,7 +127,9 @@ function iss_init(reactor) log._warning("ISS: reactor SCRAM") self.tripped = true self.trip_cause = status - self.reactor.scram() + 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 @@ -293,6 +296,7 @@ function comms_init(id, modem, local_port, server_port, reactor, iss) -- variable reactor status information, excluding heating rate local _reactor_status = function () + ppm.clear_fault() return { status = self.reactor.getStatus(), burn_rate = self.reactor.getBurnRate(), @@ -316,17 +320,19 @@ function comms_init(id, modem, local_port, server_port, reactor, iss) 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 = _reactor_status() + local status, faulted = _reactor_status() local changed = false - for key, value in pairs(status) do - if value ~= self.status_cache[key] then - changed = true - break + if not faulted then + for key, value in pairs(status) do + if value ~= self.status_cache[key] then + changed = true + break + end end end @@ -362,6 +368,7 @@ function comms_init(id, modem, local_port, server_port, reactor, iss) -- 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(), @@ -373,13 +380,17 @@ function comms_init(id, modem, local_port, server_port, reactor, iss) max_burn = self.reactor.getMaxBurnRate() } - local struct_packet = { - id = self.id, - type = RPLC_TYPES.MEK_STRUCT, - mek_data = mek_data - } + if not faulted then + local struct_packet = { + id = self.id, + type = RPLC_TYPES.MEK_STRUCT, + mek_data = mek_data + } - _send(struct_packet) + _send(struct_packet) + else + log._error("failed to send structure: PPM fault") + end end local _send_iss_status = function () @@ -407,6 +418,7 @@ function comms_init(id, modem, local_port, server_port, reactor, iss) -- reconnect a newly connected reactor local reconnect_reactor = function (reactor) self.reactor = reactor + _update_status_cache() end -- parse an RPLC packet @@ -486,25 +498,25 @@ function comms_init(id, modem, local_port, server_port, reactor, iss) -- disable the reactor self.scrammed = true plc_state.scram = true - _send_ack(packet.type, self.reactor.scram()) + _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()) + _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 ~= nil then + 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) + _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()) @@ -540,9 +552,10 @@ function comms_init(id, modem, local_port, server_port, reactor, iss) self.linked = link_ack == RPLC_LINKING.ALLOW else - log._("discarding non-link packet before linked") + log._debug("discarding non-link packet before linked") end elseif packet.scada_frame.protocol() == PROTOCOLS.SCADA_MGMT then + -- todo end end end @@ -559,7 +572,8 @@ function comms_init(id, modem, local_port, server_port, reactor, iss) -- send live status information -- overridden : if ISS force disabled reactor - local send_status = function (overridden) + -- degraded : if PLC status is degraded + local send_status = function (overridden, degraded) local mek_data = nil if _update_status_cache() then @@ -572,6 +586,7 @@ function comms_init(id, modem, local_port, server_port, reactor, iss) timestamp = os.time(), control_state = not self.scrammed, overridden = overridden, + degraded = degraded, heating_rate = self.reactor.getHeatingRate(), mek_data = mek_data } diff --git a/reactor-plc/startup.lua b/reactor-plc/startup.lua index b5b23e0..869be2f 100644 --- a/reactor-plc/startup.lua +++ b/reactor-plc/startup.lua @@ -10,7 +10,7 @@ os.loadAPI("scada-common/comms.lua") os.loadAPI("config.lua") os.loadAPI("plc.lua") -local R_PLC_VERSION = "alpha-v0.1.4" +local R_PLC_VERSION = "alpha-v0.1.5" local print = util.print local println = util.println @@ -243,7 +243,7 @@ while true do if plc_comms.is_linked() then if ticks_to_update <= 0 then - plc_comms.send_status(iss_tripped) + plc_comms.send_status(iss_tripped, plc_state.degraded) ticks_to_update = UPDATE_TICKS end else @@ -275,7 +275,7 @@ while true do -- safe exit if plc_state.init_ok then plc_state.scram = true - if reactor.scram() then + if reactor.scram() ~= ppm.ACCESS_FAULT then println_ts("reactor disabled") else -- send an alarm: plc_comms.send_alarm(ALARMS.PLC_LOST_CONTROL) ? diff --git a/rtu/rtu.lua b/rtu/rtu.lua index 6bbb1d1..176b37d 100644 --- a/rtu/rtu.lua +++ b/rtu/rtu.lua @@ -1,5 +1,6 @@ -- #REQUIRES comms.lua -- #REQUIRES modbus.lua +-- #REQUIRES ppm.lua function rtu_init() local self = { @@ -10,68 +11,91 @@ function rtu_init() io_count_cache = { 0, 0, 0, 0 } } - local __count_io = function () + 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() + _count_io() return #self.discrete_inputs end + -- return : value, access fault local read_di = function (di_addr) - return self.discrete_inputs[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() + _count_io() return #self.coils end + -- return : value, access fault local read_coil = function (coil_addr) - return self.coils[coil_addr].read() + 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() + _count_io() return #self.input_regs end + -- return : value, access fault local read_input_reg = function (reg_addr) - return self.coils[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() + _count_io() return #self.holding_regs end + -- return : value, access fault local read_holding_reg = function (reg_addr) - return self.coils[reg_addr].read() + 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 { diff --git a/scada-common/modbus.lua b/scada-common/modbus.lua index 9d2899d..dc73e64 100644 --- a/scada-common/modbus.lua +++ b/scada-common/modbus.lua @@ -11,6 +11,20 @@ local MODBUS_FCODE = { 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 modbus_init(rtu_dev) local self = { @@ -19,13 +33,22 @@ function modbus_init(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] = self.rtu.read_coil(c_addr_start + i) + 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 @@ -33,13 +56,22 @@ function modbus_init(rtu_dev) 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] = self.rtu.read_di(di_addr_start + i) + 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 @@ -47,13 +79,22 @@ function modbus_init(rtu_dev) 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] = self.rtu.read_holding_reg(hr_addr_start + i) + 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 @@ -61,93 +102,132 @@ function modbus_init(rtu_dev) 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] = self.rtu.read_input_reg(ir_addr_start + i) + 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 - self.rtu.write_coil(c_addr, value) + 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 + 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 - self.rtu.write_holding_reg(hr_addr, value) + 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 - self.rtu.write_coil(c_addr_start + i, values[i + 1]) + 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 + 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 - self.rtu.write_coil(hr_addr_start + i, values[i + 1]) + 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 + return return_ok, response end local handle_packet = function (packet) local return_code = true - local readings = nil + 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, readings = _1_read_coils(packet.data[1], packet.data[2]) + return_code, response = _1_read_coils(packet.data[1], packet.data[2]) elseif packet.func_code == MODBUS_FCODE.READ_DISCRETE_INPUTS then - return_code, readings = _2_read_discrete_inputs(packet.data[1], packet.data[2]) + 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, readings = _3_read_multiple_holding_registers(packet.data[1], packet.data[2]) + 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, readings = _4_read_input_registers(packet.data[1], packet.data[2]) + 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 = _5_write_single_coil(packet.data[1], packet.data[2]) + 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 = _6_write_single_holding_register(packet.data[1], packet.data[2]) + 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 = _15_write_multiple_coils(packet.data[1], packet.data[2]) + 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 = _16_write_multiple_holding_registers(packet.data[1], packet.data[2]) + 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 @@ -155,19 +235,28 @@ function modbus_init(rtu_dev) end if return_code then - -- response (default is to echo back) - response = packet - if readings ~= nil then - response.length = #readings - response.data = readings + -- default is to echo back + if type(response) == "table" then + reply.length = #response + reply.data = response end else -- echo back with error flag - response = packet - response.func_code = bit.bor(packet.func_code, 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, response + return return_code, reply end return { diff --git a/scada-common/ppm.lua b/scada-common/ppm.lua index 1775b77..3eb3977 100644 --- a/scada-common/ppm.lua +++ b/scada-common/ppm.lua @@ -4,12 +4,17 @@ -- Protected Peripheral Manager -- +ACCESS_OK = true +ACCESS_FAULT = nil + ---------------------------- -- PRIVATE DATA/FUNCTIONS -- ---------------------------- local self = { mounts = {}, + auto_cf = false, + faulted = false, mute = false } @@ -21,18 +26,22 @@ local peri_init = function (device) 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 true + return ACCESS_OK else return result end else -- function failed + self.faulted = true if not mute then log._error("PPM: protected " .. key .. "() -> " .. result) end - return nil + return ACCESS_FAULT end end end @@ -54,6 +63,28 @@ 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 + -- MOUNTING -- -- mount all available peripherals (clears mounts first)