From e6f5ab8ef439a5ee61ec79353fcbd9a0e500591c Mon Sep 17 00:00:00 2001 From: Mikayla Date: Tue, 29 Apr 2025 02:38:42 +0000 Subject: [PATCH] #604 reworked supervisor redstone RTU interface --- scada-common/types.lua | 2 +- supervisor/facility.lua | 2 +- supervisor/session/rsctl.lua | 11 +- supervisor/session/rtu.lua | 43 +++-- supervisor/session/rtu/redstone.lua | 248 +++++++++++++++------------- supervisor/startup.lua | 2 +- supervisor/unit.lua | 2 +- 7 files changed, 176 insertions(+), 134 deletions(-) diff --git a/scada-common/types.lua b/scada-common/types.lua index 0d562a6..bc11516 100644 --- a/scada-common/types.lua +++ b/scada-common/types.lua @@ -125,7 +125,7 @@ function types.new_zero_coordinate() return { x = 0, y = 0, z = 0 } end ---@field type RTU_UNIT_TYPE ---@field index integer|false ---@field reactor integer ----@field rsio IO_PORT[]|nil +---@field rs_conns IO_PORT[][]|nil -- create a new reactor database ---@nodiscard diff --git a/supervisor/facility.lua b/supervisor/facility.lua index f3cf18b..8d22f4b 100644 --- a/supervisor/facility.lua +++ b/supervisor/facility.lua @@ -157,7 +157,7 @@ function facility.new(config) self.rtu_list = { self.redstone, self.induction, self.sps, self.tanks, self.envd } -- init redstone RTU I/O controller - self.io_ctl = rsctl.new(self.redstone) + self.io_ctl = rsctl.new(self.redstone, 0) -- fill blank alarm/tone states for _ = 1, 12 do table.insert(self.test_alarm_states, false) end diff --git a/supervisor/session/rsctl.lua b/supervisor/session/rsctl.lua index b270267..eb84cee 100644 --- a/supervisor/session/rsctl.lua +++ b/supervisor/session/rsctl.lua @@ -9,7 +9,8 @@ local rsctl = {} -- create a new redstone RTU I/O controller ---@nodiscard ---@param redstone_rtus redstone_session[] redstone RTU sessions -function rsctl.new(redstone_rtus) +---@param bank integer I/O bank (unit/facility assignment) to interface with +function rsctl.new(redstone_rtus, bank) ---@class rs_controller local public = {} @@ -18,7 +19,7 @@ function rsctl.new(redstone_rtus) ---@return boolean function public.is_connected(port) for i = 1, #redstone_rtus do - if redstone_rtus[i].get_db().io[port] ~= nil then return true end + if redstone_rtus[i].get_db().io[bank][port] ~= nil then return true end end return false @@ -29,7 +30,7 @@ function rsctl.new(redstone_rtus) ---@param value boolean function public.digital_write(port, value) for i = 1, #redstone_rtus do - local io = redstone_rtus[i].get_db().io[port] + local io = redstone_rtus[i].get_db().io[bank][port] if io ~= nil then io.write(value) end end end @@ -40,7 +41,7 @@ function rsctl.new(redstone_rtus) ---@return boolean|nil function public.digital_read(port) for i = 1, #redstone_rtus do - local io = redstone_rtus[i].get_db().io[port] + local io = redstone_rtus[i].get_db().io[bank][port] if io ~= nil then return io.read() --[[@as boolean|nil]] end end end @@ -52,7 +53,7 @@ function rsctl.new(redstone_rtus) ---@param max number maximum value for scaling 0 to 15 function public.analog_write(port, value, min, max) for i = 1, #redstone_rtus do - local io = redstone_rtus[i].get_db().io[port] + local io = redstone_rtus[i].get_db().io[bank][port] if io ~= nil then io.write(rsio.analog_write(value, min, max)) end end end diff --git a/supervisor/session/rtu.lua b/supervisor/session/rtu.lua index a38559c..4f04a1e 100644 --- a/supervisor/session/rtu.lua +++ b/supervisor/session/rtu.lua @@ -93,7 +93,7 @@ function rtu.new_session(id, s_addr, i_seq_num, in_queue, out_queue, timeout, ad type = self.advert[i][1], index = self.advert[i][2], reactor = self.advert[i][3], - rsio = self.advert[i][4] + rs_conns = self.advert[i][4] } local u_type = unit_advert.type ---@type RTU_UNIT_TYPE|boolean @@ -105,13 +105,19 @@ function rtu.new_session(id, s_addr, i_seq_num, in_queue, out_queue, timeout, ad advert_validator.assert_type_int(unit_advert.reactor) if u_type == RTU_UNIT_TYPE.REDSTONE then - advert_validator.assert_type_table(unit_advert.rsio) + advert_validator.assert_type_table(unit_advert.rs_conns) end if advert_validator.valid() then if util.is_int(unit_advert.index) then advert_validator.assert_min(unit_advert.index, 1) end - advert_validator.assert_min(unit_advert.reactor, 0) - advert_validator.assert_max(unit_advert.reactor, #self.fac_units) + + if unit_advert.reactor == -1 then + advert_validator.assert_type_table(unit_advert.rs_conns) + else + advert_validator.assert_min(unit_advert.reactor, 0) + advert_validator.assert_max(unit_advert.reactor, #self.fac_units) + end + if not advert_validator.valid() then u_type = false end else u_type = false @@ -126,15 +132,32 @@ function rtu.new_session(id, s_addr, i_seq_num, in_queue, out_queue, timeout, ad -- validation fail log.debug(log_tag .. "_handle_advertisement(): advertisement unit validation failure") else - if unit_advert.reactor > 0 then - local target_unit = self.fac_units[unit_advert.reactor] - - -- unit RTUs + if unit_advert.reactor == -1 then + -- redstone RTUs can be used in multiple different assignments if u_type == RTU_UNIT_TYPE.REDSTONE then -- redstone unit = svrs_redstone.new(id, i, unit_advert, self.modbus_q) - if type(unit) ~= "nil" then target_unit.add_redstone(unit) end - elseif u_type == RTU_UNIT_TYPE.BOILER_VALVE then + + -- link this to any subsystems this RTU provides connections for + if type(unit) ~= "nil" then + for assignment, _ in pairs(unit_advert.rs_conns) do + if assignment == 0 then + facility.add_redstone(unit) + elseif assignment > 0 and assignment < #self.fac_units then + self.fac_units[assignment].add_redstone(unit) + else + log.warning(util.c(log_tag, "_handle_advertisement(): unrecognized redstone RTU assignment ", assignment, " ", type_string)) + end + end + end + else + log.warning(util.c(log_tag, "_handle_advertisement(): encountered unsupported multi-assignment RTU type ", type_string)) + end + elseif unit_advert.reactor > 0 then + local target_unit = self.fac_units[unit_advert.reactor] + + -- unit RTUs + if u_type == RTU_UNIT_TYPE.BOILER_VALVE then -- boiler unit = svrs_boilerv.new(id, i, unit_advert, self.modbus_q) if type(unit) ~= "nil" then target_unit.add_boiler(unit) end diff --git a/supervisor/session/rtu/redstone.lua b/supervisor/session/rtu/redstone.lua index ce9d6c4..610432d 100644 --- a/supervisor/session/rtu/redstone.lua +++ b/supervisor/session/rtu/redstone.lua @@ -39,6 +39,9 @@ local PERIODICS = { OUTPUT_SYNC = 200 } +-- create a new block of IO banks (facility, then each unit) +local function new_io_block() return { [0] = {}, {}, {}, {}, {} } end + ---@class dig_phy_entry ---@field phy IO_LVL actual value ---@field req IO_LVL commanded value @@ -74,27 +77,27 @@ function redstone.new(session_id, unit_id, advert, out_queue) next_ir_req = 0, next_hr_sync = 0 }, - ---@class rs_io_list - io_list = { - digital_in = {}, ---@type IO_PORT[] discrete inputs - digital_out = {}, ---@type IO_PORT[] coils - analog_in = {}, ---@type IO_PORT[] input registers - analog_out = {} ---@type IO_PORT[] holding registers + ---@class rs_io_map + io_map = { + digital_in = {}, ---@type { bank: integer, port: IO_PORT }[] discrete inputs + digital_out = {}, ---@type { bank: integer, port: IO_PORT }[] coils + analog_in = {}, ---@type { bank: integer, port: IO_PORT }[] input registers + analog_out = {} ---@type { bank: integer, port: IO_PORT }[] holding registers }, phy_trans = { coils = -1, hold_regs = -1 }, -- last set/read ports (reflecting the current state of the RTU) ---@class rs_io_states phy_io = { - digital_in = {}, ---@type dig_phy_entry[] discrete inputs - digital_out = {}, ---@type dig_phy_entry[] coils - analog_in = {}, ---@type ana_phy_entry[] input registers - analog_out = {} ---@type ana_phy_entry[] holding registers + digital_in = new_io_block(), ---@type dig_phy_entry[][] discrete inputs + digital_out = new_io_block(), ---@type dig_phy_entry[][] coils + analog_in = new_io_block(), ---@type ana_phy_entry[][] input registers + analog_out = new_io_block() ---@type ana_phy_entry[][] holding registers }, ---@class redstone_session_db db = { -- read/write functions for connected I/O - ---@type (rs_db_dig_io|rs_db_ana_io)[] - io = {} + ---@type (rs_db_dig_io|rs_db_ana_io)[][] + io = new_io_block() } } @@ -103,93 +106,91 @@ function redstone.new(session_id, unit_id, advert, out_queue) -- INITIALIZE -- - -- create all ports as disconnected - for _ = 1, #IO_PORT do - table.insert(self.db, IO_LVL.DISCONNECT) - end - -- setup I/O - for i = 1, #advert.rsio do - local port = advert.rsio[i] + for bank = 0, 4 do + for i = 1, #advert.rs_conns[bank] do + local port = advert.rs_conns[bank][i] - if rsio.is_valid_port(port) then - local mode = rsio.get_io_mode(port) + if rsio.is_valid_port(port) then + local mode = rsio.get_io_mode(port) + local io_entry = { bank = bank, port = port } - if mode == IO_MODE.DIGITAL_IN then - self.has_di = true - table.insert(self.io_list.digital_in, port) + if mode == IO_MODE.DIGITAL_IN then + self.has_di = true + table.insert(self.io_map.digital_in, io_entry) - self.phy_io.digital_in[port] = { phy = IO_LVL.FLOATING, req = IO_LVL.FLOATING } + self.phy_io.digital_in[bank][port] = { phy = IO_LVL.FLOATING, req = IO_LVL.FLOATING } - ---@class rs_db_dig_io - local io_f = { - ---@nodiscard - read = function () return rsio.digital_is_active(port, self.phy_io.digital_in[port].phy) end, - write = function () end - } + ---@class rs_db_dig_io + local io_f = { + ---@nodiscard + read = function () return rsio.digital_is_active(port, self.phy_io.digital_in[bank][port].phy) end, + write = function () end + } - self.db.io[port] = io_f - elseif mode == IO_MODE.DIGITAL_OUT then - self.has_do = true - table.insert(self.io_list.digital_out, port) + self.db.io[port] = io_f + elseif mode == IO_MODE.DIGITAL_OUT then + self.has_do = true + table.insert(self.io_map.digital_out, io_entry) - self.phy_io.digital_out[port] = { phy = IO_LVL.FLOATING, req = IO_LVL.FLOATING } + self.phy_io.digital_out[bank][port] = { phy = IO_LVL.FLOATING, req = IO_LVL.FLOATING } - ---@class rs_db_dig_io - local io_f = { - ---@nodiscard - read = function () return rsio.digital_is_active(port, self.phy_io.digital_out[port].phy) end, - ---@param active boolean - write = function (active) - local level = rsio.digital_write_active(port, active) - if level ~= nil then self.phy_io.digital_out[port].req = level end - end - } - - self.db.io[port] = io_f - elseif mode == IO_MODE.ANALOG_IN then - self.has_ai = true - table.insert(self.io_list.analog_in, port) - - self.phy_io.analog_in[port] = { phy = 0, req = 0 } - - ---@class rs_db_ana_io - local io_f = { - ---@nodiscard - ---@return integer - read = function () return self.phy_io.analog_in[port].phy end, - write = function () end - } - - self.db.io[port] = io_f - elseif mode == IO_MODE.ANALOG_OUT then - self.has_ao = true - table.insert(self.io_list.analog_out, port) - - self.phy_io.analog_out[port] = { phy = 0, req = 0 } - - ---@class rs_db_ana_io - local io_f = { - ---@nodiscard - ---@return integer - read = function () return self.phy_io.analog_out[port].phy end, - ---@param value integer - write = function (value) - if value >= 0 and value <= 15 then - self.phy_io.analog_out[port].req = value + ---@class rs_db_dig_io + local io_f = { + ---@nodiscard + read = function () return rsio.digital_is_active(port, self.phy_io.digital_out[bank][port].phy) end, + ---@param active boolean + write = function (active) + local level = rsio.digital_write_active(port, active) + if level ~= nil then self.phy_io.digital_out[bank][port].req = level end end - end - } + } - self.db.io[port] = io_f + self.db.io[port] = io_f + elseif mode == IO_MODE.ANALOG_IN then + self.has_ai = true + table.insert(self.io_map.analog_in, io_entry) + + self.phy_io.analog_in[bank][port] = { phy = 0, req = 0 } + + ---@class rs_db_ana_io + local io_f = { + ---@nodiscard + ---@return integer + read = function () return self.phy_io.analog_in[bank][port].phy end, + write = function () end + } + + self.db.io[port] = io_f + elseif mode == IO_MODE.ANALOG_OUT then + self.has_ao = true + table.insert(self.io_map.analog_out, io_entry) + + self.phy_io.analog_out[bank][port] = { phy = 0, req = 0 } + + ---@class rs_db_ana_io + local io_f = { + ---@nodiscard + ---@return integer + read = function () return self.phy_io.analog_out[bank][port].phy end, + ---@param value integer + write = function (value) + if value >= 0 and value <= 15 then + self.phy_io.analog_out[bank][port].req = value + end + end + } + + self.db.io[port] = io_f + else + -- should be unreachable code, we already validated ports + log.error(util.c(log_tag, "failed to identify advertisement port IO mode (", bank, ":", port, ")"), true) + return nil + end else - -- should be unreachable code, we already validated ports - log.error(util.c(log_tag, "failed to identify advertisement port IO mode (", port, ")"), true) + log.error(util.c(log_tag, "invalid advertisement port (", bank, ":", port, ")"), true) return nil end - else - log.error(util.c(log_tag, "invalid advertisement port (", port, ")"), true) - return nil end end @@ -197,12 +198,12 @@ function redstone.new(session_id, unit_id, advert, out_queue) -- query discrete inputs local function _request_discrete_inputs() - self.session.send_request(TXN_TYPES.DI_READ, MODBUS_FCODE.READ_DISCRETE_INPUTS, { 1, #self.io_list.digital_in }) + self.session.send_request(TXN_TYPES.DI_READ, MODBUS_FCODE.READ_DISCRETE_INPUTS, { 1, #self.io_map.digital_in }) end -- query input registers local function _request_input_registers() - self.session.send_request(TXN_TYPES.INPUT_REG_READ, MODBUS_FCODE.READ_INPUT_REGS, { 1, #self.io_list.analog_in }) + self.session.send_request(TXN_TYPES.INPUT_REG_READ, MODBUS_FCODE.READ_INPUT_REGS, { 1, #self.io_map.analog_in }) end -- write all coil outputs @@ -210,9 +211,9 @@ function redstone.new(session_id, unit_id, advert, out_queue) local params = { 1 } local outputs = self.phy_io.digital_out - for i = 1, #self.io_list.digital_out do - local port = self.io_list.digital_out[i] - table.insert(params, outputs[port].req) + for i = 1, #self.io_map.digital_out do + local entry = self.io_map.digital_out[i] + table.insert(params, outputs[entry.bank][entry.port].req) end self.phy_trans.coils = self.session.send_request(TXN_TYPES.COIL_WRITE, MODBUS_FCODE.WRITE_MUL_COILS, params) @@ -220,7 +221,7 @@ function redstone.new(session_id, unit_id, advert, out_queue) -- read all coil outputs local function _read_coils() - self.session.send_request(TXN_TYPES.COIL_READ, MODBUS_FCODE.READ_COILS, { 1, #self.io_list.digital_out }) + self.session.send_request(TXN_TYPES.COIL_READ, MODBUS_FCODE.READ_COILS, { 1, #self.io_map.digital_out }) end -- write all holding register outputs @@ -228,9 +229,9 @@ function redstone.new(session_id, unit_id, advert, out_queue) local params = { 1 } local outputs = self.phy_io.analog_out - for i = 1, #self.io_list.analog_out do - local port = self.io_list.analog_out[i] - table.insert(params, outputs[port].req) + for i = 1, #self.io_map.analog_out do + local entry = self.io_map.analog_out[i] + table.insert(params, outputs[entry.bank][entry.port].req) end self.phy_trans.hold_regs = self.session.send_request(TXN_TYPES.HOLD_REG_WRITE, MODBUS_FCODE.WRITE_MUL_HOLD_REGS, params) @@ -238,7 +239,7 @@ function redstone.new(session_id, unit_id, advert, out_queue) -- read all holding register outputs local function _read_holding_registers() - self.session.send_request(TXN_TYPES.HOLD_REG_READ, MODBUS_FCODE.READ_MUL_HOLD_REGS, { 1, #self.io_list.analog_out }) + self.session.send_request(TXN_TYPES.HOLD_REG_READ, MODBUS_FCODE.READ_MUL_HOLD_REGS, { 1, #self.io_map.analog_out }) end -- PUBLIC FUNCTIONS -- @@ -259,24 +260,24 @@ function redstone.new(session_id, unit_id, advert, out_queue) end elseif txn_type == TXN_TYPES.DI_READ then -- discrete input read response - if m_pkt.length == #self.io_list.digital_in then + if m_pkt.length == #self.io_map.digital_in then for i = 1, m_pkt.length do - local port = self.io_list.digital_in[i] + local entry = self.io_map.digital_in[i] local value = m_pkt.data[i] - self.phy_io.digital_in[port].phy = value + self.phy_io.digital_in[entry.bank][entry.port].phy = value end else log.debug(log_tag .. "MODBUS transaction reply length mismatch (" .. TXN_TAGS[txn_type] .. ")") end elseif txn_type == TXN_TYPES.INPUT_REG_READ then -- input register read response - if m_pkt.length == #self.io_list.analog_in then + if m_pkt.length == #self.io_map.analog_in then for i = 1, m_pkt.length do - local port = self.io_list.analog_in[i] + local entry = self.io_map.analog_in[i] local value = m_pkt.data[i] - self.phy_io.analog_in[port].phy = value + self.phy_io.analog_in[entry.bank][entry.port].phy = value end else log.debug(log_tag .. "MODBUS transaction reply length mismatch (" .. TXN_TAGS[txn_type] .. ")") @@ -288,15 +289,14 @@ function redstone.new(session_id, unit_id, advert, out_queue) -- update phy I/O table -- if there are multiple outputs for the same port, they will overwrite eachother (but *should* be identical) -- given these are redstone outputs, if one worked they all should have, so no additional verification will be done - if m_pkt.length == #self.io_list.digital_out then + if m_pkt.length == #self.io_map.digital_out then for i = 1, m_pkt.length do - local port = self.io_list.digital_out[i] + local entry = self.io_map.digital_out[i] + local state = self.phy_io.digital_out[entry.bank][entry.port] local value = m_pkt.data[i] - self.phy_io.digital_out[port].phy = value - if self.phy_io.digital_out[port].req == IO_LVL.FLOATING then - self.phy_io.digital_out[port].req = value - end + state.phy = value + if state.req == IO_LVL.FLOATING then state.req = value end end self.phy_trans.coils = TXN_READY @@ -310,12 +310,12 @@ function redstone.new(session_id, unit_id, advert, out_queue) -- update phy I/O table -- if there are multiple outputs for the same port, they will overwrite eachother (but *should* be identical) -- given these are redstone outputs, if one worked they all should have, so no additional verification will be done - if m_pkt.length == #self.io_list.analog_out then + if m_pkt.length == #self.io_map.analog_out then for i = 1, m_pkt.length do - local port = self.io_list.analog_out[i] + local entry = self.io_map.analog_out[i] local value = m_pkt.data[i] - self.phy_io.analog_out[port].phy = value + self.phy_io.analog_out[entry.bank][entry.port].phy = value end else log.debug(log_tag .. "MODBUS transaction reply length mismatch (" .. TXN_TAGS[txn_type] .. ")") @@ -343,8 +343,17 @@ function redstone.new(session_id, unit_id, advert, out_queue) -- sync digital outputs if self.has_do then if (self.periodics.next_cl_sync <= time_now) and (self.phy_trans.coils == TXN_READY) then - for _, entry in pairs(self.phy_io.digital_out) do - if entry.phy ~= entry.req then + for bank = 0, 4 do + local changed = false + + for _, entry in pairs(self.phy_io.digital_out[bank]) do + if entry.phy ~= entry.req then + changed = true + break + end + end + + if changed then _write_coils() break end @@ -365,8 +374,17 @@ function redstone.new(session_id, unit_id, advert, out_queue) -- sync analog outputs if self.has_ao then if (self.periodics.next_hr_sync <= time_now) and (self.phy_trans.hold_regs == TXN_READY) then - for _, entry in pairs(self.phy_io.analog_out) do - if entry.phy ~= entry.req then + for bank = 0, 4 do + local changed = false + + for _, entry in pairs(self.phy_io.analog_out[bank]) do + if entry.phy ~= entry.req then + changed = true + break + end + end + + if changed then _write_holding_registers() break end diff --git a/supervisor/startup.lua b/supervisor/startup.lua index d1ee16d..4a88018 100644 --- a/supervisor/startup.lua +++ b/supervisor/startup.lua @@ -23,7 +23,7 @@ local supervisor = require("supervisor.supervisor") local svsessions = require("supervisor.session.svsessions") -local SUPERVISOR_VERSION = "v1.6.8" +local SUPERVISOR_VERSION = "v1.7.0" local println = util.println local println_ts = util.println_ts diff --git a/supervisor/unit.lua b/supervisor/unit.lua index b448a95..9e22824 100644 --- a/supervisor/unit.lua +++ b/supervisor/unit.lua @@ -258,7 +258,7 @@ function unit.new(reactor_id, num_boilers, num_turbines, ext_idle, aux_coolant) self.rtu_list = { self.redstone, self.boilers, self.turbines, self.tanks, self.snas, self.envd } -- init redstone RTU I/O controller - self.io_ctl = rsctl.new(self.redstone) + self.io_ctl = rsctl.new(self.redstone, reactor_id) -- init boiler table fields for _ = 1, num_boilers do