-- -- Communications -- local log = require("scada-common.log") local types = require("scada-common.types") ---@class comms local comms = {} local rtu_t = types.rtu_t local insert = table.insert local max_distance = nil comms.version = "1.3.2" ---@alias PROTOCOLS integer local PROTOCOLS = { MODBUS_TCP = 0, -- our "MODBUS TCP"-esque protocol RPLC = 1, -- reactor PLC protocol SCADA_MGMT = 2, -- SCADA supervisor management, device advertisements, etc SCADA_CRDN = 3, -- data/control packets for coordinators to/from supervisory controllers COORD_API = 4 -- data/control packets for pocket computers to/from coordinators } ---@alias RPLC_TYPES integer local RPLC_TYPES = { STATUS = 0, -- reactor/system status MEK_STRUCT = 1, -- mekanism build structure MEK_BURN_RATE = 2, -- set burn rate RPS_ENABLE = 3, -- enable reactor RPS_SCRAM = 4, -- SCRAM reactor (manual request) RPS_ASCRAM = 5, -- SCRAM reactor (automatic request) RPS_STATUS = 6, -- RPS status RPS_ALARM = 7, -- RPS alarm broadcast RPS_RESET = 8, -- clear RPS trip (if in bad state, will trip immediately) RPS_AUTO_RESET = 9, -- clear RPS trip if it is just a timeout or auto scram AUTO_BURN_RATE = 10 -- set an automatic burn rate, PLC will respond with status, enable toggle speed limited } ---@alias SCADA_MGMT_TYPES integer local SCADA_MGMT_TYPES = { ESTABLISH = 0, -- establish new connection KEEP_ALIVE = 1, -- keep alive packet w/ RTT CLOSE = 2, -- close a connection RTU_ADVERT = 3, -- RTU capability advertisement RTU_DEV_REMOUNT = 4 -- RTU multiblock possbily changed (formed, unformed) due to PPM remount } ---@alias SCADA_CRDN_TYPES integer local SCADA_CRDN_TYPES = { FAC_BUILDS = 0, -- facility RTU builds FAC_STATUS = 1, -- state of facility and facility devices FAC_CMD = 2, -- faility command UNIT_BUILDS = 3, -- build of each reactor unit (reactor + RTUs) UNIT_STATUSES = 4, -- state of each of the reactor units UNIT_CMD = 5 -- command a reactor unit } ---@alias CAPI_TYPES integer local CAPI_TYPES = { ESTABLISH = 0 -- initial greeting } ---@alias ESTABLISH_ACK integer local ESTABLISH_ACK = { ALLOW = 0, -- link approved DENY = 1, -- link denied COLLISION = 2 -- link denied due to existing active link } ---@alias DEVICE_TYPES integer local DEVICE_TYPES = { PLC = 0, -- PLC device type for establish RTU = 1, -- RTU device type for establish SV = 2, -- supervisor device type for establish CRDN = 3 -- coordinator device type for establish } ---@alias RTU_UNIT_TYPES integer local RTU_UNIT_TYPES = { REDSTONE = 0, -- redstone I/O BOILER_VALVE = 1, -- boiler mekanism 10.1+ TURBINE_VALVE = 2, -- turbine, mekanism 10.1+ IMATRIX = 3, -- induction matrix SPS = 4, -- SPS SNA = 5, -- SNA ENV_DETECTOR = 6 -- environment detector } ---@alias PLC_AUTO_ACK integer local PLC_AUTO_ACK = { FAIL = 0, -- failed to set burn rate/burn rate invalid DIRECT_SET_OK = 1, -- successfully set burn rate RAMP_SET_OK = 2, -- successfully started burn rate ramping ZERO_DIS_OK = 3 -- successfully disabled reactor with < 0.01 burn rate } ---@alias FAC_COMMANDS integer local FAC_COMMANDS = { SCRAM_ALL = 0, -- SCRAM all reactors STOP = 1, -- stop automatic control START = 2, -- start automatic control ACK_ALL_ALARMS = 3 -- acknowledge all alarms on all units } ---@alias UNIT_COMMANDS integer local UNIT_COMMANDS = { SCRAM = 0, -- SCRAM the reactor START = 1, -- start the reactor RESET_RPS = 2, -- reset the RPS SET_BURN = 3, -- set the burn rate SET_WASTE = 4, -- set the waste processing mode ACK_ALL_ALARMS = 5, -- ack all active alarms ACK_ALARM = 6, -- ack a particular alarm RESET_ALARM = 7, -- reset a particular alarm SET_GROUP = 8 -- assign this unit to a group } comms.PROTOCOLS = PROTOCOLS comms.RPLC_TYPES = RPLC_TYPES comms.SCADA_MGMT_TYPES = SCADA_MGMT_TYPES comms.SCADA_CRDN_TYPES = SCADA_CRDN_TYPES comms.CAPI_TYPES = CAPI_TYPES comms.ESTABLISH_ACK = ESTABLISH_ACK comms.DEVICE_TYPES = DEVICE_TYPES comms.RTU_UNIT_TYPES = RTU_UNIT_TYPES comms.PLC_AUTO_ACK = PLC_AUTO_ACK comms.UNIT_COMMANDS = UNIT_COMMANDS comms.FAC_COMMANDS = FAC_COMMANDS ---@alias packet scada_packet|modbus_packet|rplc_packet|mgmt_packet|crdn_packet|capi_packet ---@alias frame modbus_frame|rplc_frame|mgmt_frame|crdn_frame|capi_frame -- configure the maximum allowable message receive distance
-- packets received with distances greater than this will be silently discarded ---@param distance integer max modem message distance (less than 1 disables the limit) function comms.set_trusted_range(distance) if distance < 1 then max_distance = nil else max_distance = distance end end -- generic SCADA packet object function comms.scada_packet() local self = { modem_msg_in = nil, valid = false, raw = { -1, -1, {} }, seq_num = -1, protocol = -1, length = 0, payload = {} } ---@class scada_packet local public = {} -- make a SCADA packet ---@param seq_num integer ---@param protocol PROTOCOLS ---@param payload table function public.make(seq_num, protocol, payload) self.valid = true self.seq_num = seq_num self.protocol = protocol self.length = #payload self.payload = payload self.raw = { self.seq_num, self.protocol, self.payload } end -- parse in a modem message as a SCADA packet ---@param side string ---@param sender integer ---@param reply_to integer ---@param message any ---@param distance integer function public.receive(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 (type(max_distance) == "number") and (distance > max_distance) then -- outside of maximum allowable transmission distance -- log.debug("comms.scada_packet.receive(): discarding packet with distance " .. distance .. " outside of trusted range") else if type(self.raw) == "table" then if #self.raw >= 3 then self.seq_num = self.raw[1] self.protocol = self.raw[2] -- element 3 must be a table if type(self.raw[3]) == "table" then self.length = #self.raw[3] self.payload = self.raw[3] end end self.valid = type(self.seq_num) == "number" and type(self.protocol) == "number" and type(self.payload) == "table" end end return self.valid end -- public accessors -- function public.modem_event() return self.modem_msg_in end function public.raw_sendable() return self.raw end function public.local_port() return self.modem_msg_in.s_port end function public.remote_port() return self.modem_msg_in.r_port end function public.is_valid() return self.valid end function public.seq_num() return self.seq_num end function public.protocol() return self.protocol end function public.length() return self.length end function public.data() return self.payload end return public end -- MODBUS packet -- modeled after MODBUS TCP packet function comms.modbus_packet() local self = { frame = nil, raw = {}, txn_id = -1, length = 0, unit_id = -1, func_code = 0, data = {} } ---@class modbus_packet local public = {} -- make a MODBUS packet ---@param txn_id integer ---@param unit_id integer ---@param func_code MODBUS_FCODE ---@param data table function public.make(txn_id, unit_id, func_code, data) if type(data) == "table" then self.txn_id = txn_id self.length = #data self.unit_id = unit_id self.func_code = func_code self.data = data -- populate raw array self.raw = { self.txn_id, self.unit_id, self.func_code } for i = 1, self.length do insert(self.raw, data[i]) end else log.error("comms.modbus_packet.make(): data not table") end end -- decode a MODBUS packet from a SCADA frame ---@param frame scada_packet ---@return boolean success function public.decode(frame) if frame then self.frame = frame if frame.protocol() == PROTOCOLS.MODBUS_TCP then local size_ok = frame.length() >= 3 if size_ok then local data = frame.data() public.make(data[1], data[2], data[3], { table.unpack(data, 4, #data) }) end local valid = type(self.txn_id) == "number" and type(self.unit_id) == "number" and type(self.func_code) == "number" return size_ok and valid else log.debug("attempted MODBUS_TCP parse of incorrect protocol " .. frame.protocol(), true) return false end else log.debug("nil frame encountered", true) return false end end -- get raw to send function public.raw_sendable() return self.raw end -- get this packet as a frame with an immutable relation to this object function public.get() ---@class modbus_frame local frame = { scada_frame = self.frame, txn_id = self.txn_id, length = self.length, unit_id = self.unit_id, func_code = self.func_code, data = self.data } return frame end return public end -- reactor PLC packet function comms.rplc_packet() local self = { frame = nil, raw = {}, id = 0, type = -1, length = 0, data = {} } ---@class rplc_packet local public = {} -- check that type is known local function _rplc_type_valid() return self.type == RPLC_TYPES.STATUS or self.type == RPLC_TYPES.MEK_STRUCT or self.type == RPLC_TYPES.MEK_BURN_RATE or self.type == RPLC_TYPES.RPS_ENABLE or self.type == RPLC_TYPES.RPS_SCRAM or self.type == RPLC_TYPES.RPS_ASCRAM or self.type == RPLC_TYPES.RPS_STATUS or self.type == RPLC_TYPES.RPS_ALARM or self.type == RPLC_TYPES.RPS_RESET or self.type == RPLC_TYPES.RPS_AUTO_RESET or self.type == RPLC_TYPES.AUTO_BURN_RATE end -- make an RPLC packet ---@param id integer ---@param packet_type RPLC_TYPES ---@param data table function public.make(id, packet_type, data) if type(data) == "table" then -- packet accessor properties self.id = id self.type = packet_type self.length = #data self.data = data -- populate raw array self.raw = { self.id, self.type } for i = 1, #data do insert(self.raw, data[i]) end else log.error("comms.rplc_packet.make(): data not table") end end -- decode an RPLC packet from a SCADA frame ---@param frame scada_packet ---@return boolean success function public.decode(frame) if frame then self.frame = frame if frame.protocol() == PROTOCOLS.RPLC then local ok = frame.length() >= 2 if ok then local data = frame.data() public.make(data[1], data[2], { table.unpack(data, 3, #data) }) ok = _rplc_type_valid() end ok = ok and type(self.id) == "number" 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 -- get raw to send function public.raw_sendable() return self.raw end -- get this packet as a frame with an immutable relation to this object function public.get() ---@class rplc_frame local frame = { scada_frame = self.frame, id = self.id, type = self.type, length = self.length, data = self.data } return frame end return public end -- SCADA management packet function comms.mgmt_packet() local self = { frame = nil, raw = {}, type = -1, length = 0, data = {} } ---@class mgmt_packet local public = {} -- check that type is known local function _scada_type_valid() return self.type == SCADA_MGMT_TYPES.ESTABLISH or self.type == SCADA_MGMT_TYPES.KEEP_ALIVE or self.type == SCADA_MGMT_TYPES.CLOSE or self.type == SCADA_MGMT_TYPES.REMOTE_LINKED or self.type == SCADA_MGMT_TYPES.RTU_ADVERT or self.type == SCADA_MGMT_TYPES.RTU_DEV_REMOUNT end -- make a SCADA management packet ---@param packet_type SCADA_MGMT_TYPES ---@param data table function public.make(packet_type, data) if type(data) == "table" then -- packet accessor properties self.type = packet_type self.length = #data self.data = data -- populate raw array self.raw = { self.type } for i = 1, #data do insert(self.raw, data[i]) end else log.error("comms.mgmt_packet.make(): data not table") end end -- decode a SCADA management packet from a SCADA frame ---@param frame scada_packet ---@return boolean success function public.decode(frame) if frame then self.frame = frame if frame.protocol() == PROTOCOLS.SCADA_MGMT then local ok = frame.length() >= 1 if ok then local data = frame.data() public.make(data[1], { table.unpack(data, 2, #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 -- get raw to send function public.raw_sendable() return self.raw end -- get this packet as a frame with an immutable relation to this object function public.get() ---@class mgmt_frame local frame = { scada_frame = self.frame, type = self.type, length = self.length, data = self.data } return frame end return public end -- SCADA coordinator packet function comms.crdn_packet() local self = { frame = nil, raw = {}, type = -1, length = 0, data = {} } ---@class crdn_packet local public = {} -- check that type is known local function _crdn_type_valid() return self.type == SCADA_CRDN_TYPES.FAC_BUILDS or self.type == SCADA_CRDN_TYPES.FAC_STATUS or self.type == SCADA_CRDN_TYPES.FAC_CMD or self.type == SCADA_CRDN_TYPES.UNIT_BUILDS or self.type == SCADA_CRDN_TYPES.UNIT_STATUSES or self.type == SCADA_CRDN_TYPES.UNIT_CMD end -- make a coordinator packet ---@param packet_type SCADA_CRDN_TYPES ---@param data table function public.make(packet_type, data) if type(data) == "table" then -- packet accessor properties self.type = packet_type self.length = #data self.data = data -- populate raw array self.raw = { self.type } for i = 1, #data do insert(self.raw, data[i]) end else log.error("comms.crdn_packet.make(): data not table") end end -- decode a coordinator packet from a SCADA frame ---@param frame scada_packet ---@return boolean success function public.decode(frame) if frame then self.frame = frame if frame.protocol() == PROTOCOLS.SCADA_CRDN then local ok = frame.length() >= 1 if ok then local data = frame.data() public.make(data[1], { table.unpack(data, 2, #data) }) ok = _crdn_type_valid() end return ok else log.debug("attempted SCADA_CRDN parse of incorrect protocol " .. frame.protocol(), true) return false end else log.debug("nil frame encountered", true) return false end end -- get raw to send function public.raw_sendable() return self.raw end -- get this packet as a frame with an immutable relation to this object function public.get() ---@class crdn_frame local frame = { scada_frame = self.frame, type = self.type, length = self.length, data = self.data } return frame end return public end -- coordinator API (CAPI) packet -- @todo function comms.capi_packet() local self = { frame = nil, raw = {}, type = -1, length = 0, data = {} } ---@class capi_packet local public = {} local function _capi_type_valid() -- @todo return false end -- make a coordinator API packet ---@param packet_type CAPI_TYPES ---@param data table function public.make(packet_type, data) if type(data) == "table" then -- packet accessor properties self.type = packet_type self.length = #data self.data = data -- populate raw array self.raw = { self.type } for i = 1, #data do insert(self.raw, data[i]) end else log.error("comms.capi_packet.make(): data not table") end end -- decode a coordinator API packet from a SCADA frame ---@param frame scada_packet ---@return boolean success function public.decode(frame) if frame then self.frame = frame if frame.protocol() == PROTOCOLS.COORD_API then local ok = frame.length() >= 1 if ok then local data = frame.data() public.make(data[1], { table.unpack(data, 2, #data) }) ok = _capi_type_valid() end return ok else log.debug("attempted COORD_API parse of incorrect protocol " .. frame.protocol(), true) return false end else log.debug("nil frame encountered", true) return false end end -- get raw to send function public.raw_sendable() return self.raw end -- get this packet as a frame with an immutable relation to this object function public.get() ---@class capi_frame local frame = { scada_frame = self.frame, type = self.type, length = self.length, data = self.data } return frame end return public end -- convert rtu_t to RTU unit type ---@param type rtu_t ---@return RTU_UNIT_TYPES|nil function comms.rtu_t_to_unit_type(type) if type == rtu_t.redstone then return RTU_UNIT_TYPES.REDSTONE elseif type == rtu_t.boiler_valve then return RTU_UNIT_TYPES.BOILER_VALVE elseif type == rtu_t.turbine_valve then return RTU_UNIT_TYPES.TURBINE_VALVE elseif type == rtu_t.induction_matrix then return RTU_UNIT_TYPES.IMATRIX elseif type == rtu_t.sps then return RTU_UNIT_TYPES.SPS elseif type == rtu_t.sna then return RTU_UNIT_TYPES.SNA elseif type == rtu_t.env_detector then return RTU_UNIT_TYPES.ENV_DETECTOR end return nil end -- convert RTU unit type to rtu_t ---@param utype RTU_UNIT_TYPES ---@return rtu_t|nil function comms.advert_type_to_rtu_t(utype) if utype == RTU_UNIT_TYPES.REDSTONE then return rtu_t.redstone elseif utype == RTU_UNIT_TYPES.BOILER_VALVE then return rtu_t.boiler_valve elseif utype == RTU_UNIT_TYPES.TURBINE_VALVE then return rtu_t.turbine_valve elseif utype == RTU_UNIT_TYPES.IMATRIX then return rtu_t.induction_matrix elseif utype == RTU_UNIT_TYPES.SPS then return rtu_t.sps elseif utype == RTU_UNIT_TYPES.SNA then return rtu_t.sna elseif utype == RTU_UNIT_TYPES.ENV_DETECTOR then return rtu_t.env_detector end return nil end return comms