diff --git a/coordinator/coordinator.lua b/coordinator/coordinator.lua new file mode 100644 index 0000000..96d766e --- /dev/null +++ b/coordinator/coordinator.lua @@ -0,0 +1,8 @@ +-- #REQUIRES comms.lua + +-- coordinator communications +function coord_comms() + local self = { + reactor_struct_cache = nil + } +end diff --git a/coordinator/scada-coordinator.lua b/coordinator/scada-coordinator.lua deleted file mode 100644 index e69de29..0000000 diff --git a/coordinator/startup.lua b/coordinator/startup.lua new file mode 100644 index 0000000..20be7a3 --- /dev/null +++ b/coordinator/startup.lua @@ -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 diff --git a/reactor-plc/plc.lua b/reactor-plc/plc.lua index decfd0b..675bc70 100644 --- a/reactor-plc/plc.lua +++ b/reactor-plc/plc.lua @@ -1,3 +1,5 @@ +-- #REQUIRES comms.lua + function scada_link(plc_comms) local linked = false local link_timeout = os.startTimer(5) @@ -169,3 +171,192 @@ function iss_init(reactor) timed_out = timed_out } end + +-- reactor PLC communications +function rplc_comms(id, modem, local_port, server_port, reactor) + local self = { + id = id, + seq_num = 0, + modem = modem, + s_port = server_port, + l_port = local_port, + reactor = reactor, + status_cache = nil + } + + -- 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 () + 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() + } + end + + local _update_status_cache = function () + local status = _reactor_status() + local changed = false + + for key, value in pairs(status) do + if value ~= self.status_cache[key] then + changed = true + break + end + end + + if changed then + self.status_cache = status + end + + return changed + end + + -- PUBLIC FUNCTIONS -- + + -- 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) + + -- get using RPLC protocol format + if s_pkt.is_valid() and s_pkt.protocol() == PROTOCOLS.RPLC then + local body = s_pkt.data() + if #body > 2 then + pkt = { + scada_frame = s_pkt, + id = body[1], + type = body[2], + length = #body - 2, + body = { table.unpack(body, 3, 2 + #body) } + } + end + end + + return pkt + end + + -- handle a linking packet + local handle_link = function (packet) + if packet.type == RPLC_TYPES.LINK_REQ then + return packet.data[1] == RPLC_LINKING.ALLOW + else + return nil + end + end + + -- handle an RPLC packet + local handle_packet = function (packet) + if packet.type == RPLC_TYPES.KEEP_ALIVE then + -- keep alive request received, nothing to do except feed watchdog + elseif packet.type == RPLC_TYPES.MEK_STRUCT then + -- request for physical structure + send_struct() + elseif packet.type == RPLC_TYPES.RS_IO_CONNS then + -- request for redstone connections + send_rs_io_conns() + elseif packet.type == RPLC_TYPES.RS_IO_GET then + elseif packet.type == RPLC_TYPES.RS_IO_SET then + elseif packet.type == RPLC_TYPES.MEK_SCRAM then + elseif packet.type == RPLC_TYPES.MEK_ENABLE then + elseif packet.type == RPLC_TYPES.MEK_BURN_RATE then + elseif packet.type == RPLC_TYPES.ISS_GET then + elseif packet.type == RPLC_TYPES.ISS_CLEAR then + 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 structure properties (these should not change) + -- (server will cache these) + local send_struct = function () + 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() + } + + local struct_packet = { + id = self.id, + type = RPLC_TYPES.MEK_STRUCT, + mek_data = mek_data + } + + _send(struct_packet) + end + + -- send live status information + -- control_state : acknowledged control state from supervisor + -- overridden : if ISS force disabled reactor + local send_status = function (control_state, overridden) + 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 = control_state, + overridden = overridden, + heating_rate = self.reactor.getHeatingRate(), + mek_data = mek_data + } + + _send(sys_status) + end + + return { + parse_packet = parse_packet, + handle_link = handle_link, + handle_packet = handle_packet, + send_link_req = send_link_req, + send_struct = send_struct, + send_status = send_status + } +end diff --git a/reactor-plc/startup.lua b/reactor-plc/startup.lua index 11a9982..6875bcb 100644 --- a/reactor-plc/startup.lua +++ b/reactor-plc/startup.lua @@ -42,7 +42,7 @@ if not modem.isOpen(config.LISTEN_PORT) then modem.open(config.LISTEN_PORT) end -local plc_comms = comms.rplc_comms(config.REACTOR_ID, modem, config.LISTEN_PORT, config.SERVER_PORT, reactor) +local plc_comms = plc.rplc_comms(config.REACTOR_ID, modem, config.LISTEN_PORT, config.SERVER_PORT, reactor) -- attempt server connection -- exit application if connection is denied diff --git a/rtu/rtu.lua b/rtu/rtu.lua index 32600a1..e682787 100644 --- a/rtu/rtu.lua +++ b/rtu/rtu.lua @@ -1,3 +1,6 @@ +-- #REQUIRES comms.lua +-- #REQUIRES modbus.lua + function rtu_init() local self = { discrete_inputs = {}, @@ -85,3 +88,165 @@ function rtu_init() 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 + } + + -- PRIVATE FUNCTIONS -- + + local _send = function (protocol, msg) + local packet = 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 = 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() + m_pkt.receive(s_pkt.data()) + + pkt = { + scada_frame = s_pkt, + modbus_frame = m_pkt + } + -- get as SCADA management packet + elseif s_pkt.protocol() == PROTOCOLS.SCADA_MGMT then + local body = s_pkt.data() + if #body > 1 then + pkt = { + scada_frame = s_pkt, + type = body[1], + length = #body - 1, + body = { table.unpack(body, 2, 1 + #body) } + } + elseif #body == 1 then + pkt = { + scada_frame = s_pkt, + type = body[1], + length = #body - 1, + body = nil + } + else + log._error("Malformed SCADA packet has no length field") + 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.modbus_frame.unit_id <= #units then + local return_code, response = units.modbus_io.handle_packet(packet.modbus_frame) + _send(response, PROTOCOLS.MODBUS_TCP) + + 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(advertisement, PROTOCOLS.SCADA_MGMT) + end + + local send_heartbeat = function () + local heartbeat = { + type = SCADA_MGMT_TYPES.RTU_HEARTBEAT + } + + _send(heartbeat, PROTOCOLS.SCADA_MGMT) + end + + return { + parse_packet = parse_packet, + handle_packet = handle_packet, + send_advertisement = send_advertisement, + send_heartbeat = send_heartbeat + } +end diff --git a/rtu/startup.lua b/rtu/startup.lua index ce6adb8..b287a53 100644 --- a/rtu/startup.lua +++ b/rtu/startup.lua @@ -41,7 +41,7 @@ if not modem.isOpen(config.LISTEN_PORT) then modem.open(config.LISTEN_PORT) end -local rtu_comms = comms.rtu_comms(config.REACTOR_ID, modem, config.LISTEN_PORT, config.SERVER_PORT, reactor) +local rtu_comms = rtu.rtu_comms(config.REACTOR_ID, modem, config.LISTEN_PORT, config.SERVER_PORT, reactor) ---------------------------------------- -- determine configuration diff --git a/scada-common/comms.lua b/scada-common/comms.lua index bbcedde..f760352 100644 --- a/scada-common/comms.lua +++ b/scada-common/comms.lua @@ -1,5 +1,3 @@ --- #REQUIRES modbus.lua - PROTOCOLS = { MODBUS_TCP = 0, -- our "MODBUS TCP"-esque protocol RPLC = 1, -- reactor PLC protocol @@ -116,375 +114,3 @@ function scada_packet() data = data } end - --- coordinator communications -function coord_comms() - local self = { - reactor_struct_cache = nil - } -end - --- 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 - -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 - } - - -- PRIVATE FUNCTIONS -- - - local _send = function (protocol, msg) - local packet = 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 = 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() - m_pkt.receive(s_pkt.data()) - - pkt = { - scada_frame = s_pkt, - modbus_frame = m_pkt - } - -- get as SCADA management packet - elseif s_pkt.protocol() == PROTOCOLS.SCADA_MGMT then - local body = s_pkt.data() - if #body > 1 then - pkt = { - scada_frame = s_pkt, - type = body[1], - length = #body - 1, - body = { table.unpack(body, 2, 1 + #body) } - } - elseif #body == 1 then - pkt = { - scada_frame = s_pkt, - type = body[1], - length = #body - 1, - body = nil - } - else - log._error("Malformed SCADA packet has no length field") - 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.modbus_frame.unit_id <= #units then - local return_code, response = units.modbus_io.handle_packet(packet.modbus_frame) - _send(response, PROTOCOLS.MODBUS_TCP) - - 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(advertisement, PROTOCOLS.SCADA_MGMT) - end - - local send_heartbeat = function () - local heartbeat = { - type = SCADA_MGMT_TYPES.RTU_HEARTBEAT - } - - _send(heartbeat, PROTOCOLS.SCADA_MGMT) - end - - return { - parse_packet = parse_packet, - handle_packet = handle_packet, - send_advertisement = send_advertisement, - send_heartbeat = send_heartbeat - } -end - --- reactor PLC communications -function rplc_comms(id, modem, local_port, server_port, reactor) - local self = { - id = id, - seq_num = 0, - modem = modem, - s_port = server_port, - l_port = local_port, - reactor = reactor, - status_cache = nil - } - - -- 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 () - 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() - } - end - - local _update_status_cache = function () - local status = _reactor_status() - local changed = false - - for key, value in pairs(status) do - if value ~= self.status_cache[key] then - changed = true - break - end - end - - if changed then - self.status_cache = status - end - - return changed - end - - -- PUBLIC FUNCTIONS -- - - -- 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) - - -- get using RPLC protocol format - if s_pkt.is_valid() and s_pkt.protocol() == PROTOCOLS.RPLC then - local body = s_pkt.data() - if #body > 2 then - pkt = { - scada_frame = s_pkt, - id = body[1], - type = body[2], - length = #body - 2, - body = { table.unpack(body, 3, 2 + #body) } - } - end - end - - return pkt - end - - -- handle a linking packet - local handle_link = function (packet) - if packet.type == RPLC_TYPES.LINK_REQ then - return packet.data[1] == RPLC_LINKING.ALLOW - else - return nil - end - end - - -- handle an RPLC packet - local handle_packet = function (packet) - if packet.type == RPLC_TYPES.KEEP_ALIVE then - -- keep alive request received, nothing to do except feed watchdog - elseif packet.type == RPLC_TYPES.MEK_STRUCT then - -- request for physical structure - send_struct() - elseif packet.type == RPLC_TYPES.RS_IO_CONNS then - -- request for redstone connections - send_rs_io_conns() - elseif packet.type == RPLC_TYPES.RS_IO_GET then - elseif packet.type == RPLC_TYPES.RS_IO_SET then - elseif packet.type == RPLC_TYPES.MEK_SCRAM then - elseif packet.type == RPLC_TYPES.MEK_ENABLE then - elseif packet.type == RPLC_TYPES.MEK_BURN_RATE then - elseif packet.type == RPLC_TYPES.ISS_GET then - elseif packet.type == RPLC_TYPES.ISS_CLEAR then - 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 structure properties (these should not change) - -- (server will cache these) - local send_struct = function () - 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() - } - - local struct_packet = { - id = self.id, - type = RPLC_TYPES.MEK_STRUCT, - mek_data = mek_data - } - - _send(struct_packet) - end - - -- send live status information - -- control_state : acknowledged control state from supervisor - -- overridden : if ISS force disabled reactor - local send_status = function (control_state, overridden) - 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 = control_state, - overridden = overridden, - heating_rate = self.reactor.getHeatingRate(), - mek_data = mek_data - } - - _send(sys_status) - end - - return { - parse_packet = parse_packet, - handle_link = handle_link, - handle_packet = handle_packet, - send_link_req = send_link_req, - send_struct = send_struct, - send_status = send_status - } -end