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