diff --git a/ccmsi.lua b/ccmsi.lua index 8b8fa6d..19ccebc 100644 --- a/ccmsi.lua +++ b/ccmsi.lua @@ -18,7 +18,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. local function println(message) print(tostring(message)) end local function print(message) term.write(tostring(message)) end -local CCMSI_VERSION = "v1.11a" +local CCMSI_VERSION = "v1.11c" local install_dir = "/.install-cache" local manifest_path = "https://mikaylafischler.github.io/cc-mek-scada/manifests/" @@ -32,6 +32,7 @@ local function red() term.setTextColor(colors.red) end local function orange() term.setTextColor(colors.orange) end local function yellow() term.setTextColor(colors.yellow) end local function green() term.setTextColor(colors.green) end +local function cyan() term.setTextColor(colors.cyan) end local function blue() term.setTextColor(colors.blue) end local function white() term.setTextColor(colors.white) end local function lgray() term.setTextColor(colors.lightGray) end @@ -199,7 +200,7 @@ if #opts == 0 or opts[1] == "help" then println("usage: ccmsi ") println("") lgray() - println(" check - check latest versions avilable") + println(" check - check latest versions available") yellow() println(" ccmsi check for target") lgray() @@ -266,18 +267,16 @@ if mode == "check" then blue();print(local_manifest.versions[key]) if value ~= local_manifest.versions[key] then white();print(" (") - term.setTextColor(colors.cyan) - print(value);white();println(" available)") + cyan();print(value);white();println(" available)") else green();println(" (up to date)") end else lgray();print("not installed");white();print(" (latest ") - term.setTextColor(colors.cyan) - print(value);white();println(")") + cyan();print(value);white();println(")") end end if manifest.versions.installer ~= local_manifest.versions.installer then - yellow();println("\nA newer version of the installer is available, it is recommended to update (use 'ccmsi update installer').");white() + yellow();println("\nA different version of the installer is available, it is recommended to update (use 'ccmsi update installer').");white() end elseif mode == "install" or mode == "update" then local update_installer = app == "installer" @@ -315,7 +314,7 @@ elseif mode == "install" or mode == "update" then end if manifest.versions.installer ~= CCMSI_VERSION then - if not update_installer then yellow();println("A newer version of the installer is available, it is recommended to update to it.");white() end + if not update_installer then yellow();println("A different version of the installer is available, it is recommended to update to it.");white() end if update_installer or ask_y_n("Would you like to update now") then lgray();println("GET ccmsi.lua") local dl, err = http.get(repo_path .. "ccmsi.lua") diff --git a/configure.lua b/configure.lua index 5234f1d..972d16e 100644 --- a/configure.lua +++ b/configure.lua @@ -2,8 +2,8 @@ print("CONFIGURE> SCANNING FOR CONFIGURATOR...") if fs.exists("reactor-plc/configure.lua") then require("reactor-plc.configure").configure() -elseif fs.exists("rtu/startup.lua") then - print("CONFIGURE> RTU CONFIGURATOR NOT YET IMPLEMENTED IN BETA") +elseif fs.exists("rtu/configure.lua") then + require("rtu.configure").configure() elseif fs.exists("supervisor/startup.lua") then print("CONFIGURE> SUPERVISOR CONFIGURATOR NOT YET IMPLEMENTED IN BETA") elseif fs.exists("coordinator/startup.lua") then diff --git a/coordinator/iocontrol.lua b/coordinator/iocontrol.lua index 5ccffae..c8a5f09 100644 --- a/coordinator/iocontrol.lua +++ b/coordinator/iocontrol.lua @@ -469,7 +469,7 @@ function iocontrol.record_unit_builds(builds) -- note: if not all units and RTUs are connected, some will be nil for id, build in pairs(builds) do - local unit = io.units[id] ---@type ioctl_unit + local unit = io.units[id] ---@type ioctl_unit local log_header = util.c("iocontrol.record_unit_builds[UNIT ", id, "]: ") @@ -694,8 +694,8 @@ function iocontrol.update_facility_status(status) for id, sps in pairs(rtu_statuses.sps) do if type(fac.sps_data_tbl[id]) == "table" then - local data = fac.sps_data_tbl[id] ---@type sps_session_db - local ps = fac.sps_ps_tbl[id] ---@type psil + local data = fac.sps_data_tbl[id] ---@type sps_session_db + local ps = fac.sps_ps_tbl[id] ---@type psil local rtu_faulted = _record_multiblock_status(sps, data, ps) @@ -732,8 +732,8 @@ function iocontrol.update_facility_status(status) for id, tank in pairs(rtu_statuses.tanks) do if type(fac.tank_data_tbl[id]) == "table" then - local data = fac.tank_data_tbl[id] ---@type dynamicv_session_db - local ps = fac.tank_ps_tbl[id] ---@type psil + local data = fac.tank_data_tbl[id] ---@type dynamicv_session_db + local ps = fac.tank_ps_tbl[id] ---@type psil local rtu_faulted = _record_multiblock_status(tank, data, ps) @@ -760,20 +760,34 @@ function iocontrol.update_facility_status(status) end -- environment detector status - if type(rtu_statuses.rad_mon) == "table" then - if #rtu_statuses.rad_mon > 0 then - local rad_mon = rtu_statuses.rad_mon[1] - local rtu_faulted = rad_mon[1] ---@type boolean - fac.radiation = rad_mon[2] ---@type number + if type(rtu_statuses.envds) == "table" then + local max_rad, max_reading, any_conn, any_faulted = 0, types.new_zero_radiation_reading(), false, false - fac.ps.publish("rad_computed_status", util.trinary(rtu_faulted, 2, 3)) - fac.ps.publish("radiation", fac.radiation) + for _, envd in pairs(rtu_statuses.envds) do + local rtu_faulted = envd[1] ---@type boolean + local radiation = envd[2] ---@type radiation_reading + local rad_raw = envd[3] ---@type number + + any_conn = true + any_faulted = any_faulted or rtu_faulted + + if rad_raw > max_rad then + max_rad = rad_raw + max_reading = radiation + end + end + + if any_conn then + fac.radiation = max_reading + fac.ps.publish("rad_computed_status", util.trinary(any_faulted, 2, 3)) else fac.radiation = types.new_zero_radiation_reading() fac.ps.publish("rad_computed_status", 1) end + + fac.ps.publish("radiation", fac.radiation) else - log.debug(log_header .. "radiation monitor list not a table") + log.debug(log_header .. "environment detector list not a table") valid = false end else @@ -917,8 +931,8 @@ function iocontrol.update_unit_statuses(statuses) for id, boiler in pairs(rtu_statuses.boilers) do if type(unit.boiler_data_tbl[id]) == "table" then - local data = unit.boiler_data_tbl[id] ---@type boilerv_session_db - local ps = unit.boiler_ps_tbl[id] ---@type psil + local data = unit.boiler_data_tbl[id] ---@type boilerv_session_db + local ps = unit.boiler_ps_tbl[id] ---@type psil local rtu_faulted = _record_multiblock_status(boiler, data, ps) @@ -960,8 +974,8 @@ function iocontrol.update_unit_statuses(statuses) for id, turbine in pairs(rtu_statuses.turbines) do if type(unit.turbine_data_tbl[id]) == "table" then - local data = unit.turbine_data_tbl[id] ---@type turbinev_session_db - local ps = unit.turbine_ps_tbl[id] ---@type psil + local data = unit.turbine_data_tbl[id] ---@type turbinev_session_db + local ps = unit.turbine_ps_tbl[id] ---@type psil local rtu_faulted = _record_multiblock_status(turbine, data, ps) @@ -1033,9 +1047,9 @@ function iocontrol.update_unit_statuses(statuses) -- solar neutron activator status info if type(rtu_statuses.sna) == "table" then - unit.num_snas = rtu_statuses.sna[1] ---@type integer - unit.sna_prod_rate = rtu_statuses.sna[2] ---@type number - unit.sna_peak_rate = rtu_statuses.sna[3] ---@type number + unit.num_snas = rtu_statuses.sna[1] ---@type integer + unit.sna_prod_rate = rtu_statuses.sna[2] ---@type number + unit.sna_peak_rate = rtu_statuses.sna[3] ---@type number unit.unit_ps.publish("sna_count", unit.num_snas) unit.unit_ps.publish("sna_prod_rate", unit.sna_prod_rate) @@ -1048,16 +1062,28 @@ function iocontrol.update_unit_statuses(statuses) end -- environment detector status - if type(rtu_statuses.rad_mon) == "table" then - if #rtu_statuses.rad_mon > 0 then - local rad_mon = rtu_statuses.rad_mon[1] - -- local rtu_faulted = rad_mon[1] ---@type boolean - unit.radiation = rad_mon[2] ---@type number + if type(rtu_statuses.envds) == "table" then + local max_rad, max_reading, any_conn = 0, types.new_zero_radiation_reading(), false - unit.unit_ps.publish("radiation", unit.radiation) + for _, envd in pairs(rtu_statuses.envds) do + local radiation = envd[2] ---@type radiation_reading + local rad_raw = envd[3] ---@type number + + any_conn = true + + if rad_raw > max_rad then + max_rad = rad_raw + max_reading = radiation + end + end + + if any_conn then + unit.radiation = max_reading else unit.radiation = types.new_zero_radiation_reading() end + + unit.unit_ps.publish("radiation", unit.radiation) else log.debug(log_header .. "radiation monitor list not a table") valid = false diff --git a/coordinator/startup.lua b/coordinator/startup.lua index 0ce23ba..7811904 100644 --- a/coordinator/startup.lua +++ b/coordinator/startup.lua @@ -22,7 +22,7 @@ local sounder = require("coordinator.sounder") local apisessions = require("coordinator.session.apisessions") -local COORDINATOR_VERSION = "v1.0.16" +local COORDINATOR_VERSION = "v1.0.17" local println = util.println local println_ts = util.println_ts diff --git a/graphics/core.lua b/graphics/core.lua index d6518ba..437f66f 100644 --- a/graphics/core.lua +++ b/graphics/core.lua @@ -7,7 +7,7 @@ local flasher = require("graphics.flasher") local core = {} -core.version = "2.0.2" +core.version = "2.0.7" core.flasher = flasher core.events = events @@ -173,7 +173,7 @@ function core.new_ifield(e, max_len, fg_bg, dis_fg_bg) if e.enabled then e.w_set_bkg(fg_bg.bkg) e.w_set_fgd(fg_bg.fgd) - else + elseif dis_fg_bg ~= nil then e.w_set_bkg(dis_fg_bg.bkg) e.w_set_fgd(dis_fg_bg.fgd) end diff --git a/graphics/element.lua b/graphics/element.lua index dcdccc4..6debaf4 100644 --- a/graphics/element.lua +++ b/graphics/element.lua @@ -91,6 +91,8 @@ function element.new(args, child_offset_x, child_offset_y) p_window = nil, ---@type table position = events.new_coord_2d(1, 1), bounds = { x1 = 1, y1 = 1, x2 = 1, y2 = 1 }, ---@class element_bounds + offset_x = 0, + offset_y = 0, next_y = 1, -- next child y coordinate next_id = 0, -- next child ID subscriptions = {}, @@ -105,6 +107,7 @@ function element.new(args, child_offset_x, child_offset_y) value = nil, ---@type any window = nil, ---@type table content_window = nil, ---@type table|nil + mouse_window_shift = { x = 0, y = 0 }, fg_bg = core.cpair(colors.white, colors.black), frame = core.gframe(1, 1, 1, 1), children = {}, @@ -193,6 +196,10 @@ function element.new(args, child_offset_x, child_offset_y) ---@param offset_y integer y offset for mouse events ---@param next_y integer next line if no y was provided function protected.prepare_template(offset_x, offset_y, next_y) + -- record offsets in case there is a reposition + self.offset_x = offset_x + self.offset_y = offset_y + -- get frame coordinates/size if args.gframe ~= nil then protected.frame.x = args.gframe.x @@ -344,6 +351,10 @@ function element.new(args, child_offset_x, child_offset_y) -- handle this element having been unfocused function protected.on_unfocused() end + -- handle this element having had a child focused + ---@param child graphics_element + function protected.on_child_focused(child) end + -- handle this element having been shown function protected.on_shown() end @@ -520,6 +531,13 @@ function element.new(args, child_offset_x, child_offset_y) else args.parent.__focus_child(child) end end + -- a child was focused, used to make sure it is actually visible to the user in the content frame + ---@param child graphics_element + function public.__child_focused(child) + protected.on_child_focused(child) + if not self.is_root then args.parent.__child_focused(public) end + end + -- get a child element ---@nodiscard ---@param id element_id @@ -652,6 +670,7 @@ function element.new(args, child_offset_x, child_offset_y) if args.can_focus and protected.enabled and not self.focused then self.focused = true protected.on_focused() + if not self.is_root then args.parent.__child_focused(public) end end end @@ -666,7 +685,7 @@ function element.new(args, child_offset_x, child_offset_y) -- unfocus this element and all its children function public.unfocus_all() public.unfocus() - for _, child in pairs(protected.children) do child.get().unfocus() end + for _, child in pairs(protected.children) do child.get().unfocus_all() end end -- custom recolor command, varies by element if implemented @@ -681,7 +700,22 @@ function element.new(args, child_offset_x, child_offset_y) -- offsets relative to parent frame are where (1, 1) would be on top of the parent's top left corner ---@param x integer x position relative to parent frame ---@param y integer y position relative to parent frame - function public.reposition(x, y) protected.window.reposition(x, y) end + function public.reposition(x, y) + protected.window.reposition(x, y) + + -- record position + self.position.x, self.position.y = protected.window.getPosition() + + -- shift per parent child offset + self.position.x = self.position.x + self.offset_x + self.position.y = self.position.y + self.offset_y + + -- calculate mouse event bounds + self.bounds.x1 = self.position.x + self.bounds.x2 = self.position.x + protected.frame.w - 1 + self.bounds.y1 = self.position.y + self.bounds.y2 = self.position.y + protected.frame.h - 1 + end -- FUNCTION CALLBACKS -- @@ -704,10 +738,11 @@ function element.new(args, child_offset_x, child_offset_y) end local event_T = events.mouse_transposed(event, self.position.x, self.position.y) - - -- handle the mouse event then pass to children protected.handle_mouse(event_T) - for _, child in pairs(protected.children) do child.get().handle_mouse(event_T) end + + -- shift child event if the content window has moved then pass to children + local c_event_T = events.mouse_transposed(event_T, protected.mouse_window_shift.x + 1, protected.mouse_window_shift.y + 1) + for _, child in pairs(protected.children) do child.get().handle_mouse(c_event_T) end elseif event.type == events.MOUSE_CLICK.DOWN or event.type == events.MOUSE_CLICK.TAP then -- clicked out, unfocus this element and children public.unfocus_all() diff --git a/graphics/elements/controls/push_button.lua b/graphics/elements/controls/push_button.lua index 59e2b9e..88c8a5d 100644 --- a/graphics/elements/controls/push_button.lua +++ b/graphics/elements/controls/push_button.lua @@ -5,6 +5,8 @@ local tcd = require("scada-common.tcd") local core = require("graphics.core") local element = require("graphics.element") +local ALIGN = core.ALIGN + local MOUSE_CLICK = core.events.MOUSE_CLICK local KEY_CLICK = core.events.KEY_CLICK @@ -12,6 +14,7 @@ local KEY_CLICK = core.events.KEY_CLICK ---@field text string button text ---@field callback function function to call on touch ---@field min_width? integer text length if omitted +---@field alignment? ALIGN text align if min width > length ---@field active_fg_bg? cpair foreground/background colors when pressed ---@field dis_fg_bg? cpair foreground/background colors when disabled ---@field parent graphics_element @@ -31,6 +34,7 @@ local function push_button(args) element.assert(type(args.min_width) == "nil" or (type(args.min_width) == "number" and args.min_width > 0), "min_width must be nil or a number > 0") local text_width = string.len(args.text) + local alignment = args.alignment or ALIGN.CENTER -- set automatic settings args.can_focus = true @@ -41,9 +45,15 @@ local function push_button(args) -- create new graphics element base object local e = element.new(args) - local h_pad = math.floor((e.frame.w - text_width) / 2) + 1 + local h_pad = 1 local v_pad = math.floor(e.frame.h / 2) + 1 + if alignment == ALIGN.CENTER then + h_pad = math.floor((e.frame.w - text_width) / 2) + 1 + elseif alignment == ALIGN.RIGHT then + h_pad = (e.frame.w - text_width) + 1 + end + -- draw the button function e.redraw() e.window.clear() diff --git a/graphics/elements/form/number_field.lua b/graphics/elements/form/number_field.lua index 53fc473..7e1afd8 100644 --- a/graphics/elements/form/number_field.lua +++ b/graphics/elements/form/number_field.lua @@ -133,6 +133,9 @@ local function number_field(args) elseif type(args.min) == "number" and val < min then e.value = "" .. min ifield.nav_start() + else + e.value = "" .. val + ifield.nav_end() end else e.value = "" diff --git a/graphics/elements/listbox.lua b/graphics/elements/listbox.lua index d138e19..3da9ac6 100644 --- a/graphics/elements/listbox.lua +++ b/graphics/elements/listbox.lua @@ -5,6 +5,7 @@ local tcd = require("scada-common.tcd") local core = require("graphics.core") local element = require("graphics.element") +local KEY_CLICK = core.events.KEY_CLICK local MOUSE_CLICK = core.events.MOUSE_CLICK ---@class listbox_args @@ -33,6 +34,8 @@ local MOUSE_CLICK = core.events.MOUSE_CLICK ---@param args listbox_args ---@return graphics_element element, element_id id local function listbox(args) + args.can_focus = true + -- create new graphics element base object local e = element.new(args) @@ -128,7 +131,7 @@ local function listbox(args) end e.w_set_cur(e.frame.w, i) - e.w_write(" ") + if e.is_focused() then e.w_write("\x7f") else e.w_write(" ") end end e.w_set_bkg(e.fg_bg.bkg) @@ -158,6 +161,9 @@ local function listbox(args) scroll_frame.reposition(1, 1 + scroll_offset) scroll_frame.setVisible(true) + -- shift mouse events + e.mouse_window_shift.y = scroll_offset + draw_bar() end @@ -219,6 +225,32 @@ local function listbox(args) end end + -- handle focus + e.on_focused = draw_bar + e.on_unfocused = draw_bar + + -- handle a child in the list being focused, make sure it is visible + function e.on_child_focused(child) + for i = 1, #list do + local item = list[i] ---@type listbox_item + if item.e == child then + if (item.y + scroll_offset) <= 0 then + scroll_offset = 1 - item.y + update_positions() + draw_bar() + elseif (item.y + scroll_offset) == 1 then + -- do nothing, it's right at the top (if the bottom doesn't fit we can't easily fix that) + elseif ((item.h + item.y - 1) + scroll_offset) > e.frame.h then + scroll_offset = 1 - ((item.h + item.y) - e.frame.h) + update_positions() + draw_bar() + end + + return + end + end + end + -- handle mouse interaction ---@param event mouse_interaction mouse event function e.handle_mouse(event) @@ -226,23 +258,27 @@ local function listbox(args) if event.type == MOUSE_CLICK.TAP then if event.current.x == e.frame.w then if event.current.y == 1 or event.current.y < bar_bounds[1] then - draw_arrows(1) scroll_up() - if args.nav_active ~= nil then tcd.dispatch(0.25, function () draw_arrows(0) end) end + if event.current.y == 1 then + draw_arrows(1) + if args.nav_active ~= nil then tcd.dispatch(0.25, function () draw_arrows(0) end) end + end elseif event.current.y == e.frame.h or event.current.y > bar_bounds[2] then - draw_arrows(-1) scroll_down() - if args.nav_active ~= nil then tcd.dispatch(0.25, function () draw_arrows(0) end) end + if event.current.y == e.frame.h then + draw_arrows(-1) + if args.nav_active ~= nil then tcd.dispatch(0.25, function () draw_arrows(0) end) end + end end end elseif event.type == MOUSE_CLICK.DOWN then if event.current.x == e.frame.w then if event.current.y == 1 or event.current.y < bar_bounds[1] then - draw_arrows(1) scroll_up() + if event.current.y == 1 then draw_arrows(1) end elseif event.current.y == e.frame.h or event.current.y > bar_bounds[2] then - draw_arrows(-1) scroll_down() + if event.current.y == e.frame.h then draw_arrows(-1) end else -- clicked on bar holding_bar = true @@ -274,6 +310,24 @@ local function listbox(args) end end + -- handle keyboard interaction + ---@param event key_interaction key event + function e.handle_key(event) + if event.type == KEY_CLICK.DOWN or event.type == KEY_CLICK.HELD then + if event.key == keys.up then + scroll_up() + elseif event.key == keys.down then + scroll_down() + elseif event.key == keys.home then + scroll_offset = 0 + update_positions() + elseif event.key == keys["end"] then + scroll_offset = max_down_scroll + update_positions() + end + end + end + -- element redraw function e.redraw() draw_arrows(0) diff --git a/imgen.py b/imgen.py index 31a61e0..a854b1f 100644 --- a/imgen.py +++ b/imgen.py @@ -60,7 +60,7 @@ def make_manifest(size): }, "files" : { # common files - "system" : [ "initenv.lua", "startup.lua", "configure.lua" ], + "system" : [ "initenv.lua", "startup.lua", "configure.lua", "LICENSE" ], "common" : list_files("./scada-common"), "graphics" : list_files("./graphics"), "lockbox" : list_files("./lockbox"), diff --git a/reactor-plc/configure.lua b/reactor-plc/configure.lua index 4347a7f..928b082 100644 --- a/reactor-plc/configure.lua +++ b/reactor-plc/configure.lua @@ -5,6 +5,7 @@ local log = require("scada-common.log") local tcd = require("scada-common.tcd") local util = require("scada-common.util") +local rsio = require("scada-common.rsio") local core = require("graphics.core") @@ -23,6 +24,7 @@ local NumberField = require("graphics.elements.form.number_field") local TextField = require("graphics.elements.form.text_field") local println = util.println +local tri = util.trinary local cpair = core.cpair @@ -104,21 +106,24 @@ local tmp_cfg = { ---@class plc_config local ini_cfg = {} +---@class plc_config +local settings_cfg = {} +-- all settings fields, their nice names, and their default values local fields = { - { "Networked", "Networked" }, - { "UnitID", "Unit ID" }, - { "EmerCoolEnable", "Emergency Coolant" }, - { "EmerCoolSide", "Emergency Coolant Side" }, - { "EmerCoolColor", "Emergency Coolant Color" }, - { "SVR_Channel", "SVR Channel" }, - { "PLC_Channel", "PLC Channel" }, - { "ConnTimeout", "Connection Timeout" }, - { "TrustedRange", "Trusted Range" }, - { "AuthKey", "Facility Auth Key" }, - { "LogMode", "Log Mode" }, - { "LogPath", "Log Path" }, - { "LogDebug","Log Debug Messages" } + { "Networked", "Networked", false }, + { "UnitID", "Unit ID", 1 }, + { "EmerCoolEnable", "Emergency Coolant", false }, + { "EmerCoolSide", "Emergency Coolant Side", nil }, + { "EmerCoolColor", "Emergency Coolant Color", nil }, + { "SVR_Channel", "SVR Channel", 16240 }, + { "PLC_Channel", "PLC Channel", 16241 }, + { "ConnTimeout", "Connection Timeout", 5 }, + { "TrustedRange", "Trusted Range", 0 }, + { "AuthKey", "Facility Auth Key" , ""}, + { "LogMode", "Log Mode", log.MODE.APPEND }, + { "LogPath", "Log Path", "/log.txt" }, + { "LogDebug","Log Debug Messages", false } } local side_options = { "Top", "Bottom", "Left", "Right", "Front", "Back" } @@ -126,25 +131,6 @@ local side_options_map = { "top", "bottom", "left", "right", "front", "back" } local color_options = { "Red", "Orange", "Yellow", "Lime", "Green", "Cyan", "Light Blue", "Blue", "Purple", "Magenta", "Pink", "White", "Light Gray", "Gray", "Black", "Brown" } local color_options_map = { colors.red, colors.orange, colors.yellow, colors.lime, colors.green, colors.cyan, colors.lightBlue, colors.blue, colors.purple, colors.magenta, colors.pink, colors.white, colors.lightGray, colors.gray, colors.black, colors.brown } -local color_name_map = { - [colors.red] = "red", - [colors.orange] = "orange", - [colors.yellow] = "yellow", - [colors.lime] = "lime", - [colors.green] = "green", - [colors.cyan] = "cyan", - [colors.lightBlue] = "lightBlue", - [colors.blue] = "blue", - [colors.purple] = "purple", - [colors.magenta] = "magenta", - [colors.pink] = "pink", - [colors.white] = "white", - [colors.lightGray] = "lightGray", - [colors.gray] = "gray", - [colors.black] = "black", - [colors.brown] = "brown" -} - -- convert text representation to index ---@param side string local function side_to_idx(side) @@ -163,20 +149,15 @@ end -- load data from the settings file ---@param target plc_config -local function load_settings(target) - target.Networked = settings.get("Networked", false) - target.UnitID = settings.get("UnitID", 1) - target.EmerCoolEnable = settings.get("EmerCoolEnable", false) - target.EmerCoolSide = settings.get("EmerCoolSide", nil) - target.EmerCoolColor = settings.get("EmerCoolColor", nil) - target.SVR_Channel = settings.get("SVR_Channel", 16240) - target.PLC_Channel = settings.get("PLC_Channel", 16241) - target.ConnTimeout = settings.get("ConnTimeout", 5) - target.TrustedRange = settings.get("TrustedRange", 0) - target.AuthKey = settings.get("AuthKey", "") - target.LogMode = settings.get("LogMode", log.MODE.APPEND) - target.LogPath = settings.get("LogPath", "/log.txt") - target.LogDebug = settings.get("LogDebug", false) +---@param raw boolean? true to not use default values +local function load_settings(target, raw) + for _, v in pairs(fields) do settings.unset(v[1]) end + + local loaded = settings.load("/reactor-plc.settings") + + for _, v in pairs(fields) do target[v[1]] = settings.get(v[1], tri(raw, nil, v[3])) end + + return loaded end -- create the config view @@ -203,16 +184,16 @@ local function config_view(display) local y_start = 5 - TextBox{parent=main_page,x=2,y=2,height=2,text_align=CENTER,text="Welcome to the Reactor PLC configurator! Please select one of the following options."} + TextBox{parent=main_page,x=2,y=2,height=2,text="Welcome to the Reactor PLC configurator! Please select one of the following options."} if tool_ctl.ask_config then - TextBox{parent=main_page,x=2,y=y_start,height=4,width=49,text_align=CENTER,text="Notice: This device has no valid config so the configurator has been automatically started. If you previously had a valid config, you may want to check the Change Log to see what changed.",fg_bg=cpair(colors.red,colors.lightGray)} + TextBox{parent=main_page,x=2,y=y_start,height=4,width=49,text="Notice: This device has no valid config so the configurator has been automatically started. If you previously had a valid config, you may want to check the Change Log to see what changed.",fg_bg=cpair(colors.red,colors.lightGray)} y_start = y_start + 5 end local function view_config() tool_ctl.viewing_config = true - tool_ctl.gen_summary(ini_cfg) + tool_ctl.gen_summary(settings_cfg) tool_ctl.settings_apply.hide(true) main_pane.set_value(5) end @@ -239,10 +220,10 @@ local function config_view(display) local plc_pane = MultiPane{parent=plc_cfg,x=1,y=4,panes={plc_c_1,plc_c_2,plc_c_3,plc_c_4}} - TextBox{parent=plc_cfg,x=1,y=2,height=1,text_align=CENTER,text=" PLC Configuration",fg_bg=cpair(colors.black,colors.orange)} + TextBox{parent=plc_cfg,x=1,y=2,height=1,text=" PLC Configuration",fg_bg=cpair(colors.black,colors.orange)} - TextBox{parent=plc_c_1,x=1,y=1,height=1,text_align=CENTER,text="Would you like to set this PLC as networked?"} - TextBox{parent=plc_c_1,x=1,y=3,height=4,text_align=CENTER,text="If you have a supervisor, select the box. You will later be prompted to select the network configuration. If you instead want to use this as a standalone safety system, don't select the box.",fg_bg=g_lg_fg_bg} + TextBox{parent=plc_c_1,x=1,y=1,height=1,text="Would you like to set this PLC as networked?"} + TextBox{parent=plc_c_1,x=1,y=3,height=4,text="If you have a supervisor, select the box. You will later be prompted to select the network configuration. If you instead want to use this as a standalone safety system, don't select the box.",fg_bg=g_lg_fg_bg} local networked = CheckBox{parent=plc_c_1,x=1,y=8,label="Networked",default=ini_cfg.Networked,box_fg_bg=cpair(colors.orange,colors.black)} @@ -254,13 +235,13 @@ local function config_view(display) PushButton{parent=plc_c_1,x=1,y=14,min_width=6,text="\x1b Back",callback=function()main_pane.set_value(1)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} PushButton{parent=plc_c_1,x=44,y=14,min_width=6,text="Next \x1a",callback=submit_networked,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} - TextBox{parent=plc_c_2,x=1,y=1,height=1,text_align=CENTER,text="Please enter the reactor unit ID for this PLC."} - TextBox{parent=plc_c_2,x=1,y=3,height=3,text_align=CENTER,text="If this is a networked PLC, currently only IDs 1 through 4 are acceptable.",fg_bg=g_lg_fg_bg} + TextBox{parent=plc_c_2,x=1,y=1,height=1,text="Please enter the reactor unit ID for this PLC."} + TextBox{parent=plc_c_2,x=1,y=3,height=3,text="If this is a networked PLC, currently only IDs 1 through 4 are acceptable.",fg_bg=g_lg_fg_bg} - TextBox{parent=plc_c_2,x=1,y=6,height=1,text_align=CENTER,text="Unit #"} + TextBox{parent=plc_c_2,x=1,y=6,height=1,text="Unit #"} local u_id = NumberField{parent=plc_c_2,x=7,y=6,width=5,max_digits=3,default=ini_cfg.UnitID,min=1,fg_bg=bw_fg_bg} - local u_id_err = TextBox{parent=plc_c_2,x=8,y=14,height=1,width=35,text_align=LEFT,text="Please set a unit ID.",fg_bg=cpair(colors.red,colors.lightGray),hidden=true} + local u_id_err = TextBox{parent=plc_c_2,x=8,y=14,height=1,width=35,text="Please set a unit ID.",fg_bg=cpair(colors.red,colors.lightGray),hidden=true} local function submit_id() local unit_id = tonumber(u_id.get_value()) @@ -274,8 +255,8 @@ local function config_view(display) PushButton{parent=plc_c_2,x=1,y=14,min_width=6,text="\x1b Back",callback=function()plc_pane.set_value(1)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} PushButton{parent=plc_c_2,x=44,y=14,min_width=6,text="Next \x1a",callback=submit_id,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} - TextBox{parent=plc_c_3,x=1,y=1,height=4,text_align=CENTER,text="When networked, the supervisor takes care of emergency coolant via RTUs. However, you can configure independent emergency coolant via the PLC. "} - TextBox{parent=plc_c_3,x=1,y=6,height=5,text_align=CENTER,text="This independent control can be used with or without a supervisor. To configure, you would next select the interface of the redstone output connected to one or more mekanism pipes.",fg_bg=g_lg_fg_bg} + TextBox{parent=plc_c_3,x=1,y=1,height=4,text="When networked, the supervisor takes care of emergency coolant via RTUs. However, you can configure independent emergency coolant via the PLC. "} + TextBox{parent=plc_c_3,x=1,y=6,height=5,text="This independent control can be used with or without a supervisor. To configure, you would next select the interface of the redstone output connected to one or more mekanism pipes.",fg_bg=g_lg_fg_bg} local en_em_cool = CheckBox{parent=plc_c_3,x=1,y=11,label="Enable PLC Emergency Coolant Control",default=ini_cfg.EmerCoolEnable,box_fg_bg=cpair(colors.orange,colors.black)} @@ -291,17 +272,17 @@ local function config_view(display) PushButton{parent=plc_c_3,x=1,y=14,min_width=6,text="\x1b Back",callback=function()plc_pane.set_value(2)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} PushButton{parent=plc_c_3,x=44,y=14,min_width=6,text="Next \x1a",callback=submit_en_emcool,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} - TextBox{parent=plc_c_4,x=1,y=1,height=1,text_align=CENTER,text="Emergency Coolant Redstone Output Side"} + TextBox{parent=plc_c_4,x=1,y=1,height=1,text="Emergency Coolant Redstone Output Side"} local side = Radio2D{parent=plc_c_4,x=1,y=2,rows=2,columns=3,default=side_to_idx(ini_cfg.EmerCoolSide),options=side_options,radio_colors=cpair(colors.lightGray,colors.black),select_color=colors.orange} - TextBox{parent=plc_c_4,x=1,y=5,height=1,text_align=CENTER,text="Bundled Redstone Configuration"} + TextBox{parent=plc_c_4,x=1,y=5,height=1,text="Bundled Redstone Configuration"} local bundled = CheckBox{parent=plc_c_4,x=1,y=6,label="Is Bundled?",default=ini_cfg.EmerCoolColor~=nil,box_fg_bg=cpair(colors.orange,colors.black),callback=function(v)tool_ctl.bundled_emcool(v)end} local color = Radio2D{parent=plc_c_4,x=1,y=8,rows=4,columns=4,default=color_to_idx(ini_cfg.EmerCoolColor),options=color_options,radio_colors=cpair(colors.lightGray,colors.black),color_map=color_options_map,disable_color=colors.gray,disable_fg_bg=g_lg_fg_bg} if ini_cfg.EmerCoolColor == nil then color.disable() end local function submit_emcool() tmp_cfg.EmerCoolSide = side_options_map[side.get_value()] - tmp_cfg.EmerCoolColor = color_options_map[color.get_value()] + tmp_cfg.EmerCoolColor = util.trinary(bundled.get_value(), color_options_map[color.get_value()], nil) next_from_plc() end @@ -316,19 +297,19 @@ local function config_view(display) local net_pane = MultiPane{parent=net_cfg,x=1,y=4,panes={net_c_1,net_c_2,net_c_3}} - TextBox{parent=net_cfg,x=1,y=2,height=1,text_align=CENTER,text=" Network Configuration",fg_bg=cpair(colors.black,colors.lightBlue)} + TextBox{parent=net_cfg,x=1,y=2,height=1,text=" Network Configuration",fg_bg=cpair(colors.black,colors.lightBlue)} - TextBox{parent=net_c_1,x=1,y=1,height=1,text_align=CENTER,text="Please set the network channels below."} - TextBox{parent=net_c_1,x=1,y=3,height=4,text_align=CENTER,text="Each of the 5 uniquely named channels, including the 2 below, must be the same for each device in this SCADA network. For multiplayer servers, it is recommended to not use the default channels.",fg_bg=g_lg_fg_bg} + TextBox{parent=net_c_1,x=1,y=1,height=1,text="Please set the network channels below."} + TextBox{parent=net_c_1,x=1,y=3,height=4,text="Each of the 5 uniquely named channels, including the 2 below, must be the same for each device in this SCADA network. For multiplayer servers, it is recommended to not use the default channels.",fg_bg=g_lg_fg_bg} - TextBox{parent=net_c_1,x=1,y=8,height=1,text_align=CENTER,text="Supervisor Channel"} + TextBox{parent=net_c_1,x=1,y=8,height=1,text="Supervisor Channel"} local svr_chan = NumberField{parent=net_c_1,x=1,y=9,width=7,default=ini_cfg.SVR_Channel,min=1,max=65535,fg_bg=bw_fg_bg} - TextBox{parent=net_c_1,x=9,y=9,height=4,text_align=CENTER,text="[SVR_CHANNEL]",fg_bg=g_lg_fg_bg} - TextBox{parent=net_c_1,x=1,y=11,height=1,text_align=CENTER,text="PLC Channel"} + TextBox{parent=net_c_1,x=9,y=9,height=4,text="[SVR_CHANNEL]",fg_bg=g_lg_fg_bg} + TextBox{parent=net_c_1,x=1,y=11,height=1,text="PLC Channel"} local plc_chan = NumberField{parent=net_c_1,x=1,y=12,width=7,default=ini_cfg.PLC_Channel,min=1,max=65535,fg_bg=bw_fg_bg} - TextBox{parent=net_c_1,x=9,y=12,height=4,text_align=CENTER,text="[PLC_CHANNEL]",fg_bg=g_lg_fg_bg} + TextBox{parent=net_c_1,x=9,y=12,height=4,text="[PLC_CHANNEL]",fg_bg=g_lg_fg_bg} - local chan_err = TextBox{parent=net_c_1,x=8,y=14,height=1,width=35,text_align=LEFT,text="",fg_bg=cpair(colors.red,colors.lightGray),hidden=true} + local chan_err = TextBox{parent=net_c_1,x=8,y=14,height=1,width=35,text="",fg_bg=cpair(colors.red,colors.lightGray),hidden=true} local function submit_channels() local svr_c = tonumber(svr_chan.get_value()) @@ -350,16 +331,16 @@ local function config_view(display) PushButton{parent=net_c_1,x=1,y=14,min_width=6,text="\x1b Back",callback=function()main_pane.set_value(2)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} PushButton{parent=net_c_1,x=44,y=14,min_width=6,text="Next \x1a",callback=submit_channels,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} - TextBox{parent=net_c_2,x=1,y=1,height=1,text_align=CENTER,text="Connection Timeout"} + TextBox{parent=net_c_2,x=1,y=1,height=1,text="Connection Timeout"} local timeout = NumberField{parent=net_c_2,x=1,y=2,width=7,default=ini_cfg.ConnTimeout,min=2,max=25,fg_bg=bw_fg_bg} - TextBox{parent=net_c_2,x=9,y=2,height=2,text_align=CENTER,text="seconds (default 5)",fg_bg=g_lg_fg_bg} - TextBox{parent=net_c_2,x=1,y=3,height=4,text_align=CENTER,text="You generally do not want or need to modify this. On slow servers, you can increase this to make the system wait longer before assuming a disconnection.",fg_bg=g_lg_fg_bg} + TextBox{parent=net_c_2,x=9,y=2,height=2,text="seconds (default 5)",fg_bg=g_lg_fg_bg} + TextBox{parent=net_c_2,x=1,y=3,height=4,text="You generally do not want or need to modify this. On slow servers, you can increase this to make the system wait longer before assuming a disconnection.",fg_bg=g_lg_fg_bg} - TextBox{parent=net_c_2,x=1,y=8,height=1,text_align=CENTER,text="Trusted Range"} + TextBox{parent=net_c_2,x=1,y=8,height=1,text="Trusted Range"} local range = NumberField{parent=net_c_2,x=1,y=9,width=10,default=ini_cfg.TrustedRange,min=0,max_digits=20,allow_decimal=true,fg_bg=bw_fg_bg} - TextBox{parent=net_c_2,x=1,y=10,height=4,text_align=CENTER,text="Setting this to a value larger than 0 prevents connections with devices that many meters (blocks) away in any direction.",fg_bg=g_lg_fg_bg} + TextBox{parent=net_c_2,x=1,y=10,height=4,text="Setting this to a value larger than 0 prevents connections with devices that many meters (blocks) away in any direction.",fg_bg=g_lg_fg_bg} - local p2_err = TextBox{parent=net_c_2,x=8,y=14,height=1,width=35,text_align=LEFT,text="",fg_bg=cpair(colors.red,colors.lightGray),hidden=true} + local p2_err = TextBox{parent=net_c_2,x=8,y=14,height=1,width=35,text="",fg_bg=cpair(colors.red,colors.lightGray),hidden=true} local function submit_ct_tr() local timeout_val = tonumber(timeout.get_value()) @@ -381,10 +362,10 @@ local function config_view(display) PushButton{parent=net_c_2,x=1,y=14,min_width=6,text="\x1b Back",callback=function()net_pane.set_value(1)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} PushButton{parent=net_c_2,x=44,y=14,min_width=6,text="Next \x1a",callback=submit_ct_tr,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} - TextBox{parent=net_c_3,x=1,y=1,height=2,text_align=CENTER,text="Optionally, set the facility authentication key below. Do NOT use one of your passwords."} - TextBox{parent=net_c_3,x=1,y=4,height=6,text_align=CENTER,text="This enables verifying that messages are authentic, so it is intended for security on multiplayer servers. All devices on the same network MUST use the same key if any device has a key. This does result in some extra compution (can slow things down).",fg_bg=g_lg_fg_bg} + TextBox{parent=net_c_3,x=1,y=1,height=2,text="Optionally, set the facility authentication key below. Do NOT use one of your passwords."} + TextBox{parent=net_c_3,x=1,y=4,height=6,text="This enables verifying that messages are authentic, so it is intended for security on multiplayer servers. All devices on the same network MUST use the same key if any device has a key. This does result in some extra compution (can slow things down).",fg_bg=g_lg_fg_bg} - TextBox{parent=net_c_3,x=1,y=11,height=1,text_align=CENTER,text="Facility Auth Key"} + TextBox{parent=net_c_3,x=1,y=11,height=1,text="Facility Auth Key"} local key, _, censor = TextField{parent=net_c_3,x=1,y=12,max_len=64,value=ini_cfg.AuthKey,width=32,height=1,fg_bg=bw_fg_bg} local function censor_key(enable) censor(util.trinary(enable, "*", nil)) end @@ -394,7 +375,7 @@ local function config_view(display) hide_key.set_value(true) censor_key(true) - local key_err = TextBox{parent=net_c_3,x=8,y=14,height=1,width=35,text_align=LEFT,text="Key must be at least 8 characters.",fg_bg=cpair(colors.red,colors.lightGray),hidden=true} + local key_err = TextBox{parent=net_c_3,x=8,y=14,height=1,width=35,text="Key must be at least 8 characters.",fg_bg=cpair(colors.red,colors.lightGray),hidden=true} local function submit_auth() local v = key.get_value() @@ -412,20 +393,20 @@ local function config_view(display) local log_c_1 = Div{parent=log_cfg,x=2,y=4,width=49} - TextBox{parent=log_cfg,x=1,y=2,height=1,text_align=CENTER,text=" Logging Configuration",fg_bg=cpair(colors.black,colors.pink)} + TextBox{parent=log_cfg,x=1,y=2,height=1,text=" Logging Configuration",fg_bg=cpair(colors.black,colors.pink)} - TextBox{parent=log_c_1,x=1,y=1,height=1,text_align=CENTER,text="Please configure logging below."} + TextBox{parent=log_c_1,x=1,y=1,height=1,text="Please configure logging below."} - TextBox{parent=log_c_1,x=1,y=3,height=1,text_align=CENTER,text="Log File Mode"} + TextBox{parent=log_c_1,x=1,y=3,height=1,text="Log File Mode"} local mode = RadioButton{parent=log_c_1,x=1,y=4,default=ini_cfg.LogMode+1,options={"Append on Startup","Replace on Startup"},callback=function()end,radio_colors=cpair(colors.lightGray,colors.black),select_color=colors.pink} - TextBox{parent=log_c_1,x=1,y=7,height=1,text_align=CENTER,text="Log File Path"} + TextBox{parent=log_c_1,x=1,y=7,height=1,text="Log File Path"} local path = TextField{parent=log_c_1,x=1,y=8,width=49,height=1,value=ini_cfg.LogPath,max_len=128,fg_bg=bw_fg_bg} local en_dbg = CheckBox{parent=log_c_1,x=1,y=10,default=ini_cfg.LogDebug,label="Enable Logging Debug Messages",box_fg_bg=cpair(colors.pink,colors.black)} - TextBox{parent=log_c_1,x=3,y=11,height=2,text_align=CENTER,text="This results in much larger log files. It is best to only use this when there is a problem.",fg_bg=g_lg_fg_bg} + TextBox{parent=log_c_1,x=3,y=11,height=2,text="This results in much larger log files. It is best to only use this when there is a problem.",fg_bg=g_lg_fg_bg} - local path_err = TextBox{parent=log_c_1,x=8,y=14,height=1,width=35,text_align=LEFT,text="Please provide a log file path.",fg_bg=cpair(colors.red,colors.lightGray),hidden=true} + local path_err = TextBox{parent=log_c_1,x=8,y=14,height=1,width=35,text="Please provide a log file path.",fg_bg=cpair(colors.red,colors.lightGray),hidden=true} local function submit_log() if path.get_value() ~= "" then @@ -457,7 +438,7 @@ local function config_view(display) local sum_pane = MultiPane{parent=summary,x=1,y=4,panes={sum_c_1,sum_c_2,sum_c_3,sum_c_4}} - TextBox{parent=summary,x=1,y=2,height=1,text_align=CENTER,text=" Summary",fg_bg=cpair(colors.black,colors.green)} + TextBox{parent=summary,x=1,y=2,height=1,text=" Summary",fg_bg=cpair(colors.black,colors.green)} local setting_list = ListBox{parent=sum_c_1,x=1,y=1,height=12,width=51,scroll_height=100,fg_bg=bw_fg_bg,nav_fg_bg=g_lg_fg_bg,nav_active=cpair(colors.black,colors.gray)} @@ -483,6 +464,7 @@ local function config_view(display) if settings.save("reactor-plc.settings") then load_settings(ini_cfg) + load_settings(settings_cfg, true) try_set(networked, ini_cfg.Networked) try_set(u_id, ini_cfg.UnitID) @@ -499,6 +481,8 @@ local function config_view(display) try_set(path, ini_cfg.LogPath) try_set(en_dbg, ini_cfg.LogDebug) + tool_ctl.view_cfg.enable() + if tool_ctl.importing_legacy then tool_ctl.importing_legacy = false sum_pane.set_value(3) @@ -514,7 +498,7 @@ local function config_view(display) tool_ctl.show_key_btn = PushButton{parent=sum_c_1,x=8,y=14,min_width=17,text="Unhide Auth Key",callback=function()tool_ctl.show_auth_key()end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg,dis_fg_bg=cpair(colors.lightGray,colors.white)} tool_ctl.settings_apply = PushButton{parent=sum_c_1,x=43,y=14,min_width=7,text="Apply",callback=save_and_continue,fg_bg=cpair(colors.black,colors.green),active_fg_bg=btn_act_fg_bg} - TextBox{parent=sum_c_2,x=1,y=1,height=1,text_align=CENTER,text="Settings saved!"} + TextBox{parent=sum_c_2,x=1,y=1,height=1,text="Settings saved!"} local function go_home() main_pane.set_value(1) @@ -526,7 +510,7 @@ local function config_view(display) PushButton{parent=sum_c_2,x=1,y=14,min_width=6,text="Home",callback=go_home,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} PushButton{parent=sum_c_2,x=44,y=14,min_width=6,text="Exit",callback=exit,fg_bg=cpair(colors.black,colors.red),active_fg_bg=cpair(colors.white,colors.gray)} - TextBox{parent=sum_c_3,x=1,y=1,height=2,text_align=CENTER,text="The old config.lua file will now be deleted, then the configurator will exit."} + TextBox{parent=sum_c_3,x=1,y=1,height=2,text="The old config.lua file will now be deleted, then the configurator will exit."} local function delete_legacy() fs.delete("/reactor-plc/config.lua") @@ -536,8 +520,7 @@ local function config_view(display) PushButton{parent=sum_c_3,x=1,y=14,min_width=8,text="Cancel",callback=go_home,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} PushButton{parent=sum_c_3,x=44,y=14,min_width=6,text="OK",callback=delete_legacy,fg_bg=cpair(colors.black,colors.green),active_fg_bg=cpair(colors.white,colors.gray)} - TextBox{parent=sum_c_4,x=1,y=1,height=5,text_align=CENTER,text="Failed to save the settings file.\n\nThere may not be enough space for the modification or server file permissions may be denying writes."} - + TextBox{parent=sum_c_4,x=1,y=1,height=5,text="Failed to save the settings file.\n\nThere may not be enough space for the modification or server file permissions may be denying writes."} PushButton{parent=sum_c_4,x=1,y=14,min_width=6,text="Home",callback=go_home,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} PushButton{parent=sum_c_4,x=44,y=14,min_width=6,text="Exit",callback=exit,fg_bg=cpair(colors.black,colors.red),active_fg_bg=cpair(colors.white,colors.gray)} @@ -545,7 +528,7 @@ local function config_view(display) local cl = Div{parent=changelog,x=2,y=4,width=49} - TextBox{parent=changelog,x=1,y=2,height=1,text_align=CENTER,text=" Config Change Log",fg_bg=bw_fg_bg} + TextBox{parent=changelog,x=1,y=2,height=1,text=" Config Change Log",fg_bg=bw_fg_bg} local c_log = ListBox{parent=cl,x=1,y=1,height=12,width=51,scroll_height=100,fg_bg=bw_fg_bg,nav_fg_bg=g_lg_fg_bg,nav_active=cpair(colors.black,colors.gray)} @@ -627,8 +610,8 @@ local function config_view(display) if f[1] == "AuthKey" then val = string.rep("*", string.len(val)) end if f[1] == "LogMode" then val = util.trinary(raw == log.MODE.APPEND, "append", "replace") end - if f[1] == "EmerCoolColor" and raw ~= nil then val = color_name_map[raw] end - if val == "nil" then val = "n/a" end + if f[1] == "EmerCoolColor" and raw ~= nil then val = rsio.color_name(raw) end + if val == "nil" then val = "" end local c = util.trinary(alternate, g_lg_fg_bg, cpair(colors.gray,colors.white)) alternate = not alternate @@ -665,9 +648,8 @@ end ---@param ask_config? boolean indicate if this is being called by the PLC startup app due to an invalid configuration function configurator.configure(ask_config) tool_ctl.ask_config = ask_config == true - tool_ctl.has_config = settings.load("/reactor-plc.settings") - - load_settings(ini_cfg) + tool_ctl.has_config = load_settings(ini_cfg) + load_settings(settings_cfg, true) reset_term() @@ -685,18 +667,14 @@ function configurator.configure(ask_config) -- handle event if event == "timer" then - -- notify timer callback dispatcher tcd.handle(param1) elseif event == "mouse_click" or event == "mouse_up" or event == "mouse_drag" or event == "mouse_scroll" or event == "double_click" then - -- handle a mouse event local m_e = core.events.new_mouse_event(event, param1, param2, param3) if m_e then display.handle_mouse(m_e) end elseif event == "char" or event == "key" or event == "key_up" then - -- handle a key event local k_e = core.events.new_key_event(event, param1, param2) if k_e then display.handle_key(k_e) end elseif event == "paste" then - -- handle a paste event display.handle_paste(param1) end diff --git a/reactor-plc/startup.lua b/reactor-plc/startup.lua index 9f6a8a9..e8705a2 100644 --- a/reactor-plc/startup.lua +++ b/reactor-plc/startup.lua @@ -18,7 +18,7 @@ local plc = require("reactor-plc.plc") local renderer = require("reactor-plc.renderer") local threads = require("reactor-plc.threads") -local R_PLC_VERSION = "v1.6.2" +local R_PLC_VERSION = "v1.6.4" local println = util.println local println_ts = util.println_ts diff --git a/rtu/config.lua b/rtu/config.lua deleted file mode 100644 index 98ca5df..0000000 --- a/rtu/config.lua +++ /dev/null @@ -1,73 +0,0 @@ -local rsio = require("scada-common.rsio") - -local config = {} - --- supervisor comms channel -config.SVR_CHANNEL = 16240 --- RTU/MODBUS comms channel -config.RTU_CHANNEL = 16242 --- max trusted modem message distance (0 to disable check) -config.TRUSTED_RANGE = 0 --- time in seconds (>= 2) before assuming a remote device is no longer active -config.COMMS_TIMEOUT = 5 --- facility authentication key (do NOT use one of your passwords) --- this enables verifying that messages are authentic --- all devices on the same network must use the same key --- config.AUTH_KEY = "SCADAfacility123" - --- alarm sounder volume (0.0 to 3.0, 1.0 being standard max volume, this is the option given to to speaker.play()) --- note: alarm sine waves are at half saturation, so that multiple will be required to reach full scale -config.SOUNDER_VOLUME = 1.0 - --- log path -config.LOG_PATH = "/log.txt" --- log mode --- 0 = APPEND (adds to existing file on start) --- 1 = NEW (replaces existing file on start) -config.LOG_MODE = 0 --- true to log verbose debug messages -config.LOG_DEBUG = false - --- RTU peripheral devices (named: side/network device name) -config.RTU_DEVICES = { - { - name = "boilerValve_0", - index = 1, - for_reactor = 1 - }, - { - name = "turbineValve_0", - index = 1, - for_reactor = 1 - } -} --- RTU redstone interface definitions -config.RTU_REDSTONE = { - -- { - -- for_reactor = 1, - -- io = { - -- { - -- port = rsio.IO.WASTE_PO, - -- side = "top", - -- bundled_color = colors.red - -- }, - -- { - -- port = rsio.IO.WASTE_PU, - -- side = "top", - -- bundled_color = colors.orange - -- }, - -- { - -- port = rsio.IO.WASTE_POPL, - -- side = "top", - -- bundled_color = colors.yellow - -- }, - -- { - -- port = rsio.IO.WASTE_AM, - -- side = "top", - -- bundled_color = colors.lime - -- } - -- } - -- } -} - -return config diff --git a/rtu/configure.lua b/rtu/configure.lua new file mode 100644 index 0000000..c674826 --- /dev/null +++ b/rtu/configure.lua @@ -0,0 +1,1505 @@ +-- +-- Configuration GUI +-- + +local log = require("scada-common.log") +local rsio = require("scada-common.rsio") +local tcd = require("scada-common.tcd") +local util = require("scada-common.util") +local ppm = require("scada-common.ppm") + +local core = require("graphics.core") + +local DisplayBox = require("graphics.elements.displaybox") +local Div = require("graphics.elements.div") +local ListBox = require("graphics.elements.listbox") +local MultiPane = require("graphics.elements.multipane") +local TextBox = require("graphics.elements.textbox") + +local CheckBox = require("graphics.elements.controls.checkbox") +local PushButton = require("graphics.elements.controls.push_button") +local Radio2D = require("graphics.elements.controls.radio_2d") +local RadioButton = require("graphics.elements.controls.radio_button") + +local NumberField = require("graphics.elements.form.number_field") +local TextField = require("graphics.elements.form.text_field") + +local println = util.println +local tri = util.trinary + +local cpair = core.cpair + +local IO = rsio.IO + +local LEFT = core.ALIGN.LEFT +local CENTER = core.ALIGN.CENTER +local RIGHT = core.ALIGN.RIGHT + +-- rsio port descriptions +local PORT_DESC = { + "Facility SCRAM", + "Facility Acknowledge", + "Reactor SCRAM", + "Reactor RPS Reset", + "Reactor Enable", + "Unit Acknowledge", + "Facility Alarm (high prio)", + "Facility Alarm (any)", + "Waste Plutonium Valve", + "Waste Polonium Valve", + "Waste Po Pellets Valve", + "Waste Antimatter Valve", + "Reactor Active", + "Reactor in Auto Control", + "RPS Tripped", + "RPS Auto SCRAM", + "RPS High Damage", + "RPS High Temperature", + "RPS Low Coolant", + "RPS Excess Heated Coolant", + "RPS Excess Waste", + "RPS Insufficient Fuel", + "RPS PLC Fault", + "RPS Supervisor Timeout", + "Unit Alarm", + "Unit Emergency Cool. Valve" +} + +-- designation (0 = facility, 1 = unit) +local PORT_DSGN = { [-1] = 1, 0, 0, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 } + +assert(#PORT_DESC == rsio.NUM_PORTS) +assert(#PORT_DSGN == rsio.NUM_PORTS) + +-- changes to the config data/format to let the user know +local changes = {} + +---@class rtu_rs_definition +---@field unit integer|nil +---@field port IO_PORT +---@field side side +---@field color color|nil + +---@class rtu_peri_definition +---@field unit integer|nil +---@field index integer|nil +---@field name string + +local RTU_DEV_TYPES = { "boilerValve", "turbineValve", "dynamicValve", "inductionPort", "spsPort", "solarNeutronActivator", "environmentDetector" } +local NEEDS_UNIT = { "boilerValve", "turbineValve", "dynamicValve", "solarNeutronActivator", "environmentDetector" } + +---@class rtu_configurator +local configurator = {} + +local style = {} + +style.root = cpair(colors.black, colors.lightGray) +style.header = cpair(colors.white, colors.gray) + +style.colors = { + { c = colors.red, hex = 0xdf4949 }, + { c = colors.orange, hex = 0xffb659 }, + { c = colors.yellow, hex = 0xfffc79 }, + { c = colors.lime, hex = 0x80ff80 }, + { c = colors.green, hex = 0x4aee8a }, + { c = colors.cyan, hex = 0x34bac8 }, + { c = colors.lightBlue, hex = 0x6cc0f2 }, + { c = colors.blue, hex = 0x0096ff }, + { c = colors.purple, hex = 0xb156ee }, + { c = colors.pink, hex = 0xf26ba2 }, + { c = colors.magenta, hex = 0xf9488a }, + { c = colors.lightGray, hex = 0xcacaca }, + { c = colors.gray, hex = 0x575757 } +} + +local bw_fg_bg = cpair(colors.black, colors.white) +local g_lg_fg_bg = cpair(colors.gray, colors.lightGray) +local nav_fg_bg = bw_fg_bg +local btn_act_fg_bg = cpair(colors.white, colors.gray) + +local tool_ctl = { + ask_config = false, + has_config = false, + viewing_config = false, + importing_legacy = false, + importing_any_dc = false, + peri_cfg_editing = false, ---@type string|false + peri_cfg_manual = false, + rs_cfg_editing = false, ---@type integer|false + + view_gw_cfg = nil, ---@type graphics_element + dev_cfg = nil, ---@type graphics_element + rs_cfg = nil, ---@type graphics_element + settings_apply = nil, ---@type graphics_element + settings_confirm = nil, ---@type graphics_element + + go_home = nil, ---@type function + gen_summary = nil, ---@type function + show_current_cfg = nil, ---@type function + load_legacy = nil, ---@type function + p_assign = nil, ---@type function + update_peri_list = nil, ---@type function + gen_peri_summary = nil, ---@type function + gen_rs_summary = nil, ---@type function + + show_auth_key = nil, ---@type function + show_key_btn = nil, ---@type graphics_element + auth_key_textbox = nil, ---@type graphics_element + auth_key_value = "", + + ppm_devs = nil, ---@type graphics_element + p_name_msg = nil, ---@type graphics_element + p_prompt = nil, ---@type graphics_element + p_idx = nil, ---@type graphics_element + p_unit = nil, ---@type graphics_element + p_assign_btn = nil, ---@type graphics_element + p_assign_end = nil, ---@type graphics_element + p_desc = nil, ---@type graphics_element + p_desc_ext = nil, ---@type graphics_element + p_err = nil, ---@type graphics_element + + rs_cfg_selection = nil, ---@type graphics_element + rs_cfg_unit_l = nil, ---@type graphics_element + rs_cfg_unit = nil, ---@type graphics_element + rs_cfg_color = nil, ---@type graphics_element + rs_cfg_shortcut = nil ---@type graphics_element +} + +---@class rtu_config +local tmp_cfg = { + SpeakerVolume = 1.0, + Peripherals = {}, + Redstone = {}, + SVR_Channel = nil, + RTU_Channel = nil, + ConnTimeout = nil, + TrustedRange = nil, + AuthKey = nil, + LogMode = 0, + LogPath = "", + LogDebug = false +} + +---@class rtu_config +local ini_cfg = {} +---@class rtu_config +local settings_cfg = {} + +local fields = { + { "SpeakerVolume", "Speaker Volume", 1.0 }, + { "SVR_Channel", "SVR Channel", 16240 }, + { "RTU_Channel", "RTU Channel", 16242 }, + { "ConnTimeout", "Connection Timeout", 5 }, + { "TrustedRange", "Trusted Range", 0 }, + { "AuthKey", "Facility Auth Key", "" }, + { "LogMode", "Log Mode", log.MODE.APPEND }, + { "LogPath", "Log Path", "/log.txt" }, + { "LogDebug","Log Debug Messages", false } +} + +local side_options = { "Top", "Bottom", "Left", "Right", "Front", "Back" } +local side_options_map = { "top", "bottom", "left", "right", "front", "back" } +local color_options = { "Red", "Orange", "Yellow", "Lime", "Green", "Cyan", "Light Blue", "Blue", "Purple", "Magenta", "Pink", "White", "Light Gray", "Gray", "Black", "Brown" } +local color_options_map = { colors.red, colors.orange, colors.yellow, colors.lime, colors.green, colors.cyan, colors.lightBlue, colors.blue, colors.purple, colors.magenta, colors.pink, colors.white, colors.lightGray, colors.gray, colors.black, colors.brown } + +-- convert text representation to index +---@param side string +local function side_to_idx(side) + for k, v in ipairs(side_options_map) do + if v == side then return k end + end +end + +-- convert color to index +---@param color color +local function color_to_idx(color) + for k, v in ipairs(color_options_map) do + if v == color then return k end + end +end + +-- deep copy peripherals defs +local function deep_copy_peri(data) + local array = {} + for _, d in ipairs(data) do table.insert(array, { unit = d.unit, index = d.index, name = d.name }) end + return array +end + +-- deep copy redstone defs +local function deep_copy_rs(data) + local array = {} + for _, d in ipairs(data) do table.insert(array, { unit = d.unit, port = d.port, side = d.side, color = d.color }) end + return array +end + +-- load data from the settings file +---@param target rtu_config +---@param raw boolean? true to not use default values +local function load_settings(target, raw) + for k, _ in pairs(tmp_cfg) do settings.unset(k) end + + local loaded = settings.load("/rtu.settings") + + for _, v in pairs(fields) do target[v[1]] = settings.get(v[1], tri(raw, nil, v[3])) end + + target.Peripherals = settings.get("Peripherals", tri(raw, nil, {})) + target.Redstone = settings.get("Redstone", tri(raw, nil, {})) + + return loaded +end + +-- create the config view +---@param display graphics_element +local function config_view(display) +---@diagnostic disable-next-line: undefined-field + local function exit() os.queueEvent("terminate") end + + TextBox{parent=display,y=1,text="RTU Gateway Configurator",alignment=CENTER,height=1,fg_bg=style.header} + + local root_pane_div = Div{parent=display,x=1,y=2} + + local main_page = Div{parent=root_pane_div,x=1,y=1} + local spkr_cfg = Div{parent=root_pane_div,x=1,y=1} + local net_cfg = Div{parent=root_pane_div,x=1,y=1} + local log_cfg = Div{parent=root_pane_div,x=1,y=1} + local summary = Div{parent=root_pane_div,x=1,y=1} + local changelog = Div{parent=root_pane_div,x=1,y=1} + local peri_cfg = Div{parent=root_pane_div,x=1,y=1} + local rs_cfg = Div{parent=root_pane_div,x=1,y=1} + + local main_pane = MultiPane{parent=root_pane_div,x=1,y=1,panes={main_page,spkr_cfg,net_cfg,log_cfg,summary,changelog,peri_cfg,rs_cfg}} + + --#region MAIN PAGE + + local y_start = 2 + + if tool_ctl.ask_config then + TextBox{parent=main_page,x=2,y=y_start,height=4,width=49,text="Notice: This device has no valid config so the configurator has been automatically started. If you previously had a valid config, you may want to check the Change Log to see what changed.",fg_bg=cpair(colors.red,colors.lightGray)} + y_start = y_start + 5 + else + TextBox{parent=main_page,x=2,y=2,height=2,text="Welcome to the RTU gateway configurator! Please select one of the following options."} + y_start = y_start + 3 + end + + local function view_config() + tool_ctl.viewing_config = true + tool_ctl.gen_summary(settings_cfg) + tool_ctl.settings_apply.hide(true) + tool_ctl.settings_confirm.hide(true) + main_pane.set_value(5) + end + + if fs.exists("/rtu/config.lua") then + PushButton{parent=main_page,x=2,y=y_start,min_width=28,text="Import Legacy 'config.lua'",callback=function()tool_ctl.load_legacy()end,fg_bg=cpair(colors.black,colors.cyan),active_fg_bg=btn_act_fg_bg} + y_start = y_start + 2 + end + + local function show_peri_conns() + tool_ctl.gen_peri_summary(ini_cfg) + main_pane.set_value(7) + end + + local function show_rs_conns() + tool_ctl.gen_rs_summary(ini_cfg) + main_pane.set_value(8) + end + + PushButton{parent=main_page,x=2,y=y_start,min_width=19,text="Configure Gateway",callback=function()main_pane.set_value(2)end,fg_bg=cpair(colors.black,colors.blue),active_fg_bg=btn_act_fg_bg} + tool_ctl.view_gw_cfg = PushButton{parent=main_page,x=2,y=y_start+2,min_width=28,text="View Gateway Configuration",callback=view_config,fg_bg=cpair(colors.black,colors.blue),active_fg_bg=btn_act_fg_bg,dis_fg_bg=cpair(colors.lightGray,colors.white)} + tool_ctl.dev_cfg = PushButton{parent=main_page,x=2,y=y_start+4,min_width=24,text="Peripheral Connections",callback=show_peri_conns,fg_bg=cpair(colors.black,colors.yellow),active_fg_bg=btn_act_fg_bg,dis_fg_bg=cpair(colors.lightGray,colors.white)} + tool_ctl.rs_cfg = PushButton{parent=main_page,x=2,y=y_start+6,min_width=22,text="Redstone Connections",callback=show_rs_conns,fg_bg=cpair(colors.black,colors.yellow),active_fg_bg=btn_act_fg_bg,dis_fg_bg=cpair(colors.lightGray,colors.white)} + + if not tool_ctl.has_config then + tool_ctl.view_gw_cfg.disable() + tool_ctl.dev_cfg.disable() + tool_ctl.rs_cfg.disable() + end + + PushButton{parent=main_page,x=2,y=17,min_width=6,text="Exit",callback=exit,fg_bg=cpair(colors.black,colors.red),active_fg_bg=btn_act_fg_bg} + PushButton{parent=main_page,x=39,y=17,min_width=12,text="Change Log",callback=function()main_pane.set_value(6)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + + --#endregion + + --#region SPEAKER CONFIG + + local spkr_c = Div{parent=spkr_cfg,x=2,y=4,width=49} + + TextBox{parent=spkr_cfg,x=1,y=2,height=1,text=" Speaker Configuration",fg_bg=cpair(colors.black,colors.cyan)} + + TextBox{parent=spkr_c,x=1,y=1,height=2,text="Speakers can be connected to this RTU gateway without RTU unit configuration entries."} + TextBox{parent=spkr_c,x=1,y=4,height=3,text="You can change the speaker audio volume from the default. The range is 0.0 to 3.0, where 1.0 is standard volume."} + + local s_vol = NumberField{parent=spkr_c,x=1,y=8,width=9,max_digits=7,allow_decimal=true,default=ini_cfg.SpeakerVolume,min=0,max=3,fg_bg=bw_fg_bg} + + TextBox{parent=spkr_c,x=1,y=10,height=3,text="Note: alarm sine waves are at half scale so that multiple will be required to reach full scale.",fg_bg=g_lg_fg_bg} + + local s_vol_err = TextBox{parent=spkr_c,x=8,y=14,height=1,width=35,text="Please set a volume.",fg_bg=cpair(colors.red,colors.lightGray),hidden=true} + + local function submit_vol() + local vol = tonumber(s_vol.get_value()) + if vol ~= nil then + s_vol_err.hide(true) + tmp_cfg.SpeakerVolume = vol + main_pane.set_value(3) + else s_vol_err.show() end + end + + PushButton{parent=spkr_c,x=1,y=14,min_width=6,text="\x1b Back",callback=function()main_pane.set_value(1)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + PushButton{parent=spkr_c,x=44,y=14,min_width=6,text="Next \x1a",callback=submit_vol,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + + --#endregion + + --#region NET CONFIG + + local net_c_1 = Div{parent=net_cfg,x=2,y=4,width=49} + local net_c_2 = Div{parent=net_cfg,x=2,y=4,width=49} + local net_c_3 = Div{parent=net_cfg,x=2,y=4,width=49} + + local net_pane = MultiPane{parent=net_cfg,x=1,y=4,panes={net_c_1,net_c_2,net_c_3}} + + TextBox{parent=net_cfg,x=1,y=2,height=1,text=" Network Configuration",fg_bg=cpair(colors.black,colors.lightBlue)} + + TextBox{parent=net_c_1,x=1,y=1,height=1,text="Please set the network channels below."} + TextBox{parent=net_c_1,x=1,y=3,height=4,text="Each of the 5 uniquely named channels, including the 2 below, must be the same for each device in this SCADA network. For multiplayer servers, it is recommended to not use the default channels.",fg_bg=g_lg_fg_bg} + + TextBox{parent=net_c_1,x=1,y=8,height=1,text="Supervisor Channel"} + local svr_chan = NumberField{parent=net_c_1,x=1,y=9,width=7,default=ini_cfg.SVR_Channel,min=1,max=65535,fg_bg=bw_fg_bg} + TextBox{parent=net_c_1,x=9,y=9,height=4,text="[SVR_CHANNEL]",fg_bg=g_lg_fg_bg} + TextBox{parent=net_c_1,x=1,y=11,height=1,text="RTU Channel"} + local rtu_chan = NumberField{parent=net_c_1,x=1,y=12,width=7,default=ini_cfg.RTU_Channel,min=1,max=65535,fg_bg=bw_fg_bg} + TextBox{parent=net_c_1,x=9,y=12,height=4,text="[RTU_CHANNEL]",fg_bg=g_lg_fg_bg} + + local chan_err = TextBox{parent=net_c_1,x=8,y=14,height=1,width=35,text="",fg_bg=cpair(colors.red,colors.lightGray),hidden=true} + + local function submit_channels() + local svr_c = tonumber(svr_chan.get_value()) + local rtu_c = tonumber(rtu_chan.get_value()) + if svr_c ~= nil and rtu_c ~= nil then + tmp_cfg.SVR_Channel = svr_c + tmp_cfg.RTU_Channel = rtu_c + net_pane.set_value(2) + chan_err.hide(true) + elseif svr_c == nil then + chan_err.set_value("Please set the supervisor channel.") + chan_err.show() + else + chan_err.set_value("Please set the RTU channel.") + chan_err.show() + end + end + + PushButton{parent=net_c_1,x=1,y=14,min_width=6,text="\x1b Back",callback=function()main_pane.set_value(2)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + PushButton{parent=net_c_1,x=44,y=14,min_width=6,text="Next \x1a",callback=submit_channels,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + + TextBox{parent=net_c_2,x=1,y=1,height=1,text="Connection Timeout"} + local timeout = NumberField{parent=net_c_2,x=1,y=2,width=7,default=ini_cfg.ConnTimeout,min=2,max=25,fg_bg=bw_fg_bg} + TextBox{parent=net_c_2,x=9,y=2,height=2,text="seconds (default 5)",fg_bg=g_lg_fg_bg} + TextBox{parent=net_c_2,x=1,y=3,height=4,text="You generally do not want or need to modify this. On slow servers, you can increase this to make the system wait longer before assuming a disconnection.",fg_bg=g_lg_fg_bg} + + TextBox{parent=net_c_2,x=1,y=8,height=1,text="Trusted Range"} + local range = NumberField{parent=net_c_2,x=1,y=9,width=10,default=ini_cfg.TrustedRange,min=0,max_digits=20,allow_decimal=true,fg_bg=bw_fg_bg} + TextBox{parent=net_c_2,x=1,y=10,height=4,text="Setting this to a value larger than 0 prevents connections with devices that many meters (blocks) away in any direction.",fg_bg=g_lg_fg_bg} + + local p2_err = TextBox{parent=net_c_2,x=8,y=14,height=1,width=35,text="",fg_bg=cpair(colors.red,colors.lightGray),hidden=true} + + local function submit_ct_tr() + local timeout_val = tonumber(timeout.get_value()) + local range_val = tonumber(range.get_value()) + if timeout_val ~= nil and range_val ~= nil then + tmp_cfg.ConnTimeout = timeout_val + tmp_cfg.TrustedRange = range_val + net_pane.set_value(3) + p2_err.hide(true) + elseif timeout_val == nil then + p2_err.set_value("Please set the connection timeout.") + p2_err.show() + else + p2_err.set_value("Please set the trusted range.") + p2_err.show() + end + end + + PushButton{parent=net_c_2,x=1,y=14,min_width=6,text="\x1b Back",callback=function()net_pane.set_value(1)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + PushButton{parent=net_c_2,x=44,y=14,min_width=6,text="Next \x1a",callback=submit_ct_tr,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + + TextBox{parent=net_c_3,x=1,y=1,height=2,text="Optionally, set the facility authentication key below. Do NOT use one of your passwords."} + TextBox{parent=net_c_3,x=1,y=4,height=6,text="This enables verifying that messages are authentic, so it is intended for security on multiplayer servers. All devices on the same network MUST use the same key if any device has a key. This does result in some extra compution (can slow things down).",fg_bg=g_lg_fg_bg} + + TextBox{parent=net_c_3,x=1,y=11,height=1,text="Facility Auth Key"} + local key, _, censor = TextField{parent=net_c_3,x=1,y=12,max_len=64,value=ini_cfg.AuthKey,width=32,height=1,fg_bg=bw_fg_bg} + + local function censor_key(enable) censor(util.trinary(enable, "*", nil)) end + + local hide_key = CheckBox{parent=net_c_3,x=34,y=12,label="Hide",box_fg_bg=cpair(colors.lightBlue,colors.black),callback=censor_key} + + hide_key.set_value(true) + censor_key(true) + + local key_err = TextBox{parent=net_c_3,x=8,y=14,height=1,width=35,text="Key must be at least 8 characters.",fg_bg=cpair(colors.red,colors.lightGray),hidden=true} + + local function submit_auth() + local v = key.get_value() + if string.len(v) == 0 or string.len(v) >= 8 then + tmp_cfg.AuthKey = key.get_value() + main_pane.set_value(4) + key_err.hide(true) + else key_err.show() end + end + + PushButton{parent=net_c_3,x=1,y=14,min_width=6,text="\x1b Back",callback=function()net_pane.set_value(2)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + PushButton{parent=net_c_3,x=44,y=14,min_width=6,text="Next \x1a",callback=submit_auth,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + + --#endregion + + --#region LOG CONFIG + + local log_c_1 = Div{parent=log_cfg,x=2,y=4,width=49} + + TextBox{parent=log_cfg,x=1,y=2,height=1,text=" Logging Configuration",fg_bg=cpair(colors.black,colors.pink)} + + TextBox{parent=log_c_1,x=1,y=1,height=1,text="Please configure logging below."} + + TextBox{parent=log_c_1,x=1,y=3,height=1,text="Log File Mode"} + local mode = RadioButton{parent=log_c_1,x=1,y=4,default=ini_cfg.LogMode+1,options={"Append on Startup","Replace on Startup"},callback=function()end,radio_colors=cpair(colors.lightGray,colors.black),select_color=colors.pink} + + TextBox{parent=log_c_1,x=1,y=7,height=1,text="Log File Path"} + local path = TextField{parent=log_c_1,x=1,y=8,width=49,height=1,value=ini_cfg.LogPath,max_len=128,fg_bg=bw_fg_bg} + + local en_dbg = CheckBox{parent=log_c_1,x=1,y=10,default=ini_cfg.LogDebug,label="Enable Logging Debug Messages",box_fg_bg=cpair(colors.pink,colors.black)} + TextBox{parent=log_c_1,x=3,y=11,height=2,text="This results in much larger log files. It is best to only use this when there is a problem.",fg_bg=g_lg_fg_bg} + + local path_err = TextBox{parent=log_c_1,x=8,y=14,height=1,width=35,text="Please provide a log file path.",fg_bg=cpair(colors.red,colors.lightGray),hidden=true} + + local function submit_log() + if path.get_value() ~= "" then + path_err.hide(true) + tmp_cfg.LogMode = mode.get_value() - 1 + tmp_cfg.LogPath = path.get_value() + tmp_cfg.LogDebug = en_dbg.get_value() + tool_ctl.gen_summary(tmp_cfg) + tool_ctl.viewing_config = false + tool_ctl.importing_legacy = false + tool_ctl.settings_apply.show() + tool_ctl.settings_confirm.hide(true) + main_pane.set_value(5) + else path_err.show() end + end + + PushButton{parent=log_c_1,x=1,y=14,min_width=6,text="\x1b Back",callback=function()main_pane.set_value(3)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + PushButton{parent=log_c_1,x=44,y=14,min_width=6,text="Next \x1a",callback=submit_log,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + + --#endregion + + --#region SUMMARY OF CHANGES + + local sum_c_1 = Div{parent=summary,x=2,y=4,width=49} + local sum_c_2 = Div{parent=summary,x=2,y=4,width=49} + local sum_c_3 = Div{parent=summary,x=2,y=4,width=49} + local sum_c_4 = Div{parent=summary,x=2,y=4,width=49} + local sum_c_5 = Div{parent=summary,x=2,y=4,width=49} + local sum_c_6 = Div{parent=summary,x=2,y=4,width=49} + local sum_c_7 = Div{parent=summary,x=2,y=4,width=49} + + local sum_pane = MultiPane{parent=summary,x=1,y=4,panes={sum_c_1,sum_c_2,sum_c_3,sum_c_4,sum_c_5,sum_c_6,sum_c_7}} + + TextBox{parent=summary,x=1,y=2,height=1,text=" Summary",fg_bg=cpair(colors.black,colors.green)} + + local setting_list = ListBox{parent=sum_c_1,x=1,y=1,height=12,width=51,scroll_height=100,fg_bg=bw_fg_bg,nav_fg_bg=g_lg_fg_bg,nav_active=cpair(colors.black,colors.gray)} + + local function back_from_settings() + if tool_ctl.viewing_config or tool_ctl.importing_legacy then + if tool_ctl.importing_legacy and tool_ctl.importing_any_dc then + sum_pane.set_value(7) + else + tool_ctl.importing_legacy = false + tool_ctl.go_home() + end + + tool_ctl.viewing_config = false + else main_pane.set_value(4) end + end + + ---@param element graphics_element + ---@param data any + local function try_set(element, data) + if data ~= nil then element.set_value(data) end + end + + ---@param exclude_conns boolean? true to exclude saving peripheral/redstone connections + local function save_and_continue(exclude_conns) + for k, v in pairs(tmp_cfg) do + if not (exclude_conns and (k == "Peripherals" or k == "Redstone")) then settings.set(k, v) end + end + + if settings.save("rtu.settings") then + load_settings(ini_cfg) + load_settings(settings_cfg, true) + + try_set(s_vol, ini_cfg.SpeakerVolume) + try_set(svr_chan, ini_cfg.SVR_Channel) + try_set(rtu_chan, ini_cfg.RTU_Channel) + try_set(timeout, ini_cfg.ConnTimeout) + try_set(range, ini_cfg.TrustedRange) + try_set(key, ini_cfg.AuthKey) + try_set(mode, ini_cfg.LogMode) + try_set(path, ini_cfg.LogPath) + try_set(en_dbg, ini_cfg.LogDebug) + + if not exclude_conns then + tmp_cfg.Peripherals = deep_copy_peri(ini_cfg.Peripherals) + tmp_cfg.Redstone = deep_copy_rs(ini_cfg.Redstone) + + tool_ctl.update_peri_list() + end + + tool_ctl.dev_cfg.enable() + tool_ctl.rs_cfg.enable() + tool_ctl.view_gw_cfg.enable() + + if tool_ctl.importing_legacy then + tool_ctl.importing_legacy = false + sum_pane.set_value(5) + else sum_pane.set_value(4) end + else sum_pane.set_value(6) end + end + + PushButton{parent=sum_c_1,x=1,y=14,min_width=6,text="\x1b Back",callback=back_from_settings,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + tool_ctl.show_key_btn = PushButton{parent=sum_c_1,x=8,y=14,min_width=17,text="Unhide Auth Key",callback=function()tool_ctl.show_auth_key()end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg,dis_fg_bg=cpair(colors.lightGray,colors.white)} + tool_ctl.settings_apply = PushButton{parent=sum_c_1,x=43,y=14,min_width=7,text="Apply",callback=function()save_and_continue(true)end,fg_bg=cpair(colors.black,colors.green),active_fg_bg=btn_act_fg_bg} + tool_ctl.settings_confirm = PushButton{parent=sum_c_1,x=41,y=14,min_width=9,text="Confirm",callback=function()sum_pane.set_value(2)end,fg_bg=cpair(colors.black,colors.green),active_fg_bg=btn_act_fg_bg} + tool_ctl.settings_confirm.hide() + + TextBox{parent=sum_c_2,x=1,y=1,height=1,text="The following peripherals will be imported:"} + local peri_import_list = ListBox{parent=sum_c_2,x=1,y=3,height=10,width=51,scroll_height=1000,fg_bg=bw_fg_bg,nav_fg_bg=g_lg_fg_bg,nav_active=cpair(colors.black,colors.gray)} + + PushButton{parent=sum_c_2,x=1,y=14,min_width=6,text="\x1b Back",callback=function()sum_pane.set_value(1)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + PushButton{parent=sum_c_2,x=41,y=14,min_width=9,text="Confirm",callback=function()sum_pane.set_value(3)end,fg_bg=cpair(colors.black,colors.green),active_fg_bg=btn_act_fg_bg} + + TextBox{parent=sum_c_3,x=1,y=1,height=1,text="The following redstone entries will be imported:"} + local rs_import_list = ListBox{parent=sum_c_3,x=1,y=3,height=10,width=51,scroll_height=1000,fg_bg=bw_fg_bg,nav_fg_bg=g_lg_fg_bg,nav_active=cpair(colors.black,colors.gray)} + + PushButton{parent=sum_c_3,x=1,y=14,min_width=6,text="\x1b Back",callback=function()sum_pane.set_value(2)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + PushButton{parent=sum_c_3,x=43,y=14,min_width=7,text="Apply",callback=save_and_continue,fg_bg=cpair(colors.black,colors.green),active_fg_bg=btn_act_fg_bg} + + TextBox{parent=sum_c_4,x=1,y=1,height=1,text="Settings saved!"} + PushButton{parent=sum_c_4,x=1,y=14,min_width=6,text="Home",callback=function()tool_ctl.go_home()end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + PushButton{parent=sum_c_4,x=44,y=14,min_width=6,text="Exit",callback=exit,fg_bg=cpair(colors.black,colors.red),active_fg_bg=cpair(colors.white,colors.gray)} + + TextBox{parent=sum_c_5,x=1,y=1,height=2,text="The old config.lua file will now be deleted, then the configurator will exit."} + + local function delete_legacy() + fs.delete("/rtu/config.lua") + exit() + end + + PushButton{parent=sum_c_5,x=1,y=14,min_width=8,text="Cancel",callback=function()tool_ctl.go_home()end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + PushButton{parent=sum_c_5,x=44,y=14,min_width=6,text="OK",callback=delete_legacy,fg_bg=cpair(colors.black,colors.green),active_fg_bg=cpair(colors.white,colors.gray)} + + TextBox{parent=sum_c_6,x=1,y=1,height=5,text="Failed to save the settings file.\n\nThere may not be enough space for the modification or server file permissions may be denying writes."} + PushButton{parent=sum_c_6,x=1,y=14,min_width=6,text="Home",callback=function()tool_ctl.go_home()end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + PushButton{parent=sum_c_6,x=44,y=14,min_width=6,text="Exit",callback=exit,fg_bg=cpair(colors.black,colors.red),active_fg_bg=cpair(colors.white,colors.gray)} + + TextBox{parent=sum_c_7,x=1,y=1,height=8,text="Warning!\n\nSome of the devices in your old config file aren't currently connected. If the device isn't connected, the options can't be properly validated. Please either connect your devices and try again or complete the import without validation on those entry's settings."} + TextBox{parent=sum_c_7,x=1,y=10,height=3,text="Afterwards, either (a) edit then save entries for currently disconnected devices to properly configure or (b) delete those entries."} + PushButton{parent=sum_c_7,x=1,y=14,min_width=6,text="\x1b Back",callback=function()tool_ctl.go_home()end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + PushButton{parent=sum_c_7,x=41,y=14,min_width=9,text="Confirm",callback=function()sum_pane.set_value(1)end,fg_bg=cpair(colors.black,colors.orange),active_fg_bg=btn_act_fg_bg} + + --#endregion + + --#region CONFIG CHANGE LOG + + local cl = Div{parent=changelog,x=2,y=4,width=49} + + TextBox{parent=changelog,x=1,y=2,height=1,text=" Config Change Log",fg_bg=bw_fg_bg} + + local c_log = ListBox{parent=cl,x=1,y=1,height=12,width=51,scroll_height=100,fg_bg=bw_fg_bg,nav_fg_bg=g_lg_fg_bg,nav_active=cpair(colors.black,colors.gray)} + + for _, change in ipairs(changes) do + TextBox{parent=c_log,text=change[1],height=1,fg_bg=bw_fg_bg} + for _, v in ipairs(change[2]) do + local e = Div{parent=c_log,height=#util.strwrap(v,46)} + TextBox{parent=e,y=1,x=1,text="- ",height=1,fg_bg=cpair(colors.gray,colors.white)} + TextBox{parent=e,y=1,x=3,text=v,height=e.get_height(),fg_bg=cpair(colors.gray,colors.white)} + end + end + + PushButton{parent=cl,x=1,y=14,min_width=6,text="\x1b Back",callback=function()main_pane.set_value(1)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + + --#endregion + + --#region DEVICES + + local peri_c_1 = Div{parent=peri_cfg,x=2,y=4,width=49} + local peri_c_2 = Div{parent=peri_cfg,x=2,y=4,width=49} + local peri_c_3 = Div{parent=peri_cfg,x=2,y=4,width=49} + local peri_c_4 = Div{parent=peri_cfg,x=2,y=4,width=49} + local peri_c_5 = Div{parent=peri_cfg,x=2,y=4,width=49} + local peri_c_6 = Div{parent=peri_cfg,x=2,y=4,width=49} + local peri_c_7 = Div{parent=peri_cfg,x=2,y=4,width=49} + + local peri_pane = MultiPane{parent=peri_cfg,x=1,y=4,panes={peri_c_1,peri_c_2,peri_c_3,peri_c_4,peri_c_5,peri_c_6,peri_c_7}} + + TextBox{parent=peri_cfg,x=1,y=2,height=1,text=" Peripheral Connections",fg_bg=cpair(colors.black,colors.purple)} + + local peri_list = ListBox{parent=peri_c_1,x=1,y=1,height=12,width=51,scroll_height=1000,fg_bg=bw_fg_bg,nav_fg_bg=g_lg_fg_bg,nav_active=cpair(colors.black,colors.gray)} + + local function peri_revert() + tmp_cfg.Peripherals = deep_copy_peri(ini_cfg.Peripherals) + tool_ctl.gen_peri_summary(tmp_cfg) + end + + local function peri_apply() + settings.set("Peripherals", tmp_cfg.Peripherals) + + if settings.save("rtu.settings") then + load_settings(ini_cfg) + load_settings(settings_cfg, true) + peri_pane.set_value(5) + else + peri_pane.set_value(6) + end + end + + PushButton{parent=peri_c_1,x=1,y=14,min_width=6,text="\x1b Back",callback=function()main_pane.set_value(1)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + PushButton{parent=peri_c_1,x=8,y=14,min_width=16,text="Revert Changes",callback=peri_revert,fg_bg=cpair(colors.black,colors.yellow),active_fg_bg=btn_act_fg_bg} + PushButton{parent=peri_c_1,x=35,y=14,min_width=7,text="Add +",callback=function()peri_pane.set_value(2)end,fg_bg=cpair(colors.black,colors.blue),active_fg_bg=btn_act_fg_bg} + PushButton{parent=peri_c_1,x=43,y=14,min_width=7,text="Apply",callback=peri_apply,fg_bg=cpair(colors.black,colors.green),active_fg_bg=btn_act_fg_bg} + + TextBox{parent=peri_c_2,x=1,y=1,height=1,text="Select one of the below devices to use."} + + tool_ctl.ppm_devs = ListBox{parent=peri_c_2,x=1,y=3,height=10,width=51,scroll_height=1000,fg_bg=bw_fg_bg,nav_fg_bg=g_lg_fg_bg,nav_active=cpair(colors.black,colors.gray)} + + PushButton{parent=peri_c_2,x=1,y=14,min_width=6,text="\x1b Back",callback=function()peri_pane.set_value(1)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + PushButton{parent=peri_c_2,x=8,y=14,min_width=10,text="Manual +",callback=function()peri_pane.set_value(3)end,fg_bg=cpair(colors.black,colors.orange),active_fg_bg=btn_act_fg_bg} + PushButton{parent=peri_c_2,x=26,y=14,min_width=24,text="I don't see my device!",callback=function()peri_pane.set_value(7)end,fg_bg=cpair(colors.black,colors.yellow),active_fg_bg=btn_act_fg_bg} + + TextBox{parent=peri_c_7,x=1,y=1,height=10,text="Make sure your device is either touching the RTU or connected via wired modems. There should be a wired modem on a side of the RTU then one on the device, connected by a cable. The modem on the device needs to be right clicked to connect it (which will turn its border red), at which point the peripheral name will be shown in the chat."} + TextBox{parent=peri_c_7,x=1,y=9,height=4,text="If it still does not show, it may not be compatible. Currently only Boilers, Turbines, Dynamic Tanks, SNAs, SPSs, Induction Matricies, and Environment Detectors are supported."} + PushButton{parent=peri_c_7,x=1,y=14,min_width=6,text="\x1b Back",callback=function()peri_pane.set_value(2)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + + local new_peri_attrs = { "", "" } + local function new_peri(name, type) + new_peri_attrs = { name, type } + tool_ctl.peri_cfg_editing = false + + tool_ctl.p_err.hide(true) + tool_ctl.p_name_msg.set_value("Configuring peripheral on '" .. name .. "':") + tool_ctl.p_desc_ext.set_value("") + + if type == "boilerValve" then + tool_ctl.p_prompt.set_value("This is the # boiler for reactor unit # .") + tool_ctl.p_idx.show() + tool_ctl.p_idx.redraw() + tool_ctl.p_idx.enable() + tool_ctl.p_idx.set_max(2) + tool_ctl.p_unit.reposition(44, 4) + tool_ctl.p_unit.enable() + tool_ctl.p_assign_btn.hide(true) + tool_ctl.p_assign_end.hide(true) + tool_ctl.p_desc.reposition(1, 7) + tool_ctl.p_desc.set_value("Each unit can have at most 2 boilers. Boiler #1 shows up first on the main display, followed by boiler #2 below it. These numberings are independent of which RTU they are connected to. For example, one RTU can have boiler #1 and another can have #2, but both cannot have #1.") + elseif type == "turbineValve" then + tool_ctl.p_prompt.set_value("This is the # turbine for reactor unit # .") + tool_ctl.p_idx.show() + tool_ctl.p_idx.redraw() + tool_ctl.p_idx.enable() + tool_ctl.p_idx.set_max(3) + tool_ctl.p_unit.reposition(45, 4) + tool_ctl.p_unit.enable() + tool_ctl.p_assign_btn.hide(true) + tool_ctl.p_assign_end.hide(true) + tool_ctl.p_desc.reposition(1, 7) + tool_ctl.p_desc.set_value("Each unit can have at most 3 turbines. Turbine #1 shows up first on the main display, followed by #2 then #3 below it. These numberings are independent of which RTU they are connected to. For example, one RTU can have turbine #1 and another can have #2, but both cannot have #1.") + elseif type == "solarNeutronActivator" then + tool_ctl.p_idx.hide() + tool_ctl.p_prompt.set_value("This SNA is for reactor unit # .") + tool_ctl.p_unit.reposition(31, 4) + tool_ctl.p_unit.enable() + tool_ctl.p_assign_btn.hide(true) + tool_ctl.p_assign_end.hide(true) + tool_ctl.p_desc_ext.set_value("Before adding lots of SNAs: multiply the \"PEAK\" rate on the flow monitor (after connecting at least 1 SNA) by 10 to get the mB/t of waste that they can process. Enough SNAs to provide 2x to 3x of your max burn rate should be a good margin to catch up after night or cloudy weather. Too many devices (such as SNAs) on one RTU can cause lag.") + elseif type == "dynamicValve" then + tool_ctl.p_prompt.set_value("This is the # dynamic tank for...") + tool_ctl.p_assign_btn.show() + tool_ctl.p_assign_btn.redraw() + tool_ctl.p_assign_end.show() + tool_ctl.p_assign_end.redraw() + tool_ctl.p_idx.show() + tool_ctl.p_idx.redraw() + tool_ctl.p_idx.set_max(4) + tool_ctl.p_unit.reposition(18, 6) + + if tool_ctl.p_assign_btn.get_value() == 1 then + tool_ctl.p_idx.enable() + tool_ctl.p_unit.disable() + else + tool_ctl.p_idx.set_value(1) + tool_ctl.p_idx.disable() + tool_ctl.p_unit.enable() + end + + tool_ctl.p_desc.reposition(1, 8) + tool_ctl.p_desc.set_value("Each reactor unit can have at most 1 tank and the facility can have at most 4. Each facility tank must have a unique # 1 through 4, regardless of where it is connected. Only a total of 4 tanks can be displayed on the flow monitor.") + elseif type == "environmentDetector" then + tool_ctl.p_prompt.set_value("This is the # environment detector for...") + tool_ctl.p_assign_btn.show() + tool_ctl.p_assign_btn.redraw() + tool_ctl.p_assign_end.show() + tool_ctl.p_assign_end.redraw() + tool_ctl.p_idx.show() + tool_ctl.p_idx.redraw() + tool_ctl.p_idx.set_max(99) + tool_ctl.p_unit.reposition(18, 6) + if tool_ctl.p_assign_btn.get_value() == 1 then tool_ctl.p_unit.disable() else tool_ctl.p_unit.enable() end + tool_ctl.p_desc.reposition(1, 8) + tool_ctl.p_desc.set_value("You can connect more than one environment detector for a particular unit or the facility. In that case, the maximum radiation reading from those assigned to that particular unit or the facility will be used for alarms and display.") + elseif type == "inductionPort" or type == "spsPort" then + local dev = util.trinary(type == "inductionPort", "induction matrix", "SPS") + tool_ctl.p_idx.hide(true) + tool_ctl.p_unit.hide(true) + tool_ctl.p_prompt.set_value("This is the " .. dev .. " for the facility.") + tool_ctl.p_assign_btn.hide(true) + tool_ctl.p_assign_end.hide(true) + tool_ctl.p_desc.reposition(1, 7) + tool_ctl.p_desc.set_value("There can only be one of these devices per SCADA network, so it will be assigned as the sole " .. dev .. " for the facility. There must only be one of these across all the RTUs you have.") + else + assert(false, "invalid peripheral type after type validation") + end + + peri_pane.set_value(4) + end + + -- update peripherals list + function tool_ctl.update_peri_list() + local alternate = true + local mounts = ppm.list_mounts() + + -- filter out in-use peripherals + for _, v in ipairs(tmp_cfg.Peripherals) do mounts[v.name] = nil end + + tool_ctl.ppm_devs.remove_all() + for name, entry in pairs(mounts) do + if util.table_contains(RTU_DEV_TYPES, entry.type) then + local bkg = util.trinary(alternate, colors.white, colors.lightGray) + + ---@cast entry ppm_entry + local line = Div{parent=tool_ctl.ppm_devs,height=2,fg_bg=cpair(colors.black,bkg)} + PushButton{parent=line,x=1,y=1,min_width=9,alignment=LEFT,height=1,text="> SELECT",callback=function()tool_ctl.peri_cfg_manual=false;new_peri(name,entry.type)end,fg_bg=cpair(colors.black,colors.purple),active_fg_bg=cpair(colors.white,colors.black)} + TextBox{parent=line,x=11,y=1,height=1,text=name,fg_bg=cpair(colors.black,bkg)} + TextBox{parent=line,x=11,y=2,height=1,text=entry.type,fg_bg=cpair(colors.gray,bkg)} + + alternate = not alternate + end + end + end + + tool_ctl.update_peri_list() + + TextBox{parent=peri_c_3,x=1,y=1,height=4,text="This feature is intended for advanced users. If you are clicking this just because your device is not shown, follow the connection instructions in 'I don't see my device!'."} + TextBox{parent=peri_c_3,x=1,y=6,height=4,text="Peripheral Name"} + local p_name = TextField{parent=peri_c_3,x=1,y=7,width=49,height=1,max_len=128,fg_bg=bw_fg_bg} + local p_type = Radio2D{parent=peri_c_3,x=1,y=9,rows=4,columns=2,default=1,options=RTU_DEV_TYPES,radio_colors=cpair(colors.lightGray,colors.black),select_color=colors.purple} + local man_p_err = TextBox{parent=peri_c_3,x=8,y=14,height=1,width=35,text="Please enter a peripheral name.",fg_bg=cpair(colors.red,colors.lightGray),hidden=true} + man_p_err.hide(true) + + local function submit_manual_peri() + local name = p_name.get_value() + if string.len(name) > 0 then + tool_ctl.entering_manual = true + man_p_err.hide(true) + new_peri(name, RTU_DEV_TYPES[p_type.get_value()]) + else man_p_err.show() end + end + + PushButton{parent=peri_c_3,x=1,y=14,min_width=6,text="\x1b Back",callback=function()peri_pane.set_value(2)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + PushButton{parent=peri_c_3,x=44,y=14,min_width=6,text="Next \x1a",callback=submit_manual_peri,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + + tool_ctl.p_name_msg = TextBox{parent=peri_c_4,x=1,y=1,height=2,text=""} + tool_ctl.p_prompt = TextBox{parent=peri_c_4,x=1,y=4,height=2,text=""} + tool_ctl.p_idx = NumberField{parent=peri_c_4,x=14,y=4,width=4,max_digits=2,min=1,max=2,default=1,fg_bg=bw_fg_bg,dis_fg_bg=cpair(colors.lightGray,colors.white)} + tool_ctl.p_assign_btn = RadioButton{parent=peri_c_4,x=1,y=5,default=1,options={"the facility.","a unit. (unit #"},callback=function(v)tool_ctl.p_assign(v)end,radio_colors=cpair(colors.lightGray,colors.black),select_color=colors.purple} + tool_ctl.p_assign_end = TextBox{parent=peri_c_4,x=22,y=6,height=6,width=1,text=")"} + + tool_ctl.p_unit = NumberField{parent=peri_c_4,x=44,y=4,width=4,max_digits=2,min=1,max=4,default=1,fg_bg=bw_fg_bg,dis_fg_bg=cpair(colors.lightGray,colors.white)} + tool_ctl.p_unit.disable() + + function tool_ctl.p_assign(opt) + if opt == 1 then + tool_ctl.p_unit.disable() + if new_peri_attrs[2] == "dynamicValve" then tool_ctl.p_idx.enable() end + else + tool_ctl.p_unit.enable() + if new_peri_attrs[2] == "dynamicValve" then + tool_ctl.p_idx.set_value(1) + tool_ctl.p_idx.disable() + end + end + end + + tool_ctl.p_desc = TextBox{parent=peri_c_4,x=1,y=7,height=6,text="",fg_bg=g_lg_fg_bg} + tool_ctl.p_desc_ext = TextBox{parent=peri_c_4,x=1,y=6,height=7,text="",fg_bg=g_lg_fg_bg} + + tool_ctl.p_err = TextBox{parent=peri_c_4,x=8,y=14,height=1,width=35,text="",fg_bg=cpair(colors.red,colors.lightGray),hidden=true} + tool_ctl.p_err.hide(true) + + local function back_from_peri_opts() + if tool_ctl.peri_cfg_editing ~= false then + peri_pane.set_value(1) + elseif tool_ctl.entering_manual then + peri_pane.set_value(3) + else + peri_pane.set_value(2) + end + + tool_ctl.entering_manual = false + end + + local function save_peri_entry() + local peri_name = new_peri_attrs[1] + local peri_type = new_peri_attrs[2] + + local unit, index = nil, nil + + local for_facility = tool_ctl.p_assign_btn.get_value() == 1 + local u = tonumber(tool_ctl.p_unit.get_value()) + local idx = tonumber(tool_ctl.p_idx.get_value()) + + if util.table_contains(NEEDS_UNIT, peri_type) then + if (peri_type == "dynamicValve" or peri_type == "environmentDetector") and for_facility then + -- skip + elseif not (util.is_int(u) and u > 0 and u < 5) then + tool_ctl.p_err.set_value("Unit ID must be within 1 through 4.") + tool_ctl.p_err.show() + return + else unit = u end + end + + if peri_type == "boilerValve" then + if not (idx == 1 or idx == 2) then + tool_ctl.p_err.set_value("Index must be 1 or 2.") + tool_ctl.p_err.show() + return + else index = idx end + elseif peri_type == "turbineValve" then + if not (idx == 1 or idx == 2 or idx == 3) then + tool_ctl.p_err.set_value("Index must be 1, 2, or 3.") + tool_ctl.p_err.show() + return + else index = idx end + elseif peri_type == "dynamicValve" and for_facility then + if not (util.is_int(idx) and idx > 0 and idx < 5) then + tool_ctl.p_err.set_value("Index must be within 1 through 4.") + tool_ctl.p_err.show() + return + else index = idx end + elseif peri_type == "dynamicValve" then + index = 1 + elseif peri_type == "environmentDetector" then + if not (util.is_int(idx) and idx > 0) then + tool_ctl.p_err.set_value("Index must be greater than 0.") + tool_ctl.p_err.show() + return + else index = idx end + end + + tool_ctl.p_err.hide(true) + + ---@type rtu_peri_definition + local def = { name = peri_name, unit = unit, index = index } + + if tool_ctl.peri_cfg_editing == false then + table.insert(tmp_cfg.Peripherals, def) + else + def.name = tmp_cfg.Peripherals[tool_ctl.peri_cfg_editing].name + tmp_cfg.Peripherals[tool_ctl.peri_cfg_editing] = def + end + + peri_pane.set_value(1) + tool_ctl.gen_peri_summary(tmp_cfg) + + tool_ctl.p_idx.set_value(1) + end + + PushButton{parent=peri_c_4,x=1,y=14,min_width=6,text="\x1b Back",callback=back_from_peri_opts,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + PushButton{parent=peri_c_4,x=41,y=14,min_width=9,text="Confirm",callback=save_peri_entry,fg_bg=cpair(colors.black,colors.blue),active_fg_bg=btn_act_fg_bg} + + TextBox{parent=peri_c_5,x=1,y=1,height=1,text="Settings saved!"} + PushButton{parent=peri_c_5,x=1,y=14,min_width=6,text="\x1b Back",callback=function()peri_pane.set_value(1)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + PushButton{parent=peri_c_5,x=44,y=14,min_width=6,text="Home",callback=function()tool_ctl.go_home()end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + + TextBox{parent=peri_c_6,x=1,y=1,height=5,text="Failed to save the settings file.\n\nThere may not be enough space for the modification or server file permissions may be denying writes."} + PushButton{parent=peri_c_6,x=1,y=14,min_width=6,text="\x1b Back",callback=function()peri_pane.set_value(1)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + PushButton{parent=peri_c_6,x=44,y=14,min_width=6,text="Home",callback=function()tool_ctl.go_home()end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + + --#endregion + + --#region REDSTONE + + local rs_c_1 = Div{parent=rs_cfg,x=2,y=4,width=49} + local rs_c_2 = Div{parent=rs_cfg,x=2,y=4,width=49} + local rs_c_3 = Div{parent=rs_cfg,x=2,y=4,width=49} + local rs_c_4 = Div{parent=rs_cfg,x=2,y=4,width=49} + local rs_c_5 = Div{parent=rs_cfg,x=2,y=4,width=49} + local rs_c_6 = Div{parent=rs_cfg,x=2,y=4,width=49} + + local rs_pane = MultiPane{parent=rs_cfg,x=1,y=4,panes={rs_c_1,rs_c_2,rs_c_3,rs_c_4,rs_c_5,rs_c_6}} + + TextBox{parent=rs_cfg,x=1,y=2,height=1,text=" Redstone Connections",fg_bg=cpair(colors.black,colors.red)} + + TextBox{parent=rs_c_1,x=1,y=1,height=1,text=" port side/color unit/facility",fg_bg=g_lg_fg_bg} + local rs_list = ListBox{parent=rs_c_1,x=1,y=2,height=11,width=51,scroll_height=200,fg_bg=bw_fg_bg,nav_fg_bg=g_lg_fg_bg,nav_active=cpair(colors.black,colors.gray)} + + local function rs_revert() + tmp_cfg.Redstone = deep_copy_rs(ini_cfg.Redstone) + tool_ctl.gen_rs_summary(tmp_cfg) + end + + local function rs_apply() + settings.set("Redstone", tmp_cfg.Redstone) + + if settings.save("rtu.settings") then + load_settings(ini_cfg) + load_settings(settings_cfg, true) + rs_pane.set_value(4) + else + rs_pane.set_value(5) + end + end + + PushButton{parent=rs_c_1,x=1,y=14,min_width=6,text="\x1b Back",callback=function()main_pane.set_value(1)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + PushButton{parent=rs_c_1,x=8,y=14,min_width=16,text="Revert Changes",callback=rs_revert,fg_bg=cpair(colors.black,colors.yellow),active_fg_bg=btn_act_fg_bg} + PushButton{parent=rs_c_1,x=35,y=14,min_width=7,text="New +",callback=function()rs_pane.set_value(2)end,fg_bg=cpair(colors.black,colors.blue),active_fg_bg=btn_act_fg_bg} + PushButton{parent=rs_c_1,x=43,y=14,min_width=7,text="Apply",callback=rs_apply,fg_bg=cpair(colors.black,colors.green),active_fg_bg=btn_act_fg_bg} + + TextBox{parent=rs_c_6,x=1,y=1,height=5,text="You already configured this input. There can only be one entry for each input.\n\nPlease select a different port."} + PushButton{parent=rs_c_6,x=1,y=14,min_width=6,text="\x1b Back",callback=function()rs_pane.set_value(2)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + + TextBox{parent=rs_c_2,x=1,y=1,height=1,text="Select one of the below ports to use."} + + local rs_ports = ListBox{parent=rs_c_2,x=1,y=3,height=10,width=51,scroll_height=200,fg_bg=bw_fg_bg,nav_fg_bg=g_lg_fg_bg,nav_active=cpair(colors.black,colors.gray)} + + local new_rs_port = IO.F_SCRAM + local function new_rs(port) + if (rsio.get_io_mode(port) == rsio.IO_DIR.IN) then + for i = 1, #tmp_cfg.Redstone do + if tmp_cfg.Redstone[i].port == port then + rs_pane.set_value(6) + return + end + end + end + + tool_ctl.rs_cfg_editing = false + + local text + + if port == -1 then + tool_ctl.rs_cfg_color.hide(true) + tool_ctl.rs_cfg_shortcut.show() + text = "You selected the ALL_WASTE shortcut." + else + tool_ctl.rs_cfg_shortcut.hide(true) + tool_ctl.rs_cfg_color.show() + text = "You selected " .. rsio.to_string(port) .. " (for " + if PORT_DSGN[port] == 1 then + text = text .. "a unit)." + tool_ctl.rs_cfg_unit_l.show() + tool_ctl.rs_cfg_unit.show() + else + tool_ctl.rs_cfg_unit_l.hide(true) + tool_ctl.rs_cfg_unit.hide(true) + text = text .. "the facility)." + end + end + + tool_ctl.rs_cfg_selection.set_value(text) + new_rs_port = port + rs_pane.set_value(3) + end + + -- add entries to redstone option list + local all_w_macro = Div{parent=rs_ports,height=1} + PushButton{parent=all_w_macro,x=1,y=1,min_width=14,alignment=LEFT,height=1,text=">ALL_WASTE",callback=function()new_rs(-1)end,fg_bg=cpair(colors.black,colors.green),active_fg_bg=cpair(colors.white,colors.black)} + TextBox{parent=all_w_macro,x=16,y=1,width=5,height=1,text="[n/a]",fg_bg=cpair(colors.lightGray,colors.white)} + TextBox{parent=all_w_macro,x=22,y=1,height=1,text="Create all 4 waste entries",fg_bg=cpair(colors.gray,colors.white)} + for i = 1, rsio.NUM_PORTS do + local name = rsio.to_string(i) + local io_dir = util.trinary(rsio.get_io_mode(i) == rsio.IO_DIR.IN, "[in]", "[out]") + local btn_color = util.trinary(rsio.get_io_mode(i) == rsio.IO_DIR.IN, colors.yellow, colors.lightBlue) + local entry = Div{parent=rs_ports,height=1} + PushButton{parent=entry,x=1,y=1,min_width=14,alignment=LEFT,height=1,text=">"..name,callback=function()new_rs(i)end,fg_bg=cpair(colors.black,btn_color),active_fg_bg=cpair(colors.white,colors.black)} + TextBox{parent=entry,x=16,y=1,width=5,height=1,text=io_dir,fg_bg=cpair(colors.lightGray,colors.white)} + TextBox{parent=entry,x=22,y=1,height=1,text=PORT_DESC[i],fg_bg=cpair(colors.gray,colors.white)} + end + + PushButton{parent=rs_c_2,x=1,y=14,min_width=6,text="\x1b Back",callback=function()rs_pane.set_value(1)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + + tool_ctl.rs_cfg_selection = TextBox{parent=rs_c_3,x=1,y=1,height=1,text=""} + + tool_ctl.rs_cfg_unit_l = TextBox{parent=rs_c_3,x=27,y=3,width=7,height=1,text="Unit ID"} + tool_ctl.rs_cfg_unit = NumberField{parent=rs_c_3,x=27,y=4,width=10,max_digits=2,min=1,max=4,fg_bg=bw_fg_bg} + + TextBox{parent=rs_c_3,x=1,y=3,width=11,height=1,text="Output Side"} + local side = Radio2D{parent=rs_c_3,x=1,y=4,rows=2,columns=3,default=1,options=side_options,radio_colors=cpair(colors.lightGray,colors.black),select_color=colors.red} + + local function set_bundled(bundled) + if bundled then tool_ctl.rs_cfg_color.enable() else tool_ctl.rs_cfg_color.disable() end + end + + tool_ctl.rs_cfg_shortcut = TextBox{parent=rs_c_3,x=1,y=9,height=4,text="This shortcut will add entries for each of the 4 waste outputs. If you select bundled, 4 colors will be assigned to the selected side. Otherwise, 4 default sides will be used."} + tool_ctl.rs_cfg_shortcut.hide(true) + + local bundled = CheckBox{parent=rs_c_3,x=1,y=7,label="Is Bundled?",default=false,box_fg_bg=cpair(colors.red,colors.black),callback=set_bundled} + tool_ctl.rs_cfg_color = Radio2D{parent=rs_c_3,x=1,y=9,rows=4,columns=4,default=1,options=color_options,radio_colors=cpair(colors.lightGray,colors.black),color_map=color_options_map,disable_color=colors.gray,disable_fg_bg=g_lg_fg_bg} + tool_ctl.rs_cfg_color.disable() + + local rs_err = TextBox{parent=rs_c_3,x=8,y=14,height=1,width=35,text="Unit ID must be within 1 through 4.",fg_bg=cpair(colors.red,colors.lightGray),hidden=true} + rs_err.hide(true) + + local function back_from_rs_opts() + rs_err.hide(true) + if tool_ctl.rs_cfg_editing ~= false then rs_pane.set_value(1) else rs_pane.set_value(2) end + end + + local function save_rs_entry() + local u = tonumber(tool_ctl.rs_cfg_unit.get_value()) + + if PORT_DSGN[new_rs_port] == 0 or (util.is_int(u) and u > 0 and u < 5) then + rs_err.hide(true) + + if new_rs_port >= 0 then + ---@type rtu_rs_definition + local def = { + unit = util.trinary(PORT_DSGN[new_rs_port] == 1, u, nil), + port = new_rs_port, + side = side_options_map[side.get_value()], + color = util.trinary(bundled.get_value(), color_options_map[tool_ctl.rs_cfg_color.get_value()], nil) + } + + if tool_ctl.rs_cfg_editing == false then + table.insert(tmp_cfg.Redstone, def) + else + def.port = tmp_cfg.Redstone[tool_ctl.rs_cfg_editing].port + tmp_cfg.Redstone[tool_ctl.rs_cfg_editing] = def + end + elseif new_rs_port == -1 then + local default_sides = { "left", "back", "right", "front" } + local default_colors = { colors.red, colors.orange, colors.yellow, colors.lime } + for i = 0, 3 do + table.insert(tmp_cfg.Redstone, { + unit = util.trinary(PORT_DSGN[IO.WASTE_PU + i] == 1, u, nil), + port = IO.WASTE_PU + i, + side = util.trinary(bundled.get_value(), side_options_map[side.get_value()], default_sides[i + 1]), + color = util.trinary(bundled.get_value(), default_colors[i + 1], nil) + }) + end + end + + rs_pane.set_value(1) + tool_ctl.gen_rs_summary(tmp_cfg) + + side.set_value(1) + bundled.set_value(false) + tool_ctl.rs_cfg_color.set_value(1) + tool_ctl.rs_cfg_color.disable() + else rs_err.show() end + end + + PushButton{parent=rs_c_3,x=1,y=14,min_width=6,text="\x1b Back",callback=back_from_rs_opts,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + PushButton{parent=rs_c_3,x=41,y=14,min_width=9,text="Confirm",callback=save_rs_entry,fg_bg=cpair(colors.black,colors.blue),active_fg_bg=btn_act_fg_bg} + + TextBox{parent=rs_c_4,x=1,y=1,height=1,text="Settings saved!"} + PushButton{parent=rs_c_4,x=1,y=14,min_width=6,text="\x1b Back",callback=function()rs_pane.set_value(1)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + PushButton{parent=rs_c_4,x=44,y=14,min_width=6,text="Home",callback=function()tool_ctl.go_home()end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + + TextBox{parent=rs_c_5,x=1,y=1,height=5,text="Failed to save the settings file.\n\nThere may not be enough space for the modification or server file permissions may be denying writes."} + PushButton{parent=rs_c_5,x=1,y=14,min_width=6,text="\x1b Back",callback=function()rs_pane.set_value(1)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + PushButton{parent=rs_c_5,x=44,y=14,min_width=6,text="Home",callback=function()tool_ctl.go_home()end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + + --#endregion + + -- set tool functions now that we have the elements + + -- load a legacy config file + function tool_ctl.load_legacy() + local config = require("rtu.config") + + tool_ctl.importing_any_dc = false + + tmp_cfg.SpeakerVolume = config.SOUNDER_VOLUME + tmp_cfg.SVR_Channel = config.SVR_CHANNEL + tmp_cfg.RTU_Channel = config.RTU_CHANNEL + tmp_cfg.ConnTimeout = config.COMMS_TIMEOUT + tmp_cfg.TrustedRange = config.TRUSTED_RANGE + tmp_cfg.AuthKey = config.AUTH_KEY or "" + tmp_cfg.LogMode = config.LOG_MODE + tmp_cfg.LogPath = config.LOG_PATH + tmp_cfg.LogDebug = config.LOG_DEBUG or false + + local mounts = ppm.list_mounts() + + peri_import_list.remove_all() + for _, entry in ipairs(config.RTU_DEVICES) do + local for_facility = entry.for_reactor == 0 + local ini_unit = util.trinary(for_facility, nil, entry.for_reactor) + + local def = { name = entry.name, unit = ini_unit, index = entry.index } + local mount = mounts[def.name] ---@type ppm_entry|nil + + local status = " \x13 not connected, please re-config later" + local color = colors.orange + + if mount ~= nil then + -- lets make sure things are valid + local unit, index, err = nil, nil, false + local u, idx = def.unit, def.index + + if util.table_contains(NEEDS_UNIT, mount.type) then + if (mount.type == "dynamicValve" or mount.type == "environmentDetector") and for_facility then + -- skip + elseif not (util.is_int(u) and u > 0 and u < 5) then + err = true + else unit = u end + end + + if mount.type == "boilerValve" then + if not (idx == 1 or idx == 2) then + err = true + else index = idx end + elseif mount.type == "turbineValve" then + if not (idx == 1 or idx == 2 or idx == 3) then + err = true + else index = idx end + elseif mount.type == "dynamicValve" and for_facility then + if not (util.is_int(idx) and idx > 0 and idx < 5) then + err = true + else index = idx end + elseif mount.type == "dynamicValve" then + index = 1 + elseif mount.type == "environmentDetector" then + if not (util.is_int(idx) and idx > 0) then + err = true + else index = idx end + end + + if err then + status = " \x13 invalid, please re-config later" + else + def.index = index + def.unit = unit + status = " \x04 validated" + color = colors.green + end + else tool_ctl.importing_any_dc = true end + + table.insert(tmp_cfg.Peripherals, def) + + local desc = " \x1a " + + if type(def.index) == "number" then + desc = desc .. "#" .. def.index .. " " + end + + if type(def.unit) == "number" then + desc = desc .. "for unit " .. def.unit + else + desc = desc .. "for the facility" + end + + local line = Div{parent=peri_import_list,height=3} + TextBox{parent=line,x=1,y=1,height=1,text="@ "..def.name,fg_bg=cpair(colors.black,colors.white)} + TextBox{parent=line,x=1,y=2,height=1,text=status,fg_bg=cpair(color,colors.white)} + TextBox{parent=line,x=1,y=3,height=1,text=desc,fg_bg=cpair(colors.gray,colors.white)} + end + + rs_import_list.remove_all() + for _, entry in ipairs(config.RTU_REDSTONE) do + if entry.for_reactor == 0 then entry.for_reactor = nil end + for _, io_entry in ipairs(entry.io) do + local def = { unit = entry.for_reactor, port = io_entry.port, side = io_entry.side, color = io_entry.bundled_color } + table.insert(tmp_cfg.Redstone, def) + + local name = rsio.to_string(def.port) + local io_dir = util.trinary(rsio.get_io_mode(def.port) == rsio.IO_DIR.IN, "\x1a", "\x1b") + local conn = def.side + local unit = "facility" + + if def.unit then unit = "unit " .. def.unit end + if def.color ~= nil then conn = def.side .. "/" .. rsio.color_name(def.color) end + + local line = Div{parent=rs_import_list,height=1} + TextBox{parent=line,x=1,y=1,width=1,height=1,text=io_dir,fg_bg=cpair(colors.lightGray,colors.white)} + TextBox{parent=line,x=2,y=1,width=14,height=1,text=name} + TextBox{parent=line,x=18,y=1,width=string.len(conn),height=1,text=conn,fg_bg=cpair(colors.gray,colors.white)} + TextBox{parent=line,x=40,y=1,height=1,text=unit,fg_bg=cpair(colors.gray,colors.white)} + end + end + + tool_ctl.gen_summary(tmp_cfg) + if tool_ctl.importing_any_dc then sum_pane.set_value(7) else sum_pane.set_value(1) end + main_pane.set_value(5) + tool_ctl.settings_apply.hide(true) + tool_ctl.settings_confirm.show() + tool_ctl.importing_legacy = true + end + + -- go back to the home page + function tool_ctl.go_home() + tool_ctl.viewing_config = false + tool_ctl.importing_legacy = false + tool_ctl.importing_any_dc = false + + main_pane.set_value(1) + net_pane.set_value(1) + sum_pane.set_value(1) + peri_pane.set_value(1) + rs_pane.set_value(1) + end + + -- expose the auth key on the summary page + function tool_ctl.show_auth_key() + tool_ctl.show_key_btn.disable() + tool_ctl.auth_key_textbox.set_value(tool_ctl.auth_key_value) + end + + -- generate the summary list + ---@param cfg rtu_config + function tool_ctl.gen_summary(cfg) + setting_list.remove_all() + + local alternate = false + local inner_width = setting_list.get_width() - 1 + + tool_ctl.show_key_btn.enable() + tool_ctl.auth_key_value = cfg.AuthKey or "" -- to show auth key + + for i = 1, #fields do + local f = fields[i] + local height = 1 + local label_w = string.len(f[2]) + local val_max_w = (inner_width - label_w) + 1 + local raw = cfg[f[1]] + local val = util.strval(raw) + + if f[1] == "AuthKey" then val = string.rep("*", string.len(val)) end + if f[1] == "LogMode" then val = util.trinary(raw == log.MODE.APPEND, "append", "replace") end + if val == "nil" then val = "" end + + local c = util.trinary(alternate, g_lg_fg_bg, cpair(colors.gray,colors.white)) + alternate = not alternate + + if string.len(val) > val_max_w then + local lines = util.strwrap(val, inner_width) + height = #lines + 1 + end + + local line = Div{parent=setting_list,height=height,fg_bg=c} + TextBox{parent=line,text=f[2],width=string.len(f[2]),fg_bg=cpair(colors.black,line.get_fg_bg().bkg)} + + local textbox + if height > 1 then + textbox = TextBox{parent=line,x=1,y=2,text=val,height=height-1,alignment=LEFT} + else + textbox = TextBox{parent=line,x=label_w+1,y=1,text=val,alignment=RIGHT} + end + + if f[1] == "AuthKey" then tool_ctl.auth_key_textbox = textbox end + end + end + + ---@param def rtu_peri_definition + ---@param idx integer + ---@param type string + local function edit_peri_entry(idx, def, type) + -- set inputs BEFORE calling new_peri() + if def.index ~= nil then tool_ctl.p_idx.set_value(def.index) end + if def.unit == nil then + tool_ctl.p_assign_btn.set_value(1) + else + tool_ctl.p_unit.set_value(def.unit) + tool_ctl.p_assign_btn.set_value(2) + end + + new_peri(def.name, type) + + -- set editing mode AFTER new_peri() + tool_ctl.peri_cfg_editing = idx + end + + local function delete_peri_entry(idx) + table.remove(tmp_cfg.Peripherals, idx) + tool_ctl.gen_peri_summary(tmp_cfg) + end + + -- generate the peripherals summary list + ---@param cfg rtu_config + function tool_ctl.gen_peri_summary(cfg) + peri_list.remove_all() + + for i = 1, #cfg.Peripherals do + local def = cfg.Peripherals[i] ---@type rtu_peri_definition + + local t = ppm.get_type(def.name) + local t_str = " (connect to edit)" + local disconnected = t == nil + + if not disconnected then t_str = "[" .. t .. "]" end + + local desc = " \x1a " + + if type(def.index) == "number" then + desc = desc .. "#" .. def.index .. " " + end + + if type(def.unit) == "number" then + desc = desc .. "for unit " .. def.unit + else + desc = desc .. "for the facility" + end + + local entry = Div{parent=peri_list,height=3} + TextBox{parent=entry,x=1,y=1,height=1,text="@ "..def.name,fg_bg=cpair(colors.black,colors.white)} + TextBox{parent=entry,x=1,y=2,height=1,text=" \x1a "..t_str,fg_bg=cpair(colors.gray,colors.white)} + TextBox{parent=entry,x=1,y=3,height=1,text=desc,fg_bg=cpair(colors.gray,colors.white)} + local edit_btn = PushButton{parent=entry,x=41,y=2,min_width=8,height=1,text="EDIT",callback=function()edit_peri_entry(i,def,t or "")end,fg_bg=cpair(colors.black,colors.blue),active_fg_bg=btn_act_fg_bg,dis_fg_bg=cpair(colors.lightGray,colors.white)} + PushButton{parent=entry,x=41,y=3,min_width=8,height=1,text="DELETE",callback=function()delete_peri_entry(i)end,fg_bg=cpair(colors.black,colors.red),active_fg_bg=btn_act_fg_bg} + + if disconnected then edit_btn.disable() end + end + end + + local function edit_rs_entry(idx) + local def = tmp_cfg.Redstone[idx] ---@type rtu_rs_definition + + tool_ctl.rs_cfg_editing = idx + + local text = "Editing " .. rsio.to_string(def.port) .. " (for " + if PORT_DSGN[def.port] == 1 then + text = text .. "a unit)." + tool_ctl.rs_cfg_unit_l.show() + tool_ctl.rs_cfg_unit.show() + tool_ctl.rs_cfg_unit.set_value(def.unit or 1) + else + tool_ctl.rs_cfg_unit_l.hide(true) + tool_ctl.rs_cfg_unit.hide(true) + text = text .. "the facility)." + end + + local value = 1 + if def.color ~= nil then + value = color_to_idx(def.color) + tool_ctl.rs_cfg_color.enable() + else + tool_ctl.rs_cfg_color.disable() + end + + tool_ctl.rs_cfg_selection.set_value(text) + side.set_value(side_to_idx(def.side)) + bundled.set_value(def.color ~= nil) + tool_ctl.rs_cfg_color.set_value(value) + rs_pane.set_value(3) + end + + local function delete_rs_entry(idx) + table.remove(tmp_cfg.Redstone, idx) + tool_ctl.gen_rs_summary(tmp_cfg) + end + + -- generate the redstone summary list + ---@param cfg rtu_config + function tool_ctl.gen_rs_summary(cfg) + rs_list.remove_all() + + for i = 1, #cfg.Redstone do + local def = cfg.Redstone[i] ---@type rtu_rs_definition + + local name = rsio.to_string(def.port) + local io_dir = util.trinary(rsio.get_io_mode(def.port) == rsio.IO_DIR.IN, "\x1a", "\x1b") + local conn = def.side + local unit = util.strval(def.unit or "F") + + if def.color ~= nil then conn = def.side .. "/" .. rsio.color_name(def.color) end + + local entry = Div{parent=rs_list,height=1} + TextBox{parent=entry,x=1,y=1,width=1,height=1,text=io_dir,fg_bg=cpair(colors.lightGray,colors.white)} + TextBox{parent=entry,x=2,y=1,width=14,height=1,text=name} + TextBox{parent=entry,x=16,y=1,width=string.len(conn),height=1,text=conn,fg_bg=cpair(colors.gray,colors.white)} + TextBox{parent=entry,x=33,y=1,width=1,height=1,text=unit,fg_bg=cpair(colors.gray,colors.white)} + PushButton{parent=entry,x=35,y=1,min_width=6,height=1,text="EDIT",callback=function()edit_rs_entry(i)end,fg_bg=cpair(colors.black,colors.blue),active_fg_bg=btn_act_fg_bg} + PushButton{parent=entry,x=41,y=1,min_width=8,height=1,text="DELETE",callback=function()delete_rs_entry(i)end,fg_bg=cpair(colors.black,colors.red),active_fg_bg=btn_act_fg_bg} + end + end +end + +-- reset terminal screen +local function reset_term() + term.setTextColor(colors.white) + term.setBackgroundColor(colors.black) + term.clear() + term.setCursorPos(1, 1) +end + +-- run the RTU gateway configurator +---@param ask_config? boolean indicate if this is being called by the RTU startup app due to an invalid configuration +function configurator.configure(ask_config) + tool_ctl.ask_config = ask_config == true + tool_ctl.has_config = load_settings(ini_cfg) + load_settings(settings_cfg, true) + + tmp_cfg.Peripherals = deep_copy_peri(ini_cfg.Peripherals) + tmp_cfg.Redstone = deep_copy_rs(ini_cfg.Redstone) + + reset_term() + + ppm.mount_all() + + -- set overridden colors + for i = 1, #style.colors do + term.setPaletteColor(style.colors[i].c, style.colors[i].hex) + end + + local status, error = pcall(function () + local display = DisplayBox{window=term.current(),fg_bg=style.root} + config_view(display) + + while true do + local event, param1, param2, param3 = util.pull_event() + + -- handle event + if event == "timer" then + tcd.handle(param1) + elseif event == "mouse_click" or event == "mouse_up" or event == "mouse_drag" or event == "mouse_scroll" or event == "double_click" then + local m_e = core.events.new_mouse_event(event, param1, param2, param3) + if m_e then display.handle_mouse(m_e) end + elseif event == "char" or event == "key" or event == "key_up" then + local k_e = core.events.new_key_event(event, param1, param2) + if k_e then display.handle_key(k_e) end + elseif event == "paste" then + display.handle_paste(param1) + elseif event == "peripheral_detach" then + ppm.handle_unmount(param1) + tool_ctl.update_peri_list() + elseif event == "peripheral" then + ppm.mount(param1) + tool_ctl.update_peri_list() + end + + if event == "terminate" then return end + end + end) + + -- restore colors + for i = 1, #style.colors do + local r, g, b = term.nativePaletteColor(style.colors[i].c) + term.setPaletteColor(style.colors[i].c, r, g, b) + end + + reset_term() + if not status then + println("configurator error: " .. error) + end + + return status, error +end + +return configurator diff --git a/rtu/panel/front_panel.lua b/rtu/panel/front_panel.lua index 2e5537b..acf45d6 100644 --- a/rtu/panel/front_panel.lua +++ b/rtu/panel/front_panel.lua @@ -109,12 +109,10 @@ local function init(panel, units) unit_hw.register(databus.ps, "unit_hw_" .. i, unit_hw.update) -- unit name identifier (type + index) - local name = util.c(UNIT_TYPE_LABELS[unit.type + 1], " ", unit.index) - local name_box = TextBox{parent=unit_hw_statuses,y=i,x=3,text=name,height=1} + local function get_name(t) return util.c(UNIT_TYPE_LABELS[t + 1], " ", util.trinary(util.is_int(unit.index), unit.index, "")) end + local name_box = TextBox{parent=unit_hw_statuses,y=i,x=3,text=get_name(unit.type),height=1} - name_box.register(databus.ps, "unit_type_" .. i, function (t) - name_box.set_value(util.c(UNIT_TYPE_LABELS[t + 1], " ", unit.index)) - end) + name_box.register(databus.ps, "unit_type_" .. i, function (t) name_box.set_value(get_name(t)) end) -- assignment (unit # or facility) local for_unit = util.trinary(unit.reactor == 0, "\x1a FACIL ", "\x1a UNIT " .. unit.reactor) diff --git a/rtu/rtu.lua b/rtu/rtu.lua index 242bd18..e019e23 100644 --- a/rtu/rtu.lua +++ b/rtu/rtu.lua @@ -5,7 +5,6 @@ local log = require("scada-common.log") local types = require("scada-common.types") local util = require("scada-common.util") -local config = require("rtu.config") local databus = require("rtu.databus") local modbus = require("rtu.modbus") @@ -17,6 +16,51 @@ local ESTABLISH_ACK = comms.ESTABLISH_ACK local MGMT_TYPE = comms.MGMT_TYPE local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE +---@type rtu_config +local config = {} + +rtu.config = config + +-- load the RTU configuration +function rtu.load_config() + if not settings.load("/rtu.settings") then return false end + + config.Peripherals = settings.get("Peripherals") + config.Redstone = settings.get("Redstone") + + config.SpeakerVolume = settings.get("SpeakerVolume") + config.SVR_Channel = settings.get("SVR_Channel") + config.RTU_Channel = settings.get("RTU_Channel") + config.ConnTimeout = settings.get("ConnTimeout") + config.TrustedRange = settings.get("TrustedRange") + config.AuthKey = settings.get("AuthKey") + config.LogMode = settings.get("LogMode") + config.LogPath = settings.get("LogPath") + config.LogDebug = settings.get("LogDebug") + + local cfv = util.new_validator() + + cfv.assert_type_num(config.SpeakerVolume) + cfv.assert_channel(config.SVR_Channel) + cfv.assert_channel(config.RTU_Channel) + cfv.assert_type_int(config.ConnTimeout) + cfv.assert_min(config.ConnTimeout, 2) + cfv.assert_type_num(config.TrustedRange) + cfv.assert_min(config.TrustedRange, 0) + cfv.assert_type_str(config.AuthKey) + + if type(config.AuthKey) == "string" then + local len = string.len(config.AuthKey) + cfv.assert_eq(len == 0 or len >= 8, true) + end + + cfv.assert_type_int(config.LogMode) + cfv.assert_type_str(config.LogPath) + cfv.assert_type_bool(config.LogDebug) + + return cfv.valid() +end + -- create a new RTU unit ---@nodiscard function rtu.init_unit() @@ -175,7 +219,7 @@ function rtu.init_sounder(speaker) function spkr_ctl.continue() if spkr_ctl.playing then if spkr_ctl.speaker ~= nil and spkr_ctl.stream.has_next_block() then - local success = spkr_ctl.speaker.playAudio(spkr_ctl.stream.get_next_block(), config.SOUNDER_VOLUME) + local success = spkr_ctl.speaker.playAudio(spkr_ctl.stream.get_next_block(), config.SpeakerVolume) if not success then log.error(util.c("rtu_sounder(", spkr_ctl.name, "): error playing audio")) end end end @@ -203,11 +247,8 @@ end ---@nodiscard ---@param version string RTU version ---@param nic nic network interface device ----@param rtu_channel integer PLC comms channel ----@param svr_channel integer supervisor server channel ----@param range integer trusted device connection range ---@param conn_watchdog watchdog watchdog reference -function rtu.comms(version, nic, rtu_channel, svr_channel, range, conn_watchdog) +function rtu.comms(version, nic, conn_watchdog) local self = { sv_addr = comms.BROADCAST, seq_num = 0, @@ -218,13 +259,13 @@ function rtu.comms(version, nic, rtu_channel, svr_channel, range, conn_watchdog) local insert = table.insert - comms.set_trusted_range(range) + comms.set_trusted_range(config.TrustedRange) -- PRIVATE FUNCTIONS -- -- configure modem channels nic.closeAll() - nic.open(rtu_channel) + nic.open(config.RTU_Channel) -- send a scada management packet ---@param msg_type MGMT_TYPE @@ -236,7 +277,7 @@ function rtu.comms(version, nic, rtu_channel, svr_channel, range, conn_watchdog) m_pkt.make(msg_type, msg) s_pkt.make(self.sv_addr, self.seq_num, PROTOCOL.SCADA_MGMT, m_pkt.raw_sendable()) - nic.transmit(svr_channel, rtu_channel, s_pkt) + nic.transmit(config.SVR_Channel, config.RTU_Channel, s_pkt) self.seq_num = self.seq_num + 1 end @@ -280,7 +321,7 @@ function rtu.comms(version, nic, rtu_channel, svr_channel, range, conn_watchdog) function public.send_modbus(m_pkt) local s_pkt = comms.scada_packet() s_pkt.make(self.sv_addr, self.seq_num, PROTOCOL.MODBUS_TCP, m_pkt.raw_sendable()) - nic.transmit(svr_channel, rtu_channel, s_pkt) + nic.transmit(config.SVR_Channel, config.RTU_Channel, s_pkt) self.seq_num = self.seq_num + 1 end @@ -365,7 +406,7 @@ function rtu.comms(version, nic, rtu_channel, svr_channel, range, conn_watchdog) local l_chan = packet.scada_frame.local_channel() local src_addr = packet.scada_frame.src_addr() - if l_chan == rtu_channel then + if l_chan == config.RTU_Channel then -- check sequence number if self.r_seq_num == nil then self.r_seq_num = packet.scada_frame.seq_num() diff --git a/rtu/startup.lua b/rtu/startup.lua index f40df57..5e8b304 100644 --- a/rtu/startup.lua +++ b/rtu/startup.lua @@ -15,7 +15,7 @@ local rsio = require("scada-common.rsio") local types = require("scada-common.types") local util = require("scada-common.util") -local config = require("rtu.config") +local configure = require("rtu.configure") local databus = require("rtu.databus") local modbus = require("rtu.modbus") local renderer = require("rtu.renderer") @@ -31,7 +31,7 @@ local sna_rtu = require("rtu.dev.sna_rtu") local sps_rtu = require("rtu.dev.sps_rtu") local turbinev_rtu = require("rtu.dev.turbinev_rtu") -local RTU_VERSION = "v1.6.6" +local RTU_VERSION = "v1.7.0" local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE local RTU_UNIT_HW_STATE = databus.RTU_UNIT_HW_STATE @@ -40,27 +40,26 @@ local println = util.println local println_ts = util.println_ts ---------------------------------------- --- config validation +-- get configuration ---------------------------------------- -local cfv = util.new_validator() +if not rtu.load_config() then + -- try to reconfigure (user action) + local success, error = configure.configure(true) + if success then + assert(rtu.load_config(), "failed to load valid RTU configuration") + else + assert(success, "RTU configuration error: " .. error) + end +end -cfv.assert_channel(config.SVR_CHANNEL) -cfv.assert_channel(config.RTU_CHANNEL) -cfv.assert_type_int(config.TRUSTED_RANGE) -cfv.assert_type_num(config.COMMS_TIMEOUT) -cfv.assert_min(config.COMMS_TIMEOUT, 2) -cfv.assert_type_str(config.LOG_PATH) -cfv.assert_type_int(config.LOG_MODE) -cfv.assert_type_table(config.RTU_DEVICES) -cfv.assert_type_table(config.RTU_REDSTONE) -assert(cfv.valid(), "bad config file: missing/invalid fields") +local config = rtu.config ---------------------------------------- -- log init ---------------------------------------- -log.init(config.LOG_PATH, config.LOG_MODE, config.LOG_DEBUG == true) +log.init(config.LogPath, config.LogMode, config.LogDebug) log.info("========================================") log.info("BOOTING rtu.startup " .. RTU_VERSION) @@ -85,8 +84,8 @@ local function main() ppm.mount_all() -- message authentication init - if type(config.AUTH_KEY) == "string" then - network.init_mac(config.AUTH_KEY) + if type(config.AuthKey) == "string" then + network.init_mac(config.AuthKey) end -- get modem @@ -139,173 +138,174 @@ local function main() local units = __shared_memory.rtu_sys.units - local rtu_redstone = config.RTU_REDSTONE - local rtu_devices = config.RTU_DEVICES + local rtu_redstone = config.Redstone + local rtu_devices = config.Peripherals - -- configure RTU gateway based on config file definitions - local function configure() + -- configure RTU gateway based on settings file definitions + local function sys_config() -- redstone interfaces + local rs_rtus = {} + + -- go through redstone definitions list for entry_idx = 1, #rtu_redstone do - local rs_rtu = redstone_rtu.new() - local io_table = rtu_redstone[entry_idx].io ---@type table - local io_reactor = rtu_redstone[entry_idx].for_reactor ---@type integer + local entry = rtu_redstone[entry_idx] ---@type rtu_rs_definition + local assignment + local for_reactor = entry.unit + local iface_name = util.trinary(entry.color ~= nil, util.c(entry.side, "/", rsio.color_name(entry.color)), entry.side) - -- CHECK: reactor ID must be >= to 1 - if (not util.is_int(io_reactor)) or (io_reactor < 0) then - local message = util.c("configure> redstone entry #", entry_idx, " : ", io_reactor, " isn't an integer >= 0") - println(message) - log.fatal(message) - return false - end - - -- CHECK: io table exists - if type(io_table) ~= "table" then - local message = util.c("configure> redstone entry #", entry_idx, " no IO table found") - println(message) - log.fatal(message) - return false - end - - local capabilities = {} - - log.debug(util.c("configure> starting redstone RTU I/O linking for reactor ", io_reactor, "...")) - - local continue = true - - -- CHECK: no duplicate entries - for i = 1, #units do - local unit = units[i] ---@type rtu_unit_registry_entry - if unit.reactor == io_reactor and unit.type == RTU_UNIT_TYPE.REDSTONE then - -- duplicate entry - local message = util.c("configure> skipping definition block #", entry_idx, " for reactor ", io_reactor, - " with already defined redstone I/O") - println(message) - log.warning(message) - continue = false - break + if util.is_int(entry.unit) and entry.unit > 0 and entry.unit < 5 then + ---@cast for_reactor integer + assignment = "reactor unit " .. entry.unit + if rs_rtus[for_reactor] == nil then + log.debug(util.c("sys_config> allocated redstone RTU for reactor unit ", entry.unit)) + rs_rtus[for_reactor] = { rtu = redstone_rtu.new(), capabilities = {} } end + elseif entry.unit == nil then + assignment = "facility" + for_reactor = 0 + if rs_rtus[for_reactor] == nil then + log.debug(util.c("sys_config> allocated redstone RTU for the facility")) + rs_rtus[for_reactor] = { rtu = redstone_rtu.new(), capabilities = {} } + end + else + local message = util.c("sys_config> invalid unit assignment at block index #", entry_idx) + println(message) + log.fatal(message) + return false end - -- not a duplicate - if continue then - for i = 1, #io_table do - local valid = false - local conf = io_table[i] + -- verify configuration + local valid = false + if rsio.is_valid_port(entry.port) and rsio.is_valid_side(entry.side) then + valid = util.trinary(entry.color == nil, true, rsio.is_color(entry.color)) + end - -- verify configuration - if rsio.is_valid_port(conf.port) and rsio.is_valid_side(conf.side) then - if conf.bundled_color then - valid = rsio.is_color(conf.bundled_color) - else - valid = true - end - end + local rs_rtu = rs_rtus[for_reactor].rtu + local capabilities = rs_rtus[for_reactor].capabilities - if not valid then - local message = util.c("configure> invalid redstone definition at index ", i, " in definition block #", entry_idx, - " (for reactor ", io_reactor, ")") + if not valid then + local message = util.c("sys_config> invalid redstone definition at block index #", entry_idx) + println(message) + log.fatal(message) + return false + else + -- link redstone in RTU + local mode = rsio.get_io_mode(entry.port) + if mode == rsio.IO_MODE.DIGITAL_IN then + -- can't have duplicate inputs + if util.table_contains(capabilities, entry.port) then + local message = util.c("sys_config> skipping duplicate input for port ", rsio.to_string(entry.port), " on side ", iface_name) println(message) - log.fatal(message) - return false + log.warning(message) else - -- link redstone in RTU - local mode = rsio.get_io_mode(conf.port) - if mode == rsio.IO_MODE.DIGITAL_IN then - -- can't have duplicate inputs - if util.table_contains(capabilities, conf.port) then - local message = util.c("configure> skipping duplicate input for port ", rsio.to_string(conf.port), " on side ", conf.side) - println(message) - log.warning(message) - else - rs_rtu.link_di(conf.side, conf.bundled_color) - end - elseif mode == rsio.IO_MODE.DIGITAL_OUT then - rs_rtu.link_do(conf.side, conf.bundled_color) - elseif mode == rsio.IO_MODE.ANALOG_IN then - -- can't have duplicate inputs - if util.table_contains(capabilities, conf.port) then - local message = util.c("configure> skipping duplicate input for port ", rsio.to_string(conf.port), " on side ", conf.side) - println(message) - log.warning(message) - else - rs_rtu.link_ai(conf.side) - end - elseif mode == rsio.IO_MODE.ANALOG_OUT then - rs_rtu.link_ao(conf.side) - else - -- should be unreachable code, we already validated ports - log.error("configure> fell through if chain attempting to identify IO mode", true) - println("configure> encountered a software error, check logs") - return false - end - - table.insert(capabilities, conf.port) - - log.debug(util.c("configure> linked redstone ", #capabilities, ": ", rsio.to_string(conf.port), - " (", conf.side, ") for reactor ", io_reactor)) + rs_rtu.link_di(entry.side, entry.color) end + elseif mode == rsio.IO_MODE.DIGITAL_OUT then + rs_rtu.link_do(entry.side, entry.color) + elseif mode == rsio.IO_MODE.ANALOG_IN then + -- can't have duplicate inputs + if util.table_contains(capabilities, entry.port) then + local message = util.c("sys_config> skipping duplicate input for port ", rsio.to_string(entry.port), " on side ", iface_name) + println(message) + log.warning(message) + else + rs_rtu.link_ai(entry.side) + end + elseif mode == rsio.IO_MODE.ANALOG_OUT then + rs_rtu.link_ao(entry.side) + else + -- should be unreachable code, we already validated ports + log.error("sys_config> fell through if chain attempting to identify IO mode at block index #" .. entry_idx, true) + println("sys_config> encountered a software error, check logs") + return false end - ---@class rtu_unit_registry_entry - local unit = { - uid = 0, ---@type integer - name = "redstone_io", ---@type string - type = RTU_UNIT_TYPE.REDSTONE, ---@type RTU_UNIT_TYPE - index = entry_idx, ---@type integer - reactor = io_reactor, ---@type integer - device = capabilities, ---@type table use device field for redstone ports - is_multiblock = false, ---@type boolean - formed = nil, ---@type boolean|nil - hw_state = RTU_UNIT_HW_STATE.OK, ---@type RTU_UNIT_HW_STATE - rtu = rs_rtu, ---@type rtu_device|rtu_rs_device - modbus_io = modbus.new(rs_rtu, false), - pkt_queue = nil, ---@type mqueue|nil - thread = nil ---@type parallel_thread|nil - } + table.insert(capabilities, entry.port) - table.insert(units, unit) - - local for_message = "facility" - if io_reactor > 0 then - for_message = util.c("reactor ", io_reactor) - end - - log.info(util.c("configure> initialized RTU unit #", #units, ": redstone_io (redstone) [1] for ", for_message)) - - unit.uid = #units - - databus.tx_unit_hw_status(unit.uid, unit.hw_state) + log.debug(util.c("sys_config> linked redstone ", #capabilities, ": ", rsio.to_string(entry.port), " (", iface_name, ") for ", assignment)) end end + -- create unit entries for redstone RTUs + for for_reactor, def in pairs(rs_rtus) do + ---@class rtu_unit_registry_entry + local unit = { + uid = 0, ---@type integer + name = "redstone_io", ---@type string + type = RTU_UNIT_TYPE.REDSTONE, ---@type RTU_UNIT_TYPE + index = false, ---@type integer|false + reactor = for_reactor, ---@type integer + device = def.capabilities, ---@type table use device field for redstone ports + is_multiblock = false, ---@type boolean + formed = nil, ---@type boolean|nil + hw_state = RTU_UNIT_HW_STATE.OK, ---@type RTU_UNIT_HW_STATE + rtu = def.rtu, ---@type rtu_device|rtu_rs_device + modbus_io = modbus.new(def.rtu, false), + pkt_queue = nil, ---@type mqueue|nil + thread = nil ---@type parallel_thread|nil + } + + table.insert(units, unit) + + local for_message = "facility" + if util.is_int(for_reactor) then + for_message = util.c("reactor unit ", for_reactor) + end + + log.info(util.c("sys_config> initialized RTU unit #", #units, ": redstone_io (redstone) [1] for ", for_message)) + + unit.uid = #units + + databus.tx_unit_hw_status(unit.uid, unit.hw_state) + end + -- mounted peripherals for i = 1, #rtu_devices do - local name = rtu_devices[i].name - local index = rtu_devices[i].index - local for_reactor = rtu_devices[i].for_reactor + local entry = rtu_devices[i] ---@type rtu_peri_definition + local name = entry.name + local index = entry.index + local for_reactor = util.trinary(entry.unit == nil, 0, entry.unit) -- CHECK: name is a string if type(name) ~= "string" then - local message = util.c("configure> device entry #", i, ": device ", name, " isn't a string") + local message = util.c("sys_config> device entry #", i, ": device ", name, " isn't a string") println(message) log.fatal(message) return false end - -- CHECK: index is an integer >= 1 - if (not util.is_int(index)) or (index <= 0) then - local message = util.c("configure> device entry #", i, ": index ", index, " isn't an integer >= 1") + -- CHECK: index type + if (index ~= nil) and (not util.is_int(index)) then + local message = util.c("sys_config> device entry #", i, ": index ", index, " isn't valid") println(message) log.fatal(message) return false end + -- CHECK: index range + local function validate_index(min, max) + if (not util.is_int(index)) or ((index < min) and (max ~= nil and index > max)) then + local message = util.c("sys_config> device entry #", i, ": index ", index, " isn't >= ", min) + if max ~= nil then message = util.c(message, " and <= ", max) end + println(message) + log.fatal(message) + return false + else return true end + end + -- CHECK: reactor is an integer >= 0 - if (not util.is_int(for_reactor)) or (for_reactor < 0) then - local message = util.c("configure> device entry #", i, ": reactor ", for_reactor, " isn't an integer >= 0") - println(message) - log.fatal(message) - return false + local function validate_assign(for_facility) + if for_facility and for_reactor ~= 0 then + local message = util.c("sys_config> device entry #", i, ": must only be for the facility") + println(message) + log.fatal(message) + return false + elseif (not for_facility) and ((not util.is_int(for_reactor)) or (for_reactor < 1) or (for_reactor > 4)) then + local message = util.c("sys_config> device entry #", i, ": unit assignment ", for_reactor, " isn't vaild") + println(message) + log.fatal(message) + return false + else return true end end local device = ppm.get_periph(name) @@ -318,7 +318,7 @@ local function main() local faulted = nil ---@type boolean|nil if device == nil then - local message = util.c("configure> '", name, "' not found, using placeholder") + local message = util.c("sys_config> '", name, "' not found, using placeholder") println(message) log.warning(message) @@ -330,70 +330,93 @@ local function main() if type == "boilerValve" then -- boiler multiblock + if not validate_index(1, 2) then return false end + if not validate_assign() then return false end + rtu_type = RTU_UNIT_TYPE.BOILER_VALVE rtu_iface, faulted = boilerv_rtu.new(device) is_multiblock = true formed = device.isFormed() if formed == ppm.UNDEFINED_FIELD or formed == ppm.ACCESS_FAULT then - println_ts(util.c("configure> failed to check if '", name, "' is formed")) - log.fatal(util.c("configure> failed to check if '", name, "' is a formed boiler multiblock")) + println_ts(util.c("sys_config> failed to check if '", name, "' is formed")) + log.fatal(util.c("sys_config> failed to check if '", name, "' is a formed boiler multiblock")) return false end elseif type == "turbineValve" then -- turbine multiblock + if not validate_index(1, 3) then return false end + if not validate_assign() then return false end + rtu_type = RTU_UNIT_TYPE.TURBINE_VALVE rtu_iface, faulted = turbinev_rtu.new(device) is_multiblock = true formed = device.isFormed() if formed == ppm.UNDEFINED_FIELD or formed == ppm.ACCESS_FAULT then - println_ts(util.c("configure> failed to check if '", name, "' is formed")) - log.fatal(util.c("configure> failed to check if '", name, "' is a formed turbine multiblock")) + println_ts(util.c("sys_config> failed to check if '", name, "' is formed")) + log.fatal(util.c("sys_config> failed to check if '", name, "' is a formed turbine multiblock")) return false end elseif type == "dynamicValve" then -- dynamic tank multiblock + if entry.unit == nil then + if not validate_index(1, 4) then return false end + if not validate_assign(true) then return false end + else + if not validate_index(1, 1) then return false end + if not validate_assign() then return false end + end + rtu_type = RTU_UNIT_TYPE.DYNAMIC_VALVE rtu_iface, faulted = dynamicv_rtu.new(device) is_multiblock = true formed = device.isFormed() if formed == ppm.UNDEFINED_FIELD or formed == ppm.ACCESS_FAULT then - println_ts(util.c("configure> failed to check if '", name, "' is formed")) - log.fatal(util.c("configure> failed to check if '", name, "' is a formed dynamic tank multiblock")) + println_ts(util.c("sys_config> failed to check if '", name, "' is formed")) + log.fatal(util.c("sys_config> failed to check if '", name, "' is a formed dynamic tank multiblock")) return false end elseif type == "inductionPort" then -- induction matrix multiblock + if not validate_assign(true) then return false end + rtu_type = RTU_UNIT_TYPE.IMATRIX rtu_iface, faulted = imatrix_rtu.new(device) is_multiblock = true formed = device.isFormed() if formed == ppm.UNDEFINED_FIELD or formed == ppm.ACCESS_FAULT then - println_ts(util.c("configure> failed to check if '", name, "' is formed")) - log.fatal(util.c("configure> failed to check if '", name, "' is a formed induction matrix multiblock")) + println_ts(util.c("sys_config> failed to check if '", name, "' is formed")) + log.fatal(util.c("sys_config> failed to check if '", name, "' is a formed induction matrix multiblock")) return false end elseif type == "spsPort" then -- SPS multiblock + if not validate_assign(true) then return false end + rtu_type = RTU_UNIT_TYPE.SPS rtu_iface, faulted = sps_rtu.new(device) is_multiblock = true formed = device.isFormed() if formed == ppm.UNDEFINED_FIELD or formed == ppm.ACCESS_FAULT then - println_ts(util.c("configure> failed to check if '", name, "' is formed")) - log.fatal(util.c("configure> failed to check if '", name, "' is a formed SPS multiblock")) + println_ts(util.c("sys_config> failed to check if '", name, "' is formed")) + log.fatal(util.c("sys_config> failed to check if '", name, "' is a formed SPS multiblock")) return false end elseif type == "solarNeutronActivator" then -- SNA + if not validate_assign() then return false end + rtu_type = RTU_UNIT_TYPE.SNA rtu_iface, faulted = sna_rtu.new(device) elseif type == "environmentDetector" then -- advanced peripherals environment detector + if not validate_index(1) then return false end + if not validate_assign(entry.unit == nil) then return false end + rtu_type = RTU_UNIT_TYPE.ENV_DETECTOR rtu_iface, faulted = envd_rtu.new(device) elseif type == ppm.VIRTUAL_DEVICE_TYPE then @@ -401,7 +424,7 @@ local function main() rtu_type = RTU_UNIT_TYPE.VIRTUAL rtu_iface = rtu.init_unit().interface() else - local message = util.c("configure> device '", name, "' is not a known type (", type, ")") + local message = util.c("sys_config> device '", name, "' is not a known type (", type, ")") println_ts(message) log.fatal(message) return false @@ -409,12 +432,12 @@ local function main() if is_multiblock then if not formed then - log.info(util.c("configure> device '", name, "' is not formed")) + log.info(util.c("sys_config> device '", name, "' is not formed")) elseif faulted then -- sometimes there is a race condition on server boot where it reports formed, but -- the other functions are not yet defined (that's the theory at least). mark as unformed to attempt connection later formed = false - log.warning(util.c("configure> device '", name, "' is formed, but initialization had one or more faults: marked as unformed")) + log.warning(util.c("sys_config> device '", name, "' is formed, but initialization had one or more faults: marked as unformed")) end end @@ -423,7 +446,7 @@ local function main() uid = 0, ---@type integer name = name, ---@type string type = rtu_type, ---@type RTU_UNIT_TYPE - index = index, ---@type integer + index = index or false, ---@type integer|false reactor = for_reactor, ---@type integer device = device, ---@type table is_multiblock = is_multiblock, ---@type boolean @@ -444,7 +467,7 @@ local function main() for_message = util.c("reactor ", for_reactor) end - log.info(util.c("configure> initialized RTU unit #", #units, ": ", name, " (", types.rtu_type_to_string(rtu_type), ") [", index, "] for ", for_message)) + log.info(util.c("sys_config> initialized RTU unit #", #units, ": ", name, " (", types.rtu_type_to_string(rtu_type), ") [", index, "] for ", for_message)) rtu_unit.uid = #units @@ -465,7 +488,6 @@ local function main() databus.tx_unit_hw_status(rtu_unit.uid, rtu_unit.hw_state) end - -- we made it through all that trusting-user-to-write-a-config-file chaos return true end @@ -475,9 +497,9 @@ local function main() local rtu_state = __shared_memory.rtu_state - log.debug("boot> running configure()") + log.debug("boot> running sys_config()") - if configure() then + if sys_config() then -- start UI local message rtu_state.fp_ok, message = renderer.try_start_ui(units) @@ -502,12 +524,11 @@ local function main() databus.tx_hw_spkr_count(#__shared_memory.rtu_dev.sounders) -- start connection watchdog - smem_sys.conn_watchdog = util.new_watchdog(config.COMMS_TIMEOUT) + smem_sys.conn_watchdog = util.new_watchdog(config.ConnTimeout) log.debug("startup> conn watchdog started") -- setup comms - smem_sys.rtu_comms = rtu.comms(RTU_VERSION, smem_sys.nic, config.RTU_CHANNEL, config.SVR_CHANNEL, - config.TRUSTED_RANGE, smem_sys.conn_watchdog) + smem_sys.rtu_comms = rtu.comms(RTU_VERSION, smem_sys.nic, smem_sys.conn_watchdog) log.debug("startup> comms init") -- init threads diff --git a/rtu/threads.lua b/rtu/threads.lua index d76d062..f3147e7 100644 --- a/rtu/threads.lua +++ b/rtu/threads.lua @@ -28,6 +28,147 @@ local UNIT_HW_STATE = databus.RTU_UNIT_HW_STATE local MAIN_CLOCK = 0.5 -- (2Hz, 10 ticks) local COMMS_SLEEP = 100 -- (100ms, 2 ticks) +---@param smem rtu_shared_memory +---@param println_ts function +---@param iface string +---@param type string +---@param device table +---@param unit rtu_unit_registry_entry +local function handle_unit_mount(smem, println_ts, iface, type, device, unit) + local sys = smem.rtu_sys + + -- find disconnected device to reconnect + -- note: cannot check isFormed as that would yield this coroutine and consume events + if unit.name == iface then + local resend_advert, faulted, unknown, invalid = false, false, false, false + + local function fail(msg) + invalid = true + log.error(msg .. " in config") + end + + -- found, re-link + unit.device = device + + if unit.type == RTU_UNIT_TYPE.VIRTUAL then + resend_advert = true + if type == "boilerValve" then + -- boiler multiblock + if unit.reactor < 1 or unit.reactor > 4 then fail(util.c("boiler '", unit.name, "' cannot init, not assigned to a valid unit")) end + if (unit.index == false) or unit.index < 1 or unit.index > 2 then fail(util.c("boiler '", unit.name, "' cannot init, invalid index provided")) end + + unit.type = RTU_UNIT_TYPE.BOILER_VALVE + elseif type == "turbineValve" then + -- turbine multiblock + if unit.reactor < 1 or unit.reactor > 4 then fail(util.c("turbine '", unit.name, "' cannot init, not assigned to a valid unit")) end + if (unit.index == false) or unit.index < 1 or unit.index > 3 then fail(util.c("turbine '", unit.name, "' cannot init, invalid index provided")) end + + unit.type = RTU_UNIT_TYPE.TURBINE_VALVE + elseif type == "dynamicValve" then + -- dynamic tank multiblock + if unit.reactor < 0 or unit.reactor > 4 then fail(util.c("dynamic tank '", unit.name, "' cannot init, no valid assignment provided")) end + + if (unit.reactor == 0 and ((unit.index == false) or unit.index < 1 or unit.index > 4)) or + (unit.reactor > 0 and unit.index ~= 1) then + fail(util.c("dynamic tank '", unit.name, "' cannot init, invalid index provided")) + end + + unit.type = RTU_UNIT_TYPE.DYNAMIC_VALVE + elseif type == "inductionPort" then + -- induction matrix multiblock + if unit.reactor ~= 0 then fail(util.c("induction matrix '", unit.name, "' cannot init, not assigned to facility")) end + + unit.type = RTU_UNIT_TYPE.IMATRIX + elseif type == "spsPort" then + -- SPS multiblock + if unit.reactor ~= 0 then fail(util.c("SPS '", unit.name, "' cannot init, not assigned to facility")) end + + unit.type = RTU_UNIT_TYPE.SPS + elseif type == "solarNeutronActivator" then + -- SNA + if unit.reactor < 1 or unit.reactor > 4 then fail(util.c("SNA '", unit.name, "' cannot init, not assigned to a valid unit")) end + + unit.type = RTU_UNIT_TYPE.SNA + elseif type == "environmentDetector" then + -- advanced peripherals environment detector + if unit.reactor < 0 or unit.reactor > 4 then fail(util.c("environment detector '", unit.name, "' cannot init, no valid assignment provided")) end + if (unit.index == false) or unit.index < 1 then fail(util.c("environment detector '", unit.name, "' cannot init, invalid index provided")) end + + unit.type = RTU_UNIT_TYPE.ENV_DETECTOR + else + resend_advert = false + log.error(util.c("virtual device '", unit.name, "' cannot init to an unknown type (", type, ")")) + end + + databus.tx_unit_hw_type(unit.uid, unit.type) + end + + -- if disconnected on startup, config wouldn't have been validated + -- checking now that it has connected; the config isn't valid, so don't connect it + if invalid then + unit.hw_state = UNIT_HW_STATE.OFFLINE + databus.tx_unit_hw_status(unit.uid, unit.hw_state) + return + end + + -- note for multiblock structures: if not formed, indexing the multiblock functions results in a PPM fault + + if unit.type == RTU_UNIT_TYPE.BOILER_VALVE then + unit.rtu, faulted = boilerv_rtu.new(device) + unit.formed = util.trinary(faulted, false, nil) + elseif unit.type == RTU_UNIT_TYPE.TURBINE_VALVE then + unit.rtu, faulted = turbinev_rtu.new(device) + unit.formed = util.trinary(faulted, false, nil) + elseif unit.type == RTU_UNIT_TYPE.DYNAMIC_VALVE then + unit.rtu, faulted = dynamicv_rtu.new(device) + unit.formed = util.trinary(faulted, false, nil) + elseif unit.type == RTU_UNIT_TYPE.IMATRIX then + unit.rtu, faulted = imatrix_rtu.new(device) + unit.formed = util.trinary(faulted, false, nil) + elseif unit.type == RTU_UNIT_TYPE.SPS then + unit.rtu, faulted = sps_rtu.new(device) + unit.formed = util.trinary(faulted, false, nil) + elseif unit.type == RTU_UNIT_TYPE.SNA then + unit.rtu, faulted = sna_rtu.new(device) + elseif unit.type == RTU_UNIT_TYPE.ENV_DETECTOR then + unit.rtu, faulted = envd_rtu.new(device) + else + unknown = true + log.error(util.c("failed to identify reconnected RTU unit type (", unit.name, ")"), true) + end + + if unit.is_multiblock then + unit.hw_state = UNIT_HW_STATE.UNFORMED + if unit.formed == false then + log.info(util.c("assuming ", unit.name, " is not formed due to PPM faults while initializing")) + end + elseif faulted then + unit.hw_state = UNIT_HW_STATE.FAULTED + elseif not unknown then + unit.hw_state = UNIT_HW_STATE.OK + else + unit.hw_state = UNIT_HW_STATE.OFFLINE + end + + databus.tx_unit_hw_status(unit.uid, unit.hw_state) + + if not unknown then + unit.modbus_io = modbus.new(unit.rtu, true) + + local type_name = types.rtu_type_to_string(unit.type) + local message = util.c("reconnected the ", type_name, " on interface ", unit.name) + println_ts(message) + log.info(message) + + if resend_advert then + sys.rtu_comms.send_advertisement(sys.units) + else + sys.rtu_comms.send_remounted(unit.uid) + end + end + end +end + -- main thread ---@nodiscard ---@param smem rtu_shared_memory @@ -180,102 +321,7 @@ function threads.thread__main(smem) else -- relink lost peripheral to correct unit entry for i = 1, #units do - local unit = units[i] ---@type rtu_unit_registry_entry - - -- find disconnected device to reconnect - -- note: cannot check isFormed as that would yield this coroutine and consume events - if unit.name == param1 then - local resend_advert = false - local faulted = false - local unknown = false - - -- found, re-link - unit.device = device - - if unit.type == RTU_UNIT_TYPE.VIRTUAL then - resend_advert = true - if type == "boilerValve" then - -- boiler multiblock - unit.type = RTU_UNIT_TYPE.BOILER_VALVE - elseif type == "turbineValve" then - -- turbine multiblock - unit.type = RTU_UNIT_TYPE.TURBINE_VALVE - elseif type == "inductionPort" then - -- induction matrix multiblock - unit.type = RTU_UNIT_TYPE.IMATRIX - elseif type == "spsPort" then - -- SPS multiblock - unit.type = RTU_UNIT_TYPE.SPS - elseif type == "solarNeutronActivator" then - -- SNA - unit.type = RTU_UNIT_TYPE.SNA - elseif type == "environmentDetector" then - -- advanced peripherals environment detector - unit.type = RTU_UNIT_TYPE.ENV_DETECTOR - else - resend_advert = false - log.error(util.c("virtual device '", unit.name, "' cannot init to an unknown type (", type, ")")) - end - - databus.tx_unit_hw_type(unit.uid, unit.type) - end - - -- note for multiblock structures: if not formed, indexing the multiblock functions results in a PPM fault - - if unit.type == RTU_UNIT_TYPE.BOILER_VALVE then - unit.rtu, faulted = boilerv_rtu.new(device) - unit.formed = util.trinary(faulted, false, nil) - elseif unit.type == RTU_UNIT_TYPE.TURBINE_VALVE then - unit.rtu, faulted = turbinev_rtu.new(device) - unit.formed = util.trinary(faulted, false, nil) - elseif unit.type == RTU_UNIT_TYPE.DYNAMIC_VALVE then - unit.rtu, faulted = dynamicv_rtu.new(device) - unit.formed = util.trinary(faulted, false, nil) - elseif unit.type == RTU_UNIT_TYPE.IMATRIX then - unit.rtu, faulted = imatrix_rtu.new(device) - unit.formed = util.trinary(faulted, false, nil) - elseif unit.type == RTU_UNIT_TYPE.SPS then - unit.rtu, faulted = sps_rtu.new(device) - unit.formed = util.trinary(faulted, false, nil) - elseif unit.type == RTU_UNIT_TYPE.SNA then - unit.rtu, faulted = sna_rtu.new(device) - elseif unit.type == RTU_UNIT_TYPE.ENV_DETECTOR then - unit.rtu, faulted = envd_rtu.new(device) - else - unknown = true - log.error(util.c("failed to identify reconnected RTU unit type (", unit.name, ")"), true) - end - - if unit.is_multiblock then - unit.hw_state = UNIT_HW_STATE.UNFORMED - if unit.formed == false then - log.info(util.c("assuming ", unit.name, " is not formed due to PPM faults while initializing")) - end - elseif faulted then - unit.hw_state = UNIT_HW_STATE.FAULTED - elseif not unknown then - unit.hw_state = UNIT_HW_STATE.OK - else - unit.hw_state = UNIT_HW_STATE.OFFLINE - end - - databus.tx_unit_hw_status(unit.uid, unit.hw_state) - - if not unknown then - unit.modbus_io = modbus.new(unit.rtu, true) - - local type_name = types.rtu_type_to_string(unit.type) - local message = util.c("reconnected the ", type_name, " on interface ", unit.name) - println_ts(message) - log.info(message) - - if resend_advert then - rtu_comms.send_advertisement(units) - else - rtu_comms.send_remounted(unit.uid) - end - end - end + handle_unit_mount(smem, println_ts, param1, type, device, units[i]) end end end diff --git a/scada-common/comms.lua b/scada-common/comms.lua index 07abdb8..25fcea1 100644 --- a/scada-common/comms.lua +++ b/scada-common/comms.lua @@ -17,7 +17,7 @@ local max_distance = nil local comms = {} -- protocol/data version (protocol/data independent changes tracked by util.lua version) -comms.version = "2.4.1" +comms.version = "2.4.2" ---@enum PROTOCOL local PROTOCOL = { diff --git a/scada-common/log.lua b/scada-common/log.lua index 8a42e45..f876eaa 100644 --- a/scada-common/log.lua +++ b/scada-common/log.lua @@ -13,6 +13,7 @@ local MODE = { APPEND = 0, NEW = 1 } log.MODE = MODE local logger = { + not_ready = true, path = "/log.txt", mode = MODE.APPEND, debug = false, @@ -32,6 +33,8 @@ local free_space = fs.getFreeSpace -- private log write function ---@param msg string local function _log(msg) + if logger.not_ready then return end + local out_of_space = false local time_stamp = os.date("[%c] ") local stamped = time_stamp .. util.strval(msg) @@ -94,6 +97,8 @@ function log.init(path, write_mode, include_debug, dmesg_redirect) else logger.dmesg_out = term.current() end + + logger.not_ready = false end -- close the log file handle diff --git a/scada-common/ppm.lua b/scada-common/ppm.lua index 6af4e2f..d54fec8 100644 --- a/scada-common/ppm.lua +++ b/scada-common/ppm.lua @@ -161,10 +161,10 @@ local function peri_init(iface) setmetatable(self.device, mt) - return { - type = self.type, - dev = self.device - } + ---@class ppm_entry + local entry = { type = self.type, dev = self.device } + + return entry end ---------------------- @@ -310,7 +310,11 @@ function ppm.list_avail() return peripheral.getNames() end -- list mounted peripherals ---@nodiscard ---@return table mounts -function ppm.list_mounts() return ppm_sys.mounts end +function ppm.list_mounts() + local list = {} + for k, v in pairs(ppm_sys.mounts) do list[k] = v end + return list +end -- get a mounted peripheral side/interface by device table ---@nodiscard diff --git a/scada-common/rsio.lua b/scada-common/rsio.lua index 4d98c2f..4585bfd 100644 --- a/scada-common/rsio.lua +++ b/scada-common/rsio.lua @@ -82,46 +82,59 @@ rsio.IO_LVL = IO_LVL rsio.IO_DIR = IO_DIR rsio.IO_MODE = IO_MODE rsio.IO = IO_PORT +rsio.NUM_PORTS = IO_PORT.U_EMER_COOL + +-- self checks + +local dup_chk = {} +for _, v in pairs(IO_PORT) do + assert(dup_chk[v] ~= true, "duplicate in port list") + dup_chk[v] = true +end + +assert(#dup_chk == rsio.NUM_PORTS, "port list malformed") --#endregion --#region Utility Functions +local PORT_NAMES = { + "F_SCRAM", + "F_ACK", + "R_SCRAM", + "R_RESET", + "R_ENABLE", + "U_ACK", + "F_ALARM", + "F_ALARM_ANY", + "WASTE_PU", + "WASTE_PO", + "WASTE_POPL", + "WASTE_AM", + "R_ACTIVE", + "R_AUTO_CTRL", + "R_SCRAMMED", + "R_AUTO_SCRAM", + "R_HIGH_DMG", + "R_HIGH_TEMP", + "R_LOW_COOLANT", + "R_EXCESS_HC", + "R_EXCESS_WS", + "R_INSUFF_FUEL", + "R_PLC_FAULT", + "R_PLC_TIMEOUT", + "U_ALARM", + "U_EMER_COOL" +} + +assert(rsio.NUM_PORTS == #PORT_NAMES, "port names length incorrect") + -- port to string ---@nodiscard ---@param port IO_PORT function rsio.to_string(port) - local names = { - "F_SCRAM", - "F_ACK", - "R_SCRAM", - "R_RESET", - "R_ENABLE", - "U_ACK", - "F_ALARM", - "F_ALARM_ANY", - "WASTE_PU", - "WASTE_PO", - "WASTE_POPL", - "WASTE_AM", - "R_ACTIVE", - "R_AUTO_CTRL", - "R_SCRAMMED", - "R_AUTO_SCRAM", - "R_HIGH_DMG", - "R_HIGH_TEMP", - "R_LOW_COOLANT", - "R_EXCESS_HC", - "R_EXCESS_WS", - "R_INSUFF_FUEL", - "R_PLC_FAULT", - "R_PLC_TIMEOUT", - "U_ALARM", - "U_EMER_COOL" - } - - if util.is_int(port) and port > 0 and port <= #names then - return names[port] + if util.is_int(port) and port > 0 and port <= #PORT_NAMES then + return PORT_NAMES[port] else return "UNKNOWN" end @@ -266,12 +279,24 @@ end -- check if a color is a valid single color ---@nodiscard ----@param color integer +---@param color any ---@return boolean valid function rsio.is_color(color) return util.is_int(color) and (color > 0) and (_B_AND(color, (color - 1)) == 0) end +-- color to string +---@nodiscard +---@param color color +---@return string +function rsio.color_name(color) + local color_name_map = { [colors.red] = "red", [colors.orange] = "orange", [colors.yellow] = "yellow", [colors.lime] = "lime", [colors.green] = "green", [colors.cyan] = "cyan", [colors.lightBlue] = "lightBlue", [colors.blue] = "blue", [colors.purple] = "purple", [colors.magenta] = "magenta", [colors.pink] = "pink", [colors.white] = "white", [colors.lightGray] = "lightGray", [colors.gray] = "gray", [colors.black] = "black", [colors.brown] = "brown" } + + if rsio.is_color(color) then + return color_name_map[color] + else return "unknown" end +end + --#endregion --#region Digital I/O diff --git a/scada-common/types.lua b/scada-common/types.lua index da872a2..d6fc7af 100644 --- a/scada-common/types.lua +++ b/scada-common/types.lua @@ -63,7 +63,7 @@ function types.new_zero_coordinate() return { x = 0, y = 0, z = 0 } end ---@class rtu_advertisement ---@field type RTU_UNIT_TYPE ----@field index integer +---@field index integer|false ---@field reactor integer ---@field rsio table|nil @@ -252,6 +252,14 @@ types.ALARM_STATE_NAMES = { -- STRING TYPES -- --#region +---@alias side +---|"top" +---|"bottom" +---|"left" +---|"right" +---|"front" +---|"back" + ---@alias os_event ---| "alarm" ---| "char" diff --git a/scada-common/util.lua b/scada-common/util.lua index eb3fdab..8257a2d 100644 --- a/scada-common/util.lua +++ b/scada-common/util.lua @@ -14,11 +14,15 @@ local print = print local tostring = tostring local type = type +local t_concat = table.concat +local t_insert = table.insert +local t_unpack = table.unpack + ---@class util local util = {} -- scada-common version -util.version = "1.1.5" +util.version = "1.1.9" util.TICK_TIME_S = 0.05 util.TICK_TIME_MS = 50 @@ -70,7 +74,7 @@ function util.strval(val) if t == "string" then return val end -- this depends on Lua short-circuiting the or check for metatables (note: metatables won't have metatables) if (t == "table" and (getmetatable(val) == nil or getmetatable(val).__tostring == nil)) or t == "function" then - return table.concat{"[", tostring(val), "]"} + return t_concat{"[", tostring(val), "]"} else return tostring(val) end end @@ -90,7 +94,7 @@ function util.pad(str, n) local lpad = math.floor((n - len) / 2) local rpad = (n - len) - lpad - return table.concat{util.spaces(lpad), str, util.spaces(rpad)} + return t_concat{util.spaces(lpad), str, util.spaces(rpad)} end -- wrap a string into a table of lines @@ -109,8 +113,9 @@ function util.strwrap(str, limit) return cc_strings.wrap(str, limit) end ---@diagnostic disable-next-line: unused-vararg function util.concat(...) local strings = {} - for i = 1, #arg do strings[i] = util.strval(arg[i]) end - return table.concat(strings) +---@diagnostic disable-next-line: undefined-field + for i = 1, arg.n do strings[i] = util.strval(arg[i]) end + return t_concat(strings) end -- alias @@ -121,7 +126,7 @@ util.c = util.concat ---@param format string ---@vararg any ---@diagnostic disable-next-line: unused-vararg -function util.sprintf(format, ...) return string.format(format, table.unpack(arg)) end +function util.sprintf(format, ...) return string.format(format, t_unpack(arg)) end -- luacheck: unused args @@ -185,7 +190,7 @@ function util.mov_avg(length, default) ---@param x number value function public.reset(x) data = {} - for _ = 1, length do table.insert(data, x) end + for _ = 1, length do t_insert(data, x) end end -- record a new value @@ -482,6 +487,7 @@ function util.new_validator() function public.assert_type_str(value) valid = valid and type(value) == "string" end function public.assert_type_table(value) valid = valid and type(value) == "table" end + function public.assert(check) valid = valid and (check == true) end function public.assert_eq(check, expect) valid = valid and check == expect end function public.assert_min(check, min) valid = valid and check >= min end function public.assert_min_ex(check, min) valid = valid and check > min end diff --git a/startup.lua b/startup.lua index d330066..3b2eef1 100644 --- a/startup.lua +++ b/startup.lua @@ -1,30 +1,28 @@ local util = require("scada-common.util") -local BOOTLOADER_VERSION = "0.3" - local println = util.println -local println_ts = util.println_ts + +local BOOTLOADER_VERSION = "0.4" println("SCADA BOOTLOADER V" .. BOOTLOADER_VERSION) +println("BOOT> SCANNING FOR APPLICATIONS...") -local exit_code ---@type boolean - -println_ts("BOOT> SCANNING FOR APPLICATIONS...") +local exit_code if fs.exists("reactor-plc/startup.lua") then - println("BOOT> FOUND REACTOR PLC CODE: EXEC STARTUP") + println("BOOT> EXEC REACTOR PLC STARTUP") exit_code = shell.execute("reactor-plc/startup") elseif fs.exists("rtu/startup.lua") then - println("BOOT> FOUND RTU CODE: EXEC STARTUP") + println("BOOT> EXEC RTU STARTUP") exit_code = shell.execute("rtu/startup") elseif fs.exists("supervisor/startup.lua") then - println("BOOT> FOUND SUPERVISOR CODE: EXEC STARTUP") + println("BOOT> EXEC SUPERVISOR STARTUP") exit_code = shell.execute("supervisor/startup") elseif fs.exists("coordinator/startup.lua") then - println("BOOT> FOUND COORDINATOR CODE: EXEC STARTUP") + println("BOOT> EXEC COORDINATOR STARTUP") exit_code = shell.execute("coordinator/startup") elseif fs.exists("pocket/startup.lua") then - println("BOOT> FOUND POCKET CODE: EXEC STARTUP") + println("BOOT> EXEC POCKET STARTUP") exit_code = shell.execute("pocket/startup") else println("BOOT> NO SCADA STARTUP FOUND") @@ -32,6 +30,6 @@ else return false end -if not exit_code then println_ts("BOOT> APPLICATION CRASHED") end +if not exit_code then println("BOOT> APPLICATION CRASHED") end return exit_code diff --git a/supervisor/facility.lua b/supervisor/facility.lua index 708522f..81fd531 100644 --- a/supervisor/facility.lua +++ b/supervisor/facility.lua @@ -232,9 +232,7 @@ function facility.new(num_reactors, cooling_conf) -- link a redstone RTU session ---@param rs_unit unit_session - function public.add_redstone(rs_unit) - table.insert(self.redstone, rs_unit) - end + function public.add_redstone(rs_unit) table.insert(self.redstone, rs_unit) end -- link an induction matrix RTU session ---@param imatrix unit_session @@ -258,23 +256,11 @@ function facility.new(num_reactors, cooling_conf) -- link a dynamic tank RTU session ---@param dynamic_tank unit_session - ---@return boolean linked dynamic tank accepted (max 1) - function public.add_tank(dynamic_tank) - if #self.tanks == 0 then - table.insert(self.tanks, dynamic_tank) - return true - else return false end - end + function public.add_tank(dynamic_tank) table.insert(self.tanks, dynamic_tank) end -- link an environment detector RTU session ---@param envd unit_session - ---@return boolean linked environment detector accepted (max 1) - function public.add_envd(envd) - if #self.envd == 0 then - table.insert(self.envd, envd) - return true - else return false end - end + function public.add_envd(envd) table.insert(self.envd, envd) end -- purge devices associated with the given RTU session ID ---@param session integer RTU session ID @@ -643,11 +629,16 @@ function facility.new(num_reactors, cooling_conf) end -- check for facility radiation - if self.envd[1] ~= nil then - local envd = self.envd[1] ---@type unit_session - local e_db = envd.get_db() ---@type envd_session_db + if #self.envd > 0 then + local max_rad = 0 - astatus.radiation = e_db.radiation_raw > ALARM_LIMS.FAC_HIGH_RAD + for i = 1, #self.envd do + local envd = self.envd[i] ---@type unit_session + local e_db = envd.get_db() ---@type envd_session_db + if e_db.radiation_raw > max_rad then max_rad = e_db.radiation_raw end + end + + astatus.radiation = max_rad >= ALARM_LIMS.FAC_HIGH_RAD else -- don't clear, if it is true then we lost it with high radiation, so just keep alarming -- operator can restart the system or hit the stop/reset button @@ -1093,22 +1084,22 @@ function facility.new(num_reactors, cooling_conf) build.induction = {} for i = 1, #self.induction do local matrix = self.induction[i] ---@type unit_session - build.induction[matrix.get_device_idx()] = { matrix.get_db().formed, matrix.get_db().build } + build.induction[i] = { matrix.get_db().formed, matrix.get_db().build } end end if all or type == RTU_UNIT_TYPE.SPS then build.sps = {} for i = 1, #self.sps do - local sps = self.sps[i] ---@type unit_session - build.sps[sps.get_device_idx()] = { sps.get_db().formed, sps.get_db().build } + local sps = self.sps[i] ---@type unit_session + build.sps[i] = { sps.get_db().formed, sps.get_db().build } end end if all or type == RTU_UNIT_TYPE.DYNAMIC_VALVE then build.tanks = {} for i = 1, #self.tanks do - local tank = self.tanks[i] ---@type unit_session + local tank = self.tanks[i] ---@type unit_session build.tanks[tank.get_device_idx()] = { tank.get_db().formed, tank.get_db().build } end end @@ -1160,7 +1151,7 @@ function facility.new(num_reactors, cooling_conf) for i = 1, #self.induction do local matrix = self.induction[i] ---@type unit_session local db = matrix.get_db() ---@type imatrix_session_db - status.induction[matrix.get_device_idx()] = { matrix.is_faulted(), db.formed, db.state, db.tanks } + status.induction[i] = { matrix.is_faulted(), db.formed, db.state, db.tanks } end -- status of sps @@ -1168,7 +1159,7 @@ function facility.new(num_reactors, cooling_conf) for i = 1, #self.sps do local sps = self.sps[i] ---@type unit_session local db = sps.get_db() ---@type sps_session_db - status.sps[sps.get_device_idx()] = { sps.is_faulted(), db.formed, db.state, db.tanks } + status.sps[i] = { sps.is_faulted(), db.formed, db.state, db.tanks } end -- status of dynamic tanks @@ -1180,10 +1171,11 @@ function facility.new(num_reactors, cooling_conf) end -- radiation monitors (environment detectors) - status.rad_mon = {} + status.envds = {} for i = 1, #self.envd do local envd = self.envd[i] ---@type unit_session - status.rad_mon[envd.get_device_idx()] = { envd.is_faulted(), envd.get_db().radiation } + local db = envd.get_db() ---@type envd_session_db + status.envds[envd.get_device_idx()] = { envd.is_faulted(), db.radiation, db.radiation_raw } end return status diff --git a/supervisor/session/rtu.lua b/supervisor/session/rtu.lua index 41c3373..6bebb87 100644 --- a/supervisor/session/rtu.lua +++ b/supervisor/session/rtu.lua @@ -100,7 +100,7 @@ function rtu.new_session(id, s_addr, in_queue, out_queue, timeout, advertisement -- validate unit advertisement local advert_validator = util.new_validator() - advert_validator.assert_type_int(unit_advert.index) + advert_validator.assert(util.is_int(unit_advert.index) or (unit_advert.index == false)) advert_validator.assert_type_int(unit_advert.reactor) if u_type == RTU_UNIT_TYPE.REDSTONE then @@ -108,7 +108,7 @@ function rtu.new_session(id, s_addr, in_queue, out_queue, timeout, advertisement end if advert_validator.valid() then - advert_validator.assert_min(unit_advert.index, 1) + 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 not advert_validator.valid() then u_type = false end diff --git a/supervisor/session/rtu/boilerv.lua b/supervisor/session/rtu/boilerv.lua index 2f3c231..33759f2 100644 --- a/supervisor/session/rtu/boilerv.lua +++ b/supervisor/session/rtu/boilerv.lua @@ -37,13 +37,16 @@ local PERIODICS = { ---@param advert rtu_advertisement RTU advertisement table ---@param out_queue mqueue RTU unit message out queue function boilerv.new(session_id, unit_id, advert, out_queue) - -- type check + -- checks if advert.type ~= RTU_UNIT_TYPE.BOILER_VALVE then - log.error("attempt to instantiate boilerv RTU for type '" .. types.rtu_type_to_string(advert.type) .. "'. this is a bug.") + log.error("attempt to instantiate boilerv RTU for type " .. types.rtu_type_to_string(advert.type)) + return nil + elseif not util.is_int(advert.index) then + log.error("attempt to instantiate boilerv RTU without index") return nil end - local log_tag = "session.rtu(" .. session_id .. ").boilerv(" .. advert.index .. "): " + local log_tag = util.c("session.rtu(", session_id, ").boilerv(", advert.index, ")[@", unit_id, "]: ") local self = { session = unit_session.new(session_id, unit_id, advert, out_queue, log_tag, TXN_TAGS), diff --git a/supervisor/session/rtu/dynamicv.lua b/supervisor/session/rtu/dynamicv.lua index d019da4..b1e5b4a 100644 --- a/supervisor/session/rtu/dynamicv.lua +++ b/supervisor/session/rtu/dynamicv.lua @@ -49,13 +49,16 @@ local PERIODICS = { ---@param advert rtu_advertisement RTU advertisement table ---@param out_queue mqueue RTU unit message out queue function dynamicv.new(session_id, unit_id, advert, out_queue) - -- type check + -- checks if advert.type ~= RTU_UNIT_TYPE.DYNAMIC_VALVE then - log.error("attempt to instantiate dynamicv RTU for type '" .. types.rtu_type_to_string(advert.type) .. "'. this is a bug.") + log.error("attempt to instantiate dynamicv RTU for type " .. types.rtu_type_to_string(advert.type)) + return nil + elseif not util.is_int(advert.index) then + log.error("attempt to instantiate dynamicv RTU without index") return nil end - local log_tag = "session.rtu(" .. session_id .. ").dynamicv(" .. advert.index .. "): " + local log_tag = util.c("session.rtu(", session_id, ").dynamicv(", advert.index, ")[@", unit_id, "]: ") local self = { session = unit_session.new(session_id, unit_id, advert, out_queue, log_tag, TXN_TAGS), diff --git a/supervisor/session/rtu/envd.lua b/supervisor/session/rtu/envd.lua index 3b4b666..8eacf1d 100644 --- a/supervisor/session/rtu/envd.lua +++ b/supervisor/session/rtu/envd.lua @@ -28,13 +28,16 @@ local PERIODICS = { ---@param advert rtu_advertisement ---@param out_queue mqueue function envd.new(session_id, unit_id, advert, out_queue) - -- type check + -- checks if advert.type ~= RTU_UNIT_TYPE.ENV_DETECTOR then - log.error("attempt to instantiate envd RTU for type '" .. types.rtu_type_to_string(advert.type) .. "'. this is a bug.") + log.error("attempt to instantiate envd RTU for type " .. types.rtu_type_to_string(advert.type)) + return nil + elseif not util.is_int(advert.index) then + log.error("attempt to instantiate envd RTU without index") return nil end - local log_tag = "session.rtu(" .. session_id .. ").envd(" .. advert.index .. "): " + local log_tag = util.c("session.rtu(", session_id, ").envd(", advert.index, ")[@", unit_id, "]: ") local self = { session = unit_session.new(session_id, unit_id, advert, out_queue, log_tag, TXN_TAGS), diff --git a/supervisor/session/rtu/imatrix.lua b/supervisor/session/rtu/imatrix.lua index 0b120b4..5a6880e 100644 --- a/supervisor/session/rtu/imatrix.lua +++ b/supervisor/session/rtu/imatrix.lua @@ -37,13 +37,13 @@ local PERIODICS = { ---@param advert rtu_advertisement RTU advertisement table ---@param out_queue mqueue RTU unit message out queue function imatrix.new(session_id, unit_id, advert, out_queue) - -- type check + -- checks if advert.type ~= RTU_UNIT_TYPE.IMATRIX then - log.error("attempt to instantiate imatrix RTU for type '" .. types.rtu_type_to_string(advert.type) .. "'. this is a bug.") + log.error("attempt to instantiate imatrix RTU for type " .. types.rtu_type_to_string(advert.type)) return nil end - local log_tag = "session.rtu(" .. session_id .. ").imatrix(" .. advert.index .. "): " + local log_tag = util.c("session.rtu(", session_id, ").imatrix[@", unit_id, "]: ") local self = { session = unit_session.new(session_id, unit_id, advert, out_queue, log_tag, TXN_TAGS), diff --git a/supervisor/session/rtu/redstone.lua b/supervisor/session/rtu/redstone.lua index 25b7284..b248902 100644 --- a/supervisor/session/rtu/redstone.lua +++ b/supervisor/session/rtu/redstone.lua @@ -52,12 +52,11 @@ local PERIODICS = { function redstone.new(session_id, unit_id, advert, out_queue) -- type check if advert.type ~= RTU_UNIT_TYPE.REDSTONE then - log.error("attempt to instantiate redstone RTU for type '" .. types.rtu_type_to_string(advert.type) .. "'. this is a bug.") + log.error("attempt to instantiate redstone RTU for type " .. types.rtu_type_to_string(advert.type)) return nil end - -- for redstone, use unit ID not device index - local log_tag = "session.rtu(" .. session_id .. ").redstone(" .. unit_id .. "): " + local log_tag = util.c("session.rtu(", session_id, ").redstone[@", unit_id, "]: ") local self = { session = unit_session.new(session_id, unit_id, advert, out_queue, log_tag, TXN_TAGS), diff --git a/supervisor/session/rtu/sna.lua b/supervisor/session/rtu/sna.lua index 006222b..39ab1d0 100644 --- a/supervisor/session/rtu/sna.lua +++ b/supervisor/session/rtu/sna.lua @@ -36,11 +36,11 @@ local PERIODICS = { function sna.new(session_id, unit_id, advert, out_queue) -- type check if advert.type ~= RTU_UNIT_TYPE.SNA then - log.error("attempt to instantiate sna RTU for type '" .. types.rtu_type_to_string(advert.type) .. "'. this is a bug.") + log.error("attempt to instantiate sna RTU for type " .. types.rtu_type_to_string(advert.type)) return nil end - local log_tag = "session.rtu(" .. session_id .. ").sna(" .. advert.index .. "): " + local log_tag = util.c("session.rtu(", session_id, ").sna[@", unit_id, "]: ") local self = { session = unit_session.new(session_id, unit_id, advert, out_queue, log_tag, TXN_TAGS), diff --git a/supervisor/session/rtu/sps.lua b/supervisor/session/rtu/sps.lua index da036cd..3143658 100644 --- a/supervisor/session/rtu/sps.lua +++ b/supervisor/session/rtu/sps.lua @@ -39,11 +39,11 @@ local PERIODICS = { function sps.new(session_id, unit_id, advert, out_queue) -- type check if advert.type ~= RTU_UNIT_TYPE.SPS then - log.error("attempt to instantiate sps RTU for type '" .. types.rtu_type_to_string(advert.type) .. "'. this is a bug.") + log.error("attempt to instantiate sps RTU for type " .. types.rtu_type_to_string(advert.type)) return nil end - local log_tag = "session.rtu(" .. session_id .. ").sps(" .. advert.index .. "): " + local log_tag = util.c("session.rtu(", session_id, ").sps[@", unit_id, "]: ") local self = { session = unit_session.new(session_id, unit_id, advert, out_queue, log_tag, TXN_TAGS), diff --git a/supervisor/session/rtu/turbinev.lua b/supervisor/session/rtu/turbinev.lua index 4cf32c4..e6c08f5 100644 --- a/supervisor/session/rtu/turbinev.lua +++ b/supervisor/session/rtu/turbinev.lua @@ -49,13 +49,16 @@ local PERIODICS = { ---@param advert rtu_advertisement RTU advertisement table ---@param out_queue mqueue RTU unit message out queue function turbinev.new(session_id, unit_id, advert, out_queue) - -- type check + -- checks if advert.type ~= RTU_UNIT_TYPE.TURBINE_VALVE then - log.error("attempt to instantiate turbinev RTU for type '" .. types.rtu_type_to_string(advert.type) .. "'. this is a bug.") + log.error("attempt to instantiate turbinev RTU for type " .. types.rtu_type_to_string(advert.type)) + return nil + elseif not util.is_int(advert.index) then + log.error("attempt to instantiate turbinev RTU without index") return nil end - local log_tag = "session.rtu(" .. session_id .. ").turbinev(" .. advert.index .. "): " + local log_tag = util.c("session.rtu(", session_id, ").turbinev(", advert.index, ")[@", unit_id, "]: ") local self = { session = unit_session.new(session_id, unit_id, advert, out_queue, log_tag, TXN_TAGS), diff --git a/supervisor/session/rtu/unit_session.lua b/supervisor/session/rtu/unit_session.lua index 3d27fa4..0a2964a 100644 --- a/supervisor/session/rtu/unit_session.lua +++ b/supervisor/session/rtu/unit_session.lua @@ -152,7 +152,7 @@ function unit_session.new(session_id, unit_id, advert, out_queue, log_tag, txn_t function public.get_unit_id() return unit_id end -- get the device index ---@nodiscard - function public.get_device_idx() return self.device_index end + function public.get_device_idx() return self.device_index or 0 end -- get the reactor ID ---@nodiscard function public.get_reactor() return self.reactor end diff --git a/supervisor/startup.lua b/supervisor/startup.lua index ec27930..2ff69ad 100644 --- a/supervisor/startup.lua +++ b/supervisor/startup.lua @@ -21,7 +21,7 @@ local supervisor = require("supervisor.supervisor") local svsessions = require("supervisor.session.svsessions") -local SUPERVISOR_VERSION = "v1.0.9" +local SUPERVISOR_VERSION = "v1.1.0" local println = util.println local println_ts = util.println_ts diff --git a/supervisor/unit.lua b/supervisor/unit.lua index ad6c3b0..645f4e9 100644 --- a/supervisor/unit.lua +++ b/supervisor/unit.lua @@ -864,10 +864,11 @@ function unit.new(reactor_id, num_boilers, num_turbines) status.sna = { #self.snas, public.get_sna_rate(), total_peak } -- radiation monitors (environment detectors) - status.rad_mon = {} + status.envds = {} for i = 1, #self.envd do local envd = self.envd[i] ---@type unit_session - status.rad_mon[envd.get_device_idx()] = { envd.is_faulted(), envd.get_db().radiation } + local db = envd.get_db() ---@type envd_session_db + status.envds[envd.get_device_idx()] = { envd.is_faulted(), db.radiation, db.radiation_raw } end return status diff --git a/supervisor/unitlogic.lua b/supervisor/unitlogic.lua index f16f197..e207c44 100644 --- a/supervisor/unitlogic.lua +++ b/supervisor/unitlogic.lua @@ -47,8 +47,9 @@ function logic.update_annunciator(self) local num_boilers = self.num_boilers local num_turbines = self.num_turbines + local annunc = self.db.annunciator - self.db.annunciator.RCSFault = false + annunc.RCSFault = false -- variables for boiler, or reactor if no boilers used local total_boil_rate = 0.0 @@ -57,14 +58,14 @@ function logic.update_annunciator(self) -- REACTOR -- ------------- - self.db.annunciator.AutoControl = self.auto_engaged + annunc.AutoControl = self.auto_engaged -- check PLC status - self.db.annunciator.PLCOnline = self.plc_i ~= nil + annunc.PLCOnline = self.plc_i ~= nil - local plc_ready = self.db.annunciator.PLCOnline + local plc_ready = annunc.PLCOnline - if self.db.annunciator.PLCOnline then + if plc_ready then local plc_db = self.plc_i.get_db() -- update ready state @@ -110,29 +111,29 @@ function logic.update_annunciator(self) -- heartbeat blink about every second if self.last_heartbeat + 1000 < plc_db.last_status_update then - self.db.annunciator.PLCHeartbeat = not self.db.annunciator.PLCHeartbeat + annunc.PLCHeartbeat = not annunc.PLCHeartbeat self.last_heartbeat = plc_db.last_status_update end local flow_low = util.trinary(plc_db.mek_status.ccool_type == types.FLUID.SODIUM, ANNUNC_LIMS.RCSFlowLow_NA, ANNUNC_LIMS.RCSFlowLow_H2O) -- update other annunciator fields - self.db.annunciator.ReactorSCRAM = plc_db.rps_tripped - self.db.annunciator.ManualReactorSCRAM = plc_db.rps_trip_cause == types.RPS_TRIP_CAUSE.MANUAL - self.db.annunciator.AutoReactorSCRAM = plc_db.rps_trip_cause == types.RPS_TRIP_CAUSE.AUTOMATIC - self.db.annunciator.RCPTrip = plc_db.rps_tripped and (plc_db.rps_status.ex_hcool or plc_db.rps_status.low_cool) - self.db.annunciator.RCSFlowLow = _get_dt(DT_KEYS.ReactorCCool) < flow_low - self.db.annunciator.CoolantLevelLow = plc_db.mek_status.ccool_fill < ANNUNC_LIMS.CoolantLevelLow - self.db.annunciator.ReactorTempHigh = plc_db.mek_status.temp > ANNUNC_LIMS.ReactorTempHigh - self.db.annunciator.ReactorHighDeltaT = _get_dt(DT_KEYS.ReactorTemp) > ANNUNC_LIMS.ReactorHighDeltaT - self.db.annunciator.FuelInputRateLow = _get_dt(DT_KEYS.ReactorFuel) < -1.0 or plc_db.mek_status.fuel_fill <= ANNUNC_LIMS.FuelLevelLow - self.db.annunciator.WasteLineOcclusion = _get_dt(DT_KEYS.ReactorWaste) > 1.0 or plc_db.mek_status.waste_fill >= ANNUNC_LIMS.WasteLevelHigh + annunc.ReactorSCRAM = plc_db.rps_tripped + annunc.ManualReactorSCRAM = plc_db.rps_trip_cause == types.RPS_TRIP_CAUSE.MANUAL + annunc.AutoReactorSCRAM = plc_db.rps_trip_cause == types.RPS_TRIP_CAUSE.AUTOMATIC + annunc.RCPTrip = plc_db.rps_tripped and (plc_db.rps_status.ex_hcool or plc_db.rps_status.low_cool) + annunc.RCSFlowLow = _get_dt(DT_KEYS.ReactorCCool) < flow_low + annunc.CoolantLevelLow = plc_db.mek_status.ccool_fill < ANNUNC_LIMS.CoolantLevelLow + annunc.ReactorTempHigh = plc_db.mek_status.temp > ANNUNC_LIMS.ReactorTempHigh + annunc.ReactorHighDeltaT = _get_dt(DT_KEYS.ReactorTemp) > ANNUNC_LIMS.ReactorHighDeltaT + annunc.FuelInputRateLow = _get_dt(DT_KEYS.ReactorFuel) < -1.0 or plc_db.mek_status.fuel_fill <= ANNUNC_LIMS.FuelLevelLow + annunc.WasteLineOcclusion = _get_dt(DT_KEYS.ReactorWaste) > 1.0 or plc_db.mek_status.waste_fill >= ANNUNC_LIMS.WasteLevelHigh local heating_rate_conv = util.trinary(plc_db.mek_status.ccool_type == types.FLUID.SODIUM, 200000, 20000) local high_rate = plc_db.mek_status.burn_rate >= (plc_db.mek_status.ccool_amnt * 0.27 / heating_rate_conv) -- this advisory applies when no coolant is buffered (which we can't easily determine)
-- it's a rough estimation, see GitHub cc-mek-scada/wiki/High-Rate-Calculation - self.db.annunciator.HighStartupRate = not plc_db.mek_status.status and high_rate + annunc.HighStartupRate = not plc_db.mek_status.status and high_rate -- if no boilers, use reactor heating rate to check for boil rate mismatch if num_boilers == 0 then @@ -146,21 +147,25 @@ function logic.update_annunciator(self) -- MISC RTUs -- --------------- - self.db.annunciator.RadiationMonitor = 1 - self.db.annunciator.RadiationWarning = false + local max_rad, any_faulted = 0, false + for i = 1, #self.envd do - local envd = self.envd[i] ---@type unit_session - self.db.annunciator.RadiationMonitor = util.trinary(envd.is_faulted(), 2, 3) - self.db.annunciator.RadiationWarning = envd.get_db().radiation_raw >= ANNUNC_LIMS.RadiationWarning - break + local envd = self.envd[i] ---@type unit_session + local db = envd.get_db() ---@type envd_session_db + any_faulted = any_faulted or envd.is_faulted() + if db.radiation_raw > max_rad then max_rad = db.radiation_raw end end - self.db.annunciator.EmergencyCoolant = 1 + annunc.RadiationMonitor = util.trinary(#self.envd == 0, 1, util.trinary(any_faulted, 2, 3)) + annunc.RadiationWarning = max_rad >= ANNUNC_LIMS.RadiationWarning + + annunc.EmergencyCoolant = 1 + for i = 1, #self.redstone do - local db = self.redstone[i].get_db() ---@type redstone_session_db - local io = db.io[IO.U_EMER_COOL] ---@type rs_db_dig_io|nil + local db = self.redstone[i].get_db() ---@type redstone_session_db + local io = db.io[IO.U_EMER_COOL] ---@type rs_db_dig_io|nil if io ~= nil then - self.db.annunciator.EmergencyCoolant = util.trinary(io.read(), 3, 2) + annunc.EmergencyCoolant = util.trinary(io.read(), 3, 2) break end end @@ -172,7 +177,7 @@ function logic.update_annunciator(self) local boilers_ready = num_boilers == #self.boilers -- clear boiler online flags - for i = 1, num_boilers do self.db.annunciator.BoilerOnline[i] = false end + for i = 1, num_boilers do annunc.BoilerOnline[i] = false end -- aggregated statistics local boiler_steam_dt_sum = 0.0 @@ -185,7 +190,7 @@ function logic.update_annunciator(self) local boiler = session.get_db() ---@type boilerv_session_db local idx = session.get_device_idx() - self.db.annunciator.RCSFault = self.db.annunciator.RCSFault or (not boiler.formed) or session.is_faulted() + annunc.RCSFault = annunc.RCSFault or (not boiler.formed) or session.is_faulted() -- update ready state -- - must be formed @@ -199,8 +204,8 @@ function logic.update_annunciator(self) boiler_steam_dt_sum = _get_dt(DT_KEYS.BoilerSteam .. idx) boiler_water_dt_sum = _get_dt(DT_KEYS.BoilerWater .. idx) - self.db.annunciator.BoilerOnline[idx] = true - self.db.annunciator.WaterLevelLow[idx] = boiler.tanks.water_fill < ANNUNC_LIMS.WaterLevelLow + annunc.BoilerOnline[idx] = true + annunc.WaterLevelLow[idx] = boiler.tanks.water_fill < ANNUNC_LIMS.WaterLevelLow end -- check heating rate low @@ -209,14 +214,14 @@ function logic.update_annunciator(self) -- check for inactive boilers while reactor is active for i = 1, #self.boilers do - local boiler = self.boilers[i] ---@type unit_session + local boiler = self.boilers[i] ---@type unit_session local idx = boiler.get_device_idx() - local db = boiler.get_db() ---@type boilerv_session_db + local db = boiler.get_db() ---@type boilerv_session_db if r_db.mek_status.status then - self.db.annunciator.HeatingRateLow[idx] = db.state.boil_rate == 0 + annunc.HeatingRateLow[idx] = db.state.boil_rate == 0 else - self.db.annunciator.HeatingRateLow[idx] = false + annunc.HeatingRateLow[idx] = false end end end @@ -234,9 +239,9 @@ function logic.update_annunciator(self) if num_boilers > 0 then for i = 1, #self.boilers do - local boiler = self.boilers[i] ---@type unit_session + local boiler = self.boilers[i] ---@type unit_session local idx = boiler.get_device_idx() - local db = boiler.get_db() ---@type boilerv_session_db + local db = boiler.get_db() ---@type boilerv_session_db local gaining_hc = _get_dt(DT_KEYS.BoilerHCool .. idx) > 10.0 or db.tanks.hcool_fill == 1 @@ -256,7 +261,7 @@ function logic.update_annunciator(self) cfmismatch = cfmismatch or _get_dt(DT_KEYS.ReactorCCool) < -10.0 or (gaining_hc and r_db.mek_status.ccool_fill == 0) end - self.db.annunciator.CoolantFeedMismatch = cfmismatch + annunc.CoolantFeedMismatch = cfmismatch -------------- -- TURBINES -- @@ -265,7 +270,7 @@ function logic.update_annunciator(self) local turbines_ready = num_turbines == #self.turbines -- clear turbine online flags - for i = 1, num_turbines do self.db.annunciator.TurbineOnline[i] = false end + for i = 1, num_turbines do annunc.TurbineOnline[i] = false end -- aggregated statistics local total_flow_rate = 0 @@ -277,10 +282,10 @@ function logic.update_annunciator(self) -- go through turbines for stats and online for i = 1, #self.turbines do - local session = self.turbines[i] ---@type unit_session - local turbine = session.get_db() ---@type turbinev_session_db + local session = self.turbines[i] ---@type unit_session + local turbine = session.get_db() ---@type turbinev_session_db - self.db.annunciator.RCSFault = self.db.annunciator.RCSFault or (not turbine.formed) or session.is_faulted() + annunc.RCSFault = annunc.RCSFault or (not turbine.formed) or session.is_faulted() -- update ready state -- - must be formed @@ -295,59 +300,44 @@ function logic.update_annunciator(self) max_water_return_rate = max_water_return_rate + turbine.build.max_water_output self.db.control.blade_count = self.db.control.blade_count + turbine.build.blades - self.db.annunciator.TurbineOnline[session.get_device_idx()] = true + annunc.TurbineOnline[session.get_device_idx()] = true end -- check for boil rate mismatch (> 4% error) either between reactor and turbine or boiler and turbine - self.db.annunciator.BoilRateMismatch = math.abs(total_boil_rate - total_input_rate) > (0.04 * total_boil_rate) + annunc.BoilRateMismatch = math.abs(total_boil_rate - total_input_rate) > (0.04 * total_boil_rate) -- check for steam feed mismatch and max return rate local steam_dt_max = util.trinary(num_boilers == 0, ANNUNC_LIMS.SFM_MaxSteamDT_H20, ANNUNC_LIMS.SFM_MaxSteamDT_NA) local water_dt_min = util.trinary(num_boilers == 0, ANNUNC_LIMS.SFM_MinWaterDT_H20, ANNUNC_LIMS.SFM_MinWaterDT_NA) local sfmismatch = math.abs(total_flow_rate - total_input_rate) > ANNUNC_LIMS.SteamFeedMismatch sfmismatch = sfmismatch or boiler_steam_dt_sum > steam_dt_max or boiler_water_dt_sum < water_dt_min - self.db.annunciator.SteamFeedMismatch = sfmismatch - self.db.annunciator.MaxWaterReturnFeed = max_water_return_rate == total_flow_rate and total_flow_rate ~= 0 + annunc.SteamFeedMismatch = sfmismatch + annunc.MaxWaterReturnFeed = max_water_return_rate == total_flow_rate and total_flow_rate ~= 0 -- turbine safety checks for i = 1, #self.turbines do - local turbine = self.turbines[i] ---@type unit_session - local db = turbine.get_db() ---@type turbinev_session_db + local turbine = self.turbines[i] ---@type unit_session + local db = turbine.get_db() ---@type turbinev_session_db local idx = turbine.get_device_idx() -- check if steam dumps are open if db.state.dumping_mode == DUMPING_MODE.IDLE then - self.db.annunciator.SteamDumpOpen[idx] = TRI_FAIL.OK + annunc.SteamDumpOpen[idx] = TRI_FAIL.OK elseif db.state.dumping_mode == DUMPING_MODE.DUMPING_EXCESS then - self.db.annunciator.SteamDumpOpen[idx] = TRI_FAIL.PARTIAL + annunc.SteamDumpOpen[idx] = TRI_FAIL.PARTIAL else - self.db.annunciator.SteamDumpOpen[idx] = TRI_FAIL.FULL + annunc.SteamDumpOpen[idx] = TRI_FAIL.FULL end -- check if turbines are at max speed but not keeping up - self.db.annunciator.TurbineOverSpeed[idx] = (db.state.flow_rate == db.build.max_flow_rate) and (_get_dt(DT_KEYS.TurbineSteam .. idx) > 0.0) + annunc.TurbineOverSpeed[idx] = (db.state.flow_rate == db.build.max_flow_rate) and (_get_dt(DT_KEYS.TurbineSteam .. idx) > 0.0) - --[[ - Generator Trip - a generator trip is when a generator suddenly and unexpectedly loses it's external load, which occurs when a power plant - is disconnected from the grid. in our case, this is when the turbine is disconnected, or what it's connected to becomes - fully charged. this is identified by detecting if: - - the internal power storage of the turbine is increasing AND - - there is at least 5% energy fill (preventing false trips with periodic power extraction from other mods) - this would then mean there is no external load and there will be a turbine trip soon if this is not resolved - ]]-- - self.db.annunciator.GeneratorTrip[idx] = (_get_dt(DT_KEYS.TurbinePower .. idx) > 0.0) and (db.tanks.energy_fill > 0.05) + -- see notes at cc-mek-scada/wiki/Annunciator-Panels#Generator-Trip + annunc.GeneratorTrip[idx] = (_get_dt(DT_KEYS.TurbinePower .. idx) > 0.0) and (db.tanks.energy_fill > 0.05) - --[[ - Turbine Trip - a turbine trip is when the turbine stops, which means we are no longer receiving water and lose the ability to cool. - this can be identified by these conditions: - - the current flow rate is 0 mB/t and it should not be - - can initially catch this by detecting a 0 flow rate with a non-zero input rate, but eventually the steam will fill up - - can later identified by presence of steam in tank with a 0 flow rate - ]]-- + -- see notes at cc-mek-scada/wiki/Annunciator-Panels#Turbine-Trip local has_steam = db.state.steam_input_rate > 0 or db.tanks.steam_fill > 0.01 - self.db.annunciator.TurbineTrip[idx] = has_steam and db.state.flow_rate == 0 + annunc.TurbineTrip[idx] = has_steam and db.state.flow_rate == 0 end -- update auto control ready state for this unit @@ -577,6 +567,7 @@ end ---@param self _unit_self unit instance function logic.update_status_text(self) local AISTATE = self.types.AISTATE + local annunc = self.db.annunciator -- check if an alarm is active (tripped or ack'd) ---@nodiscard @@ -666,13 +657,13 @@ function logic.update_status_text(self) if plc_db.mek_status.status then self.status_text[1] = "ACTIVE" - if self.db.annunciator.ReactorHighDeltaT then + if annunc.ReactorHighDeltaT then self.status_text[2] = "core temperature rising" - elseif self.db.annunciator.ReactorTempHigh then + elseif annunc.ReactorTempHigh then self.status_text[2] = "core temp high, system nominal" - elseif self.db.annunciator.FuelInputRateLow then + elseif annunc.FuelInputRateLow then self.status_text[2] = "insufficient fuel input rate" - elseif self.db.annunciator.WasteLineOcclusion then + elseif annunc.WasteLineOcclusion then self.status_text[2] = "insufficient waste output rate" elseif (util.time_ms() - self.last_rate_change_ms) <= FLOW_STABILITY_DELAY_MS then self.status_text[2] = "awaiting flow stability" @@ -711,7 +702,7 @@ function logic.update_status_text(self) end self.status_text = { "RPS SCRAM", cause } - elseif self.db.annunciator.RadiationWarning then + elseif annunc.RadiationWarning then -- elevated, non-hazardous level of radiation is low priority, so display it now if everything else was fine self.status_text = { "RADIATION DETECTED", "elevated level of radiation" } else @@ -726,7 +717,7 @@ function logic.update_status_text(self) self.status_text[2] = "core hot" end end - elseif self.db.annunciator.RadiationWarning then + elseif annunc.RadiationWarning then -- in case PLC was disconnected but radiation is present self.status_text = { "RADIATION DETECTED", "elevated level of radiation" } else @@ -738,6 +729,7 @@ end ---@param self _unit_self unit instance function logic.handle_redstone(self) local AISTATE = self.types.AISTATE + local annunc = self.db.annunciator -- check if an alarm is active (tripped or ack'd) ---@nodiscard @@ -806,7 +798,7 @@ function logic.handle_redstone(self) ----------------------- local enable_emer_cool = self.plc_cache.rps_status.low_cool or - (self.auto_engaged and self.db.annunciator.CoolantLevelLow and is_active(self.alarms.ReactorOverTemp)) + (self.auto_engaged and annunc.CoolantLevelLow and is_active(self.alarms.ReactorOverTemp)) -- don't turn off emergency coolant on sufficient coolant level since it might drop again -- turn off once system is OK again @@ -822,7 +814,7 @@ function logic.handle_redstone(self) end end - if self.db.annunciator.EmergencyCoolant > 1 and self.emcool_opened then + if annunc.EmergencyCoolant > 1 and self.emcool_opened then log.info(util.c("UNIT ", self.r_id, " emergency coolant valve closed")) log.info(util.c("UNIT ", self.r_id, " turbines set to not dump steam")) end @@ -849,7 +841,7 @@ function logic.handle_redstone(self) end end - if self.db.annunciator.EmergencyCoolant > 1 and not self.emcool_opened then + if annunc.EmergencyCoolant > 1 and not self.emcool_opened then log.info(util.c("UNIT ", self.r_id, " emergency coolant valve opened")) log.info(util.c("UNIT ", self.r_id, " turbines set to dump excess steam")) end