diff --git a/LICENSE b/LICENSE index e6824b5..7578961 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright © 2022 - 2023 Mikayla Fischler +Copyright © 2022 - 2024 Mikayla Fischler Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/configure.lua b/configure.lua index ab0b64e..69d7f0f 100644 --- a/configure.lua +++ b/configure.lua @@ -6,8 +6,8 @@ elseif fs.exists("rtu/configure.lua") then require("rtu.configure").configure() elseif fs.exists("supervisor/configure.lua") then require("supervisor.configure").configure() -elseif fs.exists("coordinator/startup.lua") then - print("CONFIGURE> coordinator configurator not yet implemented (use 'edit coordinator/config.lua' to configure)") +elseif fs.exists("coordinator/configure.lua") then + require("coordinator.configure").configure() elseif fs.exists("pocket/startup.lua") then print("CONFIGURE> pocket configurator not yet implemented (use 'edit pocket/config.lua' to configure)") else diff --git a/coordinator/config.lua b/coordinator/config.lua deleted file mode 100644 index bdf01e2..0000000 --- a/coordinator/config.lua +++ /dev/null @@ -1,41 +0,0 @@ -local config = {} - --- supervisor comms channel -config.SVR_CHANNEL = 16240 --- coordinator comms channel -config.CRD_CHANNEL = 16243 --- pocket comms channel -config.PKT_CHANNEL = 16244 --- 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.SV_TIMEOUT = 5 -config.API_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" - --- expected number of reactor units, used only to require that number of unit monitors -config.NUM_UNITS = 4 - --- 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 - --- true for 24 hour time on main view screen -config.TIME_24_HOUR = true - --- disable flow view (for legacy layouts) -config.DISABLE_FLOW_VIEW = false - --- 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 - -return config diff --git a/coordinator/configure.lua b/coordinator/configure.lua new file mode 100644 index 0000000..44b0b19 --- /dev/null +++ b/coordinator/configure.lua @@ -0,0 +1,1310 @@ +-- +-- Configuration GUI +-- + +local comms = require("scada-common.comms") +local log = require("scada-common.log") +local network = require("scada-common.network") +local ppm = require("scada-common.ppm") +local tcd = require("scada-common.tcd") +local util = require("scada-common.util") + +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 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 PROTOCOL = comms.PROTOCOL +local DEVICE_TYPE = comms.DEVICE_TYPE +local ESTABLISH_ACK = comms.ESTABLISH_ACK +local MGMT_TYPE = comms.MGMT_TYPE + +local cpair = core.cpair + +local LEFT = core.ALIGN.LEFT +local CENTER = core.ALIGN.CENTER +local RIGHT = core.ALIGN.RIGHT + +-- changes to the config data/format to let the user know +local changes = {} + +---@class crd_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 dis_fg_bg = cpair(colors.lightGray,colors.white) + +local tool_ctl = { + nic = nil, ---@type nic + net_listen = false, + sv_addr = comms.BROADCAST, + sv_seq_num = 0, + sv_cool_conf = nil, ---@type table list of boiler & turbine counts + show_sv_cfg = nil, ---@type function + + start_fail = 0, + fail_message = "", + has_config = false, + viewing_config = false, + importing_legacy = false, + + view_cfg = nil, ---@type graphics_element + settings_apply = nil, ---@type graphics_element + + gen_summary = nil, ---@type function + show_current_cfg = nil, ---@type function + load_legacy = 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 = "", + + sv_connect = nil, ---@type function + sv_conn_button = nil, ---@type graphics_element + sv_conn_status = nil, ---@type graphics_element + sv_conn_detail = nil, ---@type graphics_element + sv_skip = nil, ---@type graphics_element + sv_next = nil, ---@type graphics_element + + apply_mon = nil, ---@type graphics_element + + update_mon_reqs = nil, ---@type function + gen_mon_list = function () end, + edit_monitor = nil, ---@type function + + mon_iface = "", + mon_expect = {} +} + +---@class crd_config +local tmp_cfg = { + UnitCount = 1, + SpeakerVolume = 1.0, + Time24Hour = true, + DisableFlowView = false, + MainDisplay = nil, ---@type string + FlowDisplay = nil, ---@type string + UnitDisplays = {}, + SVR_Channel = nil, ---@type integer + CRD_Channel = nil, ---@type integer + PKT_Channel = nil, ---@type integer + SVR_Timeout = nil, ---@type number + API_Timeout = nil, ---@type number + TrustedRange = nil, ---@type number + AuthKey = nil, ---@type string|nil + LogMode = 0, + LogPath = "", + LogDebug = false, +} + +---@class crd_config +local ini_cfg = {} +---@class crd_config +local settings_cfg = {} + +-- all settings fields, their nice names, and their default values +local fields = { + { "UnitCount", "Number of Reactors", 1 }, + { "MainDisplay", "Main Monitor", nil }, + { "FlowDisplay", "Flow Monitor", nil }, + { "UnitDisplays", "Unit Monitors", {} }, + { "SpeakerVolume", "Speaker Volume", 1.0 }, + { "Time24Hour", "Use 24-hour Time Format", true }, + { "DisableFlowView", "Disable Flow Monitor (legacy, discouraged)", false }, + { "SVR_Channel", "SVR Channel", 16240 }, + { "CRD_Channel", "CRD Channel", 16243 }, + { "PKT_Channel", "PKT Channel", 16244 }, + { "SVR_Timeout", "Supervisor Connection Timeout", 5 }, + { "API_Timeout", "API 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 } +} + +-- check if a value is an integer within a range (inclusive) +---@param x integer +---@param min integer +---@param max integer +local function is_int_min_max(x, min, max) return util.is_int(x) and x >= min and x <= max end + +-- send a management packet to the supervisor +---@param msg_type MGMT_TYPE +---@param msg table +local function send_sv(msg_type, msg) + local s_pkt = comms.scada_packet() + local pkt = comms.mgmt_packet() + + pkt.make(msg_type, msg) + s_pkt.make(tool_ctl.sv_addr, tool_ctl.sv_seq_num, PROTOCOL.SCADA_MGMT, pkt.raw_sendable()) + + tool_ctl.nic.transmit(tmp_cfg.SVR_Channel, tmp_cfg.CRD_Channel, s_pkt) + tool_ctl.sv_seq_num = tool_ctl.sv_seq_num + 1 +end + +-- handle an establish message from the supervisor +---@param packet mgmt_frame +local function handle_packet(packet) + local error_msg = nil + + if packet.scada_frame.local_channel() ~= tmp_cfg.CRD_Channel then + error_msg = "Error: unknown receive channel." + elseif packet.scada_frame.remote_channel() == tmp_cfg.SVR_Channel and packet.scada_frame.protocol() == PROTOCOL.SCADA_MGMT then + if packet.type == MGMT_TYPE.ESTABLISH then + if packet.length == 2 then + local est_ack = packet.data[1] + local config = packet.data[2] + + if est_ack == ESTABLISH_ACK.ALLOW then + if type(config) == "table" and #config == 2 then + local count_ok = is_int_min_max(config[1], 1, 4) + local cool_ok = type(config[2]) == "table" and type(config[2].r_cool) == "table" and #config[2].r_cool == config[1] + + if count_ok and cool_ok then + tmp_cfg.UnitCount = config[1] + tool_ctl.sv_cool_conf = {} + + for i = 1, tmp_cfg.UnitCount do + local num_b = config[2].r_cool[i].BoilerCount + local num_t = config[2].r_cool[i].TurbineCount + tool_ctl.sv_cool_conf[i] = { num_b, num_t } + cool_ok = cool_ok and is_int_min_max(num_b, 0, 2) and is_int_min_max(num_t, 1, 3) + end + end + + if not count_ok then + error_msg = "Error: supervisor unit count out of range." + elseif not cool_ok then + error_msg = "Error: supervisor cooling configuration malformed." + tool_ctl.sv_cool_conf = nil + end + + tool_ctl.sv_addr = packet.scada_frame.src_addr() + send_sv(MGMT_TYPE.CLOSE, {}) + else + error_msg = "Error: invalid cooling configuration supervisor." + end + else + error_msg = "Error: invalid allow reply length from supervisor." + end + elseif packet.length == 1 then + local est_ack = packet.data[1] + + if est_ack == ESTABLISH_ACK.DENY then + error_msg = "Error: supervisor connection denied." + elseif est_ack == ESTABLISH_ACK.COLLISION then + error_msg = "Error: a coordinator is already/still connected. Please try again." + elseif est_ack == ESTABLISH_ACK.BAD_VERSION then + error_msg = "Error: coordinator comms version does not match supervisor comms version." + else + error_msg = "Error: invalid reply from supervisor." + end + else + error_msg = "Error: invalid reply length from supervisor." + end + else + error_msg = "Error: didn't get an establish reply from supervisor." + end + end + + tool_ctl.net_listen = false + + if error_msg then + tool_ctl.sv_conn_status.set_value("") + tool_ctl.sv_conn_detail.set_value(error_msg) + tool_ctl.sv_conn_button.enable() + else + tool_ctl.sv_conn_status.set_value("Connected!") + tool_ctl.sv_conn_detail.set_value("Data received successfully, press 'Next' to continue.") + tool_ctl.sv_skip.hide() + tool_ctl.sv_next.show() + end +end + +-- handle supervisor connection failure +local function handle_timeout() + tool_ctl.net_listen = false + tool_ctl.sv_conn_button.enable() + tool_ctl.sv_conn_status.set_value("Timed out.") + tool_ctl.sv_conn_detail.set_value("Supervisor did not reply. Ensure startup app is running on the supervisor.") +end + +-- load tmp_cfg fields from ini_cfg fields for displays +local function preset_monitor_fields() + tmp_cfg.DisableFlowView = ini_cfg.DisableFlowView + + tmp_cfg.MainDisplay = ini_cfg.MainDisplay + tmp_cfg.FlowDisplay = ini_cfg.FlowDisplay + for i = 1, ini_cfg.UnitCount do + tmp_cfg.UnitDisplays[i] = ini_cfg.UnitDisplays[i] + end +end + +-- load data from the settings file +---@param target crd_config +---@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("/coordinator.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 +---@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="Coordinator 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 net_cfg = Div{parent=root_pane_div,x=1,y=1} + local fac_cfg = Div{parent=root_pane_div,x=1,y=1} + local mon_cfg = Div{parent=root_pane_div,x=1,y=1} + local spkr_cfg = Div{parent=root_pane_div,x=1,y=1} + local crd_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 main_pane = MultiPane{parent=root_pane_div,x=1,y=1,panes={main_page,net_cfg,fac_cfg,mon_cfg,spkr_cfg,crd_cfg,log_cfg,summary,changelog}} + + -- Main Page + + local y_start = 5 + + TextBox{parent=main_page,x=2,y=2,height=2,text="Welcome to the Coordinator configurator! Please select one of the following options."} + + if tool_ctl.start_fail == 2 then + local msg = util.c("Notice: There is a problem with your monitor configuration. ", tool_ctl.fail_message, " Please reconfigure monitors or correct their sizes.") + TextBox{parent=main_page,x=2,y=y_start,height=4,width=49,text=msg,fg_bg=cpair(colors.red,colors.lightGray)} + y_start = y_start + 5 + elseif tool_ctl.start_fail > 0 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 + end + + local function view_config() + tool_ctl.viewing_config = true + tool_ctl.gen_summary(settings_cfg) + tool_ctl.settings_apply.hide(true) + main_pane.set_value(8) + end + + if fs.exists("/coordinator/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 + + PushButton{parent=main_page,x=2,y=y_start,min_width=18,text="Configure System",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_cfg = PushButton{parent=main_page,x=2,y=y_start+2,min_width=20,text="View Configuration",callback=view_config,fg_bg=cpair(colors.black,colors.blue),active_fg_bg=btn_act_fg_bg,dis_fg_bg=dis_fg_bg} + + if not tool_ctl.has_config then tool_ctl.view_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} + + --#region Network + + 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_c_4 = 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,net_c_4}} + + 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 3 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,width=18,text="Supervisor Channel"} + local svr_chan = NumberField{parent=net_c_1,x=21,y=8,width=7,default=ini_cfg.SVR_Channel,min=1,max=65535,fg_bg=bw_fg_bg} + TextBox{parent=net_c_1,x=29,y=8,height=4,text="[SVR_CHANNEL]",fg_bg=g_lg_fg_bg} + + TextBox{parent=net_c_1,x=1,y=10,height=1,width=19,text="Coordinator Channel"} + local crd_chan = NumberField{parent=net_c_1,x=21,y=10,width=7,default=ini_cfg.CRD_Channel,min=1,max=65535,fg_bg=bw_fg_bg} + TextBox{parent=net_c_1,x=29,y=10,height=4,text="[CRD_CHANNEL]",fg_bg=g_lg_fg_bg} + + TextBox{parent=net_c_1,x=1,y=12,height=1,width=14,text="Pocket Channel"} + local pkt_chan = NumberField{parent=net_c_1,x=21,y=12,width=7,default=ini_cfg.PKT_Channel,min=1,max=65535,fg_bg=bw_fg_bg} + TextBox{parent=net_c_1,x=29,y=12,height=4,text="[PKT_CHANNEL]",fg_bg=g_lg_fg_bg} + + local chan_err = TextBox{parent=net_c_1,x=8,y=14,height=1,width=35,text="Please set all channels.",fg_bg=cpair(colors.red,colors.lightGray),hidden=true} + + local function submit_channels() + local svr_c, crd_c, pkt_c = tonumber(svr_chan.get_value()), tonumber(crd_chan.get_value()), tonumber(pkt_chan.get_value()) + if svr_c ~= nil and crd_c ~= nil and pkt_c ~= nil then + tmp_cfg.SVR_Channel, tmp_cfg.CRD_Channel, tmp_cfg.PKT_Channel = svr_c, crd_c, pkt_c + net_pane.set_value(2) + chan_err.hide(true) + else chan_err.show() end + end + + PushButton{parent=net_c_1,x=1,y=14,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=net_c_1,x=44,y=14,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="Please set the connection timeouts below."} + TextBox{parent=net_c_2,x=1,y=3,height=4,text="You generally should not need to modify these. On slow servers, you can try to increase this to make the system wait longer before assuming a disconnection. The default for all is 5 seconds.",fg_bg=g_lg_fg_bg} + + TextBox{parent=net_c_2,x=1,y=8,height=1,width=19,text="Supervisor Timeout"} + local svr_timeout = NumberField{parent=net_c_2,x=20,y=8,width=7,default=ini_cfg.SVR_Timeout,min=2,max=25,max_chars=6,max_frac_digits=2,allow_decimal=true,fg_bg=bw_fg_bg} + + TextBox{parent=net_c_2,x=1,y=10,height=1,width=14,text="Pocket Timeout"} + local api_timeout = NumberField{parent=net_c_2,x=20,y=10,width=7,default=ini_cfg.API_Timeout,min=2,max=25,max_chars=6,max_frac_digits=2,allow_decimal=true,fg_bg=bw_fg_bg} + + TextBox{parent=net_c_2,x=28,y=8,height=4,width=7,text="seconds\n\nseconds",fg_bg=g_lg_fg_bg} + + local ct_err = TextBox{parent=net_c_2,x=8,y=14,height=1,width=35,text="Please set all connection timeouts.",fg_bg=cpair(colors.red,colors.lightGray),hidden=true} + + local function submit_timeouts() + local svr_cto, api_cto = tonumber(svr_timeout.get_value()), tonumber(api_timeout.get_value()) + if svr_cto ~= nil and api_cto ~= nil then + tmp_cfg.SVR_Timeout, tmp_cfg.API_Timeout = svr_cto, api_cto + net_pane.set_value(3) + ct_err.hide(true) + else ct_err.show() end + end + + PushButton{parent=net_c_2,x=1,y=14,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,text="Next \x1a",callback=submit_timeouts,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + + TextBox{parent=net_c_3,x=1,y=1,height=1,text="Please set the trusted range below."} + TextBox{parent=net_c_3,x=1,y=3,height=3,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_3,x=1,y=7,height=2,text="This is optional. You can disable this functionality by setting the value to 0.",fg_bg=g_lg_fg_bg} + + local range = NumberField{parent=net_c_3,x=1,y=10,width=10,default=ini_cfg.TrustedRange,min=0,max_chars=20,allow_decimal=true,fg_bg=bw_fg_bg} + + local tr_err = TextBox{parent=net_c_3,x=8,y=14,height=1,width=35,text="Please set the trusted range.",fg_bg=cpair(colors.red,colors.lightGray),hidden=true} + + local function submit_tr() + local range_val = tonumber(range.get_value()) + if range_val ~= nil then + tmp_cfg.TrustedRange = range_val + comms.set_trusted_range(range_val) + net_pane.set_value(4) + tr_err.hide(true) + else tr_err.show() end + end + + PushButton{parent=net_c_3,x=1,y=14,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,text="Next \x1a",callback=submit_tr,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + + TextBox{parent=net_c_4,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_4,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_4,x=1,y=11,height=1,text="Facility Auth Key"} + local key, _, censor = TextField{parent=net_c_4,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_4,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_4,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(3) + key_err.hide(true) + + -- init mac for supervisor connection + if string.len(v) >= 8 then network.init_mac(tmp_cfg.AuthKey) end + + -- prep supervisor connection screen + tool_ctl.sv_conn_button.enable() + tool_ctl.sv_conn_status.set_value("") + tool_ctl.sv_conn_detail.set_value("") + tool_ctl.sv_next.hide() + tool_ctl.sv_skip.show() + tool_ctl.sv_skip.disable() + tcd.dispatch_unique(2, function () tool_ctl.sv_skip.enable() end) + else key_err.show() end + end + + PushButton{parent=net_c_4,x=1,y=14,text="\x1b Back",callback=function()net_pane.set_value(3)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + PushButton{parent=net_c_4,x=44,y=14,text="Next \x1a",callback=submit_auth,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + + --#endregion + + --#region Facility + + local fac_c_1 = Div{parent=fac_cfg,x=2,y=4,width=49} + local fac_c_2 = Div{parent=fac_cfg,x=2,y=4,width=49} + local fac_c_3 = Div{parent=fac_cfg,x=2,y=4,width=49} + + local fac_pane = MultiPane{parent=mon_cfg,x=1,y=4,panes={fac_c_1,fac_c_2,fac_c_3}} + + TextBox{parent=fac_cfg,x=1,y=2,height=1,text=" Facility Configuration",fg_bg=cpair(colors.black,colors.yellow)} + + TextBox{parent=fac_c_1,x=1,y=1,height=4,text="This tool can attempt to connect to your supervisor computer. This would load facility information in order to get the unit count and aid monitor setup."} + TextBox{parent=fac_c_1,x=1,y=6,height=2,text="The supervisor startup app must be running and fully configured on your supervisor computer."} + + tool_ctl.sv_conn_status = TextBox{parent=fac_c_1,x=11,y=9,height=1,text=""} + tool_ctl.sv_conn_detail = TextBox{parent=fac_c_1,x=1,y=11,height=2,text=""} + + tool_ctl.sv_conn_button = PushButton{parent=fac_c_1,x=1,y=9,text="Connect",min_width=9,callback=function()tool_ctl.sv_connect()end,fg_bg=cpair(colors.black,colors.green),active_fg_bg=btn_act_fg_bg,dis_fg_bg=dis_fg_bg} + + local function sv_skip() + tcd.abort(handle_timeout) + tool_ctl.sv_fac_conf = nil + tool_ctl.net_listen = false + fac_pane.set_value(2) + end + + local function sv_next() + tool_ctl.show_sv_cfg() + tool_ctl.update_mon_reqs() + fac_pane.set_value(3) + end + + PushButton{parent=fac_c_1,x=1,y=14,text="\x1b Back",callback=function()main_pane.set_value(2)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + tool_ctl.sv_skip = PushButton{parent=fac_c_1,x=44,y=14,text="Skip \x1a",callback=sv_skip,fg_bg=cpair(colors.black,colors.red),active_fg_bg=btn_act_fg_bg,dis_fg_bg=dis_fg_bg} + tool_ctl.sv_next = PushButton{parent=fac_c_1,x=44,y=14,text="Next \x1a",callback=sv_next,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg,hidden=true} + + TextBox{parent=fac_c_2,x=1,y=1,height=3,text="Please enter the number of reactors you have, also referred to as reactor units or 'units' for short. A maximum of 4 is currently supported."} + local num_units = NumberField{parent=fac_c_2,x=1,y=5,width=5,max_chars=2,default=ini_cfg.UnitCount,min=1,max=4,fg_bg=bw_fg_bg} + TextBox{parent=fac_c_2,x=7,y=5,height=1,text="reactors"} + TextBox{parent=fac_c_2,x=1,y=7,height=3,text="This will decide how many monitors you need. If this does not match the supervisor's number of reactor units, the coordinator will not connect.",fg_bg=g_lg_fg_bg} + TextBox{parent=fac_c_2,x=1,y=10,height=3,text="Since you skipped supervisor sync, the main monitor minimum height can't be determined precisely. It is marked with * on the next page.",fg_bg=g_lg_fg_bg} + + local nu_error = TextBox{parent=fac_c_2,x=8,y=14,height=1,width=35,text="Please set the number of reactors.",fg_bg=cpair(colors.red,colors.lightGray),hidden=true} + + local function submit_num_units() + local count = tonumber(num_units.get_value()) + if count ~= nil and count > 0 and count < 5 then + nu_error.hide(true) + tmp_cfg.UnitCount = count + tool_ctl.update_mon_reqs() + main_pane.set_value(4) + else nu_error.show() end + end + + PushButton{parent=fac_c_2,x=1,y=14,text="\x1b Back",callback=function()fac_pane.set_value(1)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + PushButton{parent=fac_c_2,x=44,y=14,text="Next \x1a",callback=submit_num_units,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + + TextBox{parent=fac_c_3,x=1,y=1,height=2,text="The following facility configuration was fetched from your supervisor computer."} + + local fac_config_list = ListBox{parent=fac_c_3,x=1,y=4,height=9,width=51,scroll_height=100,fg_bg=bw_fg_bg,nav_fg_bg=g_lg_fg_bg,nav_active=cpair(colors.black,colors.gray)} + + PushButton{parent=fac_c_3,x=1,y=14,text="\x1b Back",callback=function()fac_pane.set_value(1)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + PushButton{parent=fac_c_3,x=44,y=14,text="Next \x1a",callback=function()main_pane.set_value(4)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + + --#endregion + + --#region Monitors + + local mon_c_1 = Div{parent=mon_cfg,x=2,y=4,width=49} + local mon_c_2 = Div{parent=mon_cfg,x=2,y=4,width=49} + local mon_c_3 = Div{parent=mon_cfg,x=2,y=4,width=49} + local mon_c_4 = Div{parent=mon_cfg,x=2,y=4,width=49} + + local mon_pane = MultiPane{parent=mon_cfg,x=1,y=4,panes={mon_c_1,mon_c_2,mon_c_3,mon_c_4}} + + TextBox{parent=mon_cfg,x=1,y=2,height=1,text=" Monitor Configuration",fg_bg=cpair(colors.black,colors.blue)} + + TextBox{parent=mon_c_1,x=1,y=1,height=5,text="Your configuration requires the following monitors. The main and flow monitors' heights are dependent on your unit count and cooling setup. If you manually entered the unit count, a * will be shown on potentially inaccurate calculations."} + local mon_reqs = ListBox{parent=mon_c_1,x=1,y=7,height=6,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 next_from_reqs() + -- unassign unit monitors above the unit count + for i = tmp_cfg.UnitCount + 1, 4 do tmp_cfg.UnitDisplays[i] = nil end + + tool_ctl.gen_mon_list() + mon_pane.set_value(2) + end + + PushButton{parent=mon_c_1,x=1,y=14,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=mon_c_1,x=8,y=14,text="Legacy Options",min_width=16,callback=function()mon_pane.set_value(4)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + PushButton{parent=mon_c_1,x=44,y=14,text="Next \x1a",callback=next_from_reqs,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + + TextBox{parent=mon_c_2,x=1,y=1,height=5,text="Please configure your monitors below. You can go back to the prior page without losing progress to double check what you need. All of those monitors must be assigned before you can proceed."} + + local mon_list = ListBox{parent=mon_c_2,x=1,y=6,height=7,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 assign_err = TextBox{parent=mon_c_2,x=8,y=14,height=1,width=35,text="",fg_bg=cpair(colors.red,colors.lightGray),hidden=true} + + local function submit_monitors() + if tmp_cfg.MainDisplay == nil then + assign_err.set_value("Please assign the main monitor.") + elseif tmp_cfg.FlowDisplay == nil and not tmp_cfg.DisableFlowView then + assign_err.set_value("Please assign the flow monitor.") + elseif util.table_len(tmp_cfg.UnitDisplays) ~= tmp_cfg.UnitCount then + for i = 1, tmp_cfg.UnitCount do + if tmp_cfg.UnitDisplays[i] == nil then + assign_err.set_value("Please assign the unit " .. i .. " monitor.") + break + end + end + else + assign_err.hide(true) + main_pane.set_value(5) + return + end + + assign_err.show() + end + + PushButton{parent=mon_c_2,x=1,y=14,text="\x1b Back",callback=function()mon_pane.set_value(1)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + PushButton{parent=mon_c_2,x=44,y=14,text="Next \x1a",callback=submit_monitors,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + + local mon_desc = TextBox{parent=mon_c_3,x=1,y=1,height=4,text=""} + + local mon_unit_l, mon_unit = nil, nil ---@type graphics_element, graphics_element + + local mon_warn = TextBox{parent=mon_c_3,x=1,y=11,height=2,text="",fg_bg=cpair(colors.red,colors.lightGray)} + + ---@param val integer assignment type + local function on_assign_mon(val) + if val == 2 and tmp_cfg.DisableFlowView then + tool_ctl.apply_mon.disable() + mon_warn.set_value("You disabled having a flow view monitor. It can't be set unless you go back and enable it.") + mon_warn.show() + elseif not util.table_contains(tool_ctl.mon_expect, val) then + tool_ctl.apply_mon.disable() + mon_warn.set_value("That assignment doesn't fit monitor dimensions. You'll need to resize the monitor for it to work.") + mon_warn.show() + else + tool_ctl.apply_mon.enable() + mon_warn.hide(true) + end + + if val == 3 then + mon_unit_l.show() + mon_unit.show() + else + mon_unit_l.hide(true) + mon_unit.hide(true) + end + + local value = mon_unit.get_value() + mon_unit.set_max(tmp_cfg.UnitCount) + if value == "0" or value == nil then mon_unit.set_value(0) end + end + + TextBox{parent=mon_c_3,x=1,y=6,width=10,height=1,text="Assignment"} + local mon_assign = RadioButton{parent=mon_c_3,x=1,y=7,default=1,options={"Main Monitor","Flow Monitor","Unit Monitor"},callback=on_assign_mon,radio_colors=cpair(colors.lightGray,colors.black),select_color=colors.blue} + + mon_unit_l = TextBox{parent=mon_c_3,x=18,y=6,width=7,height=1,text="Unit ID"} + mon_unit = NumberField{parent=mon_c_3,x=18,y=7,width=10,max_chars=2,min=1,max=4,fg_bg=bw_fg_bg} + + local mon_u_err = TextBox{parent=mon_c_3,x=8,y=14,height=1,width=35,text="Please provide a unit ID.",fg_bg=cpair(colors.red,colors.lightGray),hidden=true} + + -- purge all assignments for a given monitor + ---@param iface string + local function purge_assignments(iface) + if tmp_cfg.MainDisplay == iface then + tmp_cfg.MainDisplay = nil + elseif tmp_cfg.FlowDisplay == iface then + tmp_cfg.FlowDisplay = nil + else + for i = 1, tmp_cfg.UnitCount do + if tmp_cfg.UnitDisplays[i] == iface then tmp_cfg.UnitDisplays[i] = nil end + end + end + end + + local function apply_monitor() + local iface = tool_ctl.mon_iface + local type = mon_assign.get_value() + local u_id = tonumber(mon_unit.get_value()) + + if type == 1 then + purge_assignments(iface) + tmp_cfg.MainDisplay = iface + elseif type == 2 then + purge_assignments(iface) + tmp_cfg.FlowDisplay = iface + elseif u_id and u_id > 0 then + purge_assignments(iface) + tmp_cfg.UnitDisplays[u_id] = iface + else + mon_u_err.show() + return + end + + tool_ctl.gen_mon_list() + mon_u_err.hide(true) + mon_pane.set_value(2) + end + + PushButton{parent=mon_c_3,x=1,y=14,text="\x1b Back",callback=function()mon_pane.set_value(2)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + tool_ctl.apply_mon = PushButton{parent=mon_c_3,x=43,y=14,min_width=7,text="Apply",callback=apply_monitor,fg_bg=cpair(colors.black,colors.blue),active_fg_bg=btn_act_fg_bg,dis_fg_bg=dis_fg_bg} + + TextBox{parent=mon_c_4,x=1,y=1,height=3,text="For legacy compatibility with facilities built without space for a flow monitor, you can disable the flow monitor requirement here."} + TextBox{parent=mon_c_4,x=1,y=5,height=3,text="Please be aware that THIS OPTION WILL BE REMOVED ON RELEASE. Disabling it will only be available for the remainder of the beta."} + + local dis_flow_view = CheckBox{parent=mon_c_4,x=1,y=9,default=ini_cfg.DisableFlowView,label="Disable Flow View Monitor",box_fg_bg=cpair(colors.blue,colors.black)} + + local function back_from_legacy() + tmp_cfg.DisableFlowView = dis_flow_view.get_value() + tool_ctl.update_mon_reqs() + mon_pane.set_value(1) + end + + PushButton{parent=mon_c_4,x=1,y=14,text="\x1b Back",callback=back_from_legacy,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + + --#endregion + + --#region Speaker + + 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="The coordinator uses a speaker to play alarm sounds."} + 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_chars=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(6) + else s_vol_err.show() end + end + + PushButton{parent=spkr_c,x=1,y=14,text="\x1b Back",callback=function()main_pane.set_value(4)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + PushButton{parent=spkr_c,x=44,y=14,text="Next \x1a",callback=submit_vol,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + + --#endregion + + --#region Coordinator UI + + local crd_c_1 = Div{parent=crd_cfg,x=2,y=4,width=49} + + local crd_pane = MultiPane{parent=crd_cfg,x=1,y=4,panes={crd_c_1}} + + TextBox{parent=crd_cfg,x=1,y=2,height=1,text=" Coordinator UI Configuration",fg_bg=cpair(colors.black,colors.lime)} + + TextBox{parent=crd_c_1,x=1,y=1,height=3,text="Configure the UI interface options below if you wish to customize formats."} + + TextBox{parent=crd_c_1,x=1,y=4,height=1,text="Clock Time Format"} + local clock_fmt = RadioButton{parent=crd_c_1,x=1,y=5,default=util.trinary(ini_cfg.Time24Hour,1,2),options={"24-Hour","12-Hour"},callback=function()end,radio_colors=cpair(colors.lightGray,colors.black),select_color=colors.lime} + + local function submit_ui_opts() + tmp_cfg.Time24Hour = clock_fmt.get_value() == 1 + main_pane.set_value(7) + end + + PushButton{parent=crd_c_1,x=1,y=14,text="\x1b Back",callback=function()main_pane.set_value(5)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + PushButton{parent=crd_c_1,x=44,y=14,text="Next \x1a",callback=submit_ui_opts,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + + --#endregion + + --#region Logging + + 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() + main_pane.set_value(8) + else path_err.show() end + end + + PushButton{parent=log_c_1,x=1,y=14,text="\x1b Back",callback=function()main_pane.set_value(6)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + PushButton{parent=log_c_1,x=44,y=14,text="Next \x1a",callback=submit_log,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + + --#endregion + + --#region Summary and Saving + + 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_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=" 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_summary() + if tool_ctl.viewing_config or tool_ctl.importing_legacy then + main_pane.set_value(1) + tool_ctl.viewing_config = false + tool_ctl.importing_legacy = false + tool_ctl.settings_apply.show() + else + main_pane.set_value(7) + 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 + + local function save_and_continue() + for k, v in pairs(tmp_cfg) do settings.set(k, v) end + + if settings.save("coordinator.settings") then + load_settings(settings_cfg, true) + load_settings(ini_cfg) + + try_set(svr_chan, ini_cfg.SVR_Channel) + try_set(crd_chan, ini_cfg.CRD_Channel) + try_set(pkt_chan, ini_cfg.PKT_Channel) + try_set(svr_timeout, ini_cfg.SVR_Timeout) + try_set(api_timeout, ini_cfg.API_Timeout) + try_set(range, ini_cfg.TrustedRange) + try_set(key, ini_cfg.AuthKey) + try_set(num_units, ini_cfg.UnitCount) + try_set(dis_flow_view, ini_cfg.DisableFlowView) + try_set(s_vol, ini_cfg.SpeakerVolume) + try_set(clock_fmt, util.trinary(ini_cfg.Time24Hour, 1, 2)) + try_set(mode, ini_cfg.LogMode) + try_set(path, ini_cfg.LogPath) + try_set(en_dbg, ini_cfg.LogDebug) + + preset_monitor_fields() + + tool_ctl.gen_mon_list() + + tool_ctl.view_cfg.enable() + + if tool_ctl.importing_legacy then + tool_ctl.importing_legacy = false + sum_pane.set_value(3) + else + sum_pane.set_value(2) + end + else + sum_pane.set_value(4) + end + end + + PushButton{parent=sum_c_1,x=1,y=14,text="\x1b Back",callback=back_from_summary,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=dis_fg_bg} + 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="Settings saved!"} + + local function go_home() + main_pane.set_value(1) + net_pane.set_value(1) + fac_pane.set_value(1) + mon_pane.set_value(1) + crd_pane.set_value(1) + sum_pane.set_value(1) + end + + 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="The old config.lua and coord.settings files will now be deleted, then the configurator will exit."} + + local function delete_legacy() + fs.delete("/coordinator/config.lua") + fs.delete("/coord.settings") + exit() + end + + 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="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)} + + --#endregion + + -- 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,text="\x1b Back",callback=function()main_pane.set_value(1)end,fg_bg=nav_fg_bg,active_fg_bg=btn_act_fg_bg} + + -- set tool functions now that we have the elements + + -- load a legacy config file + function tool_ctl.load_legacy() + local config = require("coordinator.config") + + tmp_cfg.SVR_Channel = config.SVR_CHANNEL + tmp_cfg.CRD_Channel = config.CRD_CHANNEL + tmp_cfg.PKT_Channel = config.PKT_CHANNEL + tmp_cfg.SVR_Timeout = config.SV_TIMEOUT + tmp_cfg.API_Timeout = config.API_TIMEOUT + tmp_cfg.TrustedRange = config.TRUSTED_RANGE + tmp_cfg.AuthKey = config.AUTH_KEY or "" + + tmp_cfg.UnitCount = config.NUM_UNITS + tmp_cfg.DisableFlowView = config.DISABLE_FLOW_VIEW + tmp_cfg.SpeakerVolume = config.SOUNDER_VOLUME + tmp_cfg.Time24Hour = config.TIME_24_HOUR + + tmp_cfg.LogMode = config.LOG_MODE + tmp_cfg.LogPath = config.LOG_PATH + tmp_cfg.LogDebug = config.LOG_DEBUG or false + + settings.load("/coord.settings") + + tmp_cfg.MainDisplay = settings.get("PRIMARY_DISPLAY") + tmp_cfg.FlowDisplay = settings.get("FLOW_DISPLAY") + tmp_cfg.UnitDisplays = settings.get("UNIT_DISPLAYS", {}) + + -- if there are extra monitor entries, delete them now + -- not doing so will cause the app to fail to start + if is_int_min_max(tmp_cfg.UnitCount, 1, 4) then + for i = tmp_cfg.UnitCount + 1, 4 do tmp_cfg.UnitDisplays[i] = nil end + end + + if settings.get("ControlStates") == nil then + local ctrl_states = { + process = settings.get("PROCESS"), + waste_modes = settings.get("WASTE_MODES"), + priority_groups = settings.get("PRIORITY_GROUPS"), + } + + settings.set("ControlStates", ctrl_states) + end + + settings.unset("PRIMARY_DISPLAY") + settings.unset("FLOW_DISPLAY") + settings.unset("UNIT_DISPLAYS") + settings.unset("PROCESS") + settings.unset("WASTE_MODES") + settings.unset("PRIORITY_GROUPS") + + tool_ctl.gen_summary(tmp_cfg) + sum_pane.set_value(1) + main_pane.set_value(8) + tool_ctl.importing_legacy = true + end + + -- attempt a connection to the supervisor to get cooling info + function tool_ctl.sv_connect() + tool_ctl.sv_conn_button.disable() + tool_ctl.sv_conn_detail.set_value("") + + local modem = ppm.get_wireless_modem() + if modem == nil then + tool_ctl.sv_conn_status.set_value("Please connect an ender/wireless modem.") + else + tool_ctl.sv_conn_status.set_value("Modem found, connecting...") + if tool_ctl.nic == nil then tool_ctl.nic = network.nic(modem) end + + tool_ctl.nic.closeAll() + tool_ctl.nic.open(tmp_cfg.CRD_Channel) + + tool_ctl.sv_addr = comms.BROADCAST + tool_ctl.sv_seq_num = 0 + tool_ctl.net_listen = true + + send_sv(MGMT_TYPE.ESTABLISH, { comms.version, "0.0.0", DEVICE_TYPE.CRD }) + + tcd.dispatch_unique(8, handle_timeout) + end + end + + -- show the facility's unit count and cooling configuration data + function tool_ctl.show_sv_cfg() + local conf = tool_ctl.sv_cool_conf + fac_config_list.remove_all() + + local str = util.sprintf("Facility has %d reactor units:", #conf) + TextBox{parent=fac_config_list,height=1,text=str,fg_bg=cpair(colors.gray,colors.white)} + + for i = 1, #conf do + local num_b, num_t = conf[i][1], conf[i][2] + str = util.sprintf("\x07 Unit %d has %d boiler%s and %d turbine%s", i, num_b, util.trinary(num_b == 1, "", "s"), num_t, util.trinary(num_t == 1, "", "s")) + TextBox{parent=fac_config_list,height=1,text=str,fg_bg=cpair(colors.gray,colors.white)} + end + end + + -- update list of monitor requirements + function tool_ctl.update_mon_reqs() + local plural = tmp_cfg.UnitCount > 1 + + if tool_ctl.sv_cool_conf ~= nil then + local cnf = tool_ctl.sv_cool_conf + + local row1_tall = cnf[1][1] > 1 or cnf[1][2] > 2 or (cnf[2] and (cnf[2][1] > 1 or cnf[2][2] > 2)) + local row1_short = (cnf[1][1] == 0 and cnf[1][2] == 1) and (cnf[2] == nil or (cnf[2][1] == 0 and cnf[2][2] == 1)) + local row2_tall = (cnf[3] and (cnf[3][1] > 1 or cnf[3][2] > 2)) or (cnf[4] and (cnf[4][1] > 1 or cnf[4][2] > 2)) + local row2_short = (cnf[3] == nil or (cnf[3][1] == 0 and cnf[3][2] == 1)) and (cnf[4] == nil or (cnf[4][1] == 0 and cnf[4][2] == 1)) + + if tmp_cfg.UnitCount <= 2 then + tool_ctl.main_mon_h = util.trinary(row1_tall, 5, 4) + else + -- is only one tall and the other short, or are both tall? -> 5 or 6; are neither tall? -> 5 + if row1_tall or row2_tall then + tool_ctl.main_mon_h = util.trinary((row1_short and row2_tall) or (row1_tall and row2_short), 5, 6) + else tool_ctl.main_mon_h = 5 end + end + else + tool_ctl.main_mon_h = util.trinary(tmp_cfg.UnitCount <= 2, 4, 5) + end + + tool_ctl.flow_mon_h = 2 + tmp_cfg.UnitCount + + local asterisk = util.trinary(tool_ctl.sv_cool_conf == nil, "*", "") + local m_at_least = util.trinary(tool_ctl.main_mon_h < 6, "at least ", "") + local f_at_least = util.trinary(tool_ctl.flow_mon_h < 6, "at least ", "") + + mon_reqs.remove_all() + + TextBox{parent=mon_reqs,x=1,y=1,height=1,text="\x1a "..tmp_cfg.UnitCount.." Unit View Monitor"..util.trinary(plural,"s","")} + TextBox{parent=mon_reqs,x=1,y=1,height=1,text=" "..util.trinary(plural,"each ","").."must be 4 blocks wide by 4 tall",fg_bg=cpair(colors.gray,colors.white)} + TextBox{parent=mon_reqs,x=1,y=1,height=1,text="\x1a 1 Main View Monitor"} + TextBox{parent=mon_reqs,x=1,y=1,height=1,text=" must be 8 blocks wide by "..m_at_least..tool_ctl.main_mon_h..asterisk.." tall",fg_bg=cpair(colors.gray,colors.white)} + if not tmp_cfg.DisableFlowView then + TextBox{parent=mon_reqs,x=1,y=1,height=1,text="\x1a 1 Flow View Monitor"} + TextBox{parent=mon_reqs,x=1,y=1,height=1,text=" must be 8 blocks wide by "..f_at_least..tool_ctl.flow_mon_h.." tall",fg_bg=cpair(colors.gray,colors.white)} + end + end + + -- set/edit a monitor's assignment + ---@param iface string + ---@param device ppm_entry + function tool_ctl.edit_monitor(iface, device) + tool_ctl.mon_iface = iface + + local dev = device.dev + local w, h = ppm.monitor_block_size(dev.getSize()) + + local msg = "This size doesn't match a required screen. Please go back and resize it, or configure below at the risk of it not working." + + tool_ctl.mon_expect = {} + mon_assign.set_value(1) + mon_unit.set_value(0) + + if w == 4 and h == 4 then + msg = "This could work as a unit display. Please configure below." + tool_ctl.mon_expect = { 3 } + mon_assign.set_value(3) + elseif w == 8 then + if h >= tool_ctl.main_mon_h and h >= tool_ctl.flow_mon_h then + msg = "This could work as either your main monitor or flow monitor. Please configure below." + tool_ctl.mon_expect = { 1, 2 } + if tmp_cfg.MainDisplay then mon_assign.set_value(2) end + elseif h >= tool_ctl.main_mon_h then + msg = "This could work as your main monitor. Please configure below." + tool_ctl.mon_expect = { 1 } + elseif h >= tool_ctl.flow_mon_h then + msg = "This could work as your flow monitor. Please configure below." + tool_ctl.mon_expect = { 2 } + mon_assign.set_value(2) + end + end + + -- override if a config exists + if tmp_cfg.MainDisplay == iface then + mon_assign.set_value(1) + elseif tmp_cfg.FlowDisplay == iface then + mon_assign.set_value(2) + else + for i = 1, tmp_cfg.UnitCount do + if tmp_cfg.UnitDisplays[i] == iface then + mon_assign.set_value(3) + mon_unit.set_value(i) + break + end + end + end + + on_assign_mon(mon_assign.get_value()) + + mon_desc.set_value(util.c("You have selected '", iface, "', which has a block size of ", w, " wide by ", h, " tall. ", msg)) + mon_pane.set_value(3) + end + + -- generate the list of available monitors + function tool_ctl.gen_mon_list() + mon_list.remove_all() + + local monitors = ppm.get_monitor_list() + for iface, device in pairs(monitors) do + local dev = device.dev + + dev.setTextScale(0.5) + dev.setTextColor(colors.white) + dev.setBackgroundColor(colors.black) + dev.clear() + dev.setCursorPos(1, 1) + dev.setTextColor(colors.magenta) + dev.write("This is monitor") + dev.setCursorPos(1, 2) + dev.setTextColor(colors.white) + dev.write(iface) + + local assignment = "Unused" + + if tmp_cfg.MainDisplay == iface then + assignment = "Main" + elseif tmp_cfg.FlowDisplay == iface then + assignment = "Flow" + else + for i = 1, tmp_cfg.UnitCount do + if tmp_cfg.UnitDisplays[i] == iface then + assignment = "Unit " .. i + break + end + end + end + + local line = Div{parent=mon_list,x=1,y=1,height=1} + + TextBox{parent=line,x=1,y=1,width=6,height=1,text=assignment,fg_bg=cpair(util.trinary(assignment=="Unused",colors.red,colors.blue),colors.white)} + TextBox{parent=line,x=8,y=1,height=1,text=iface} + + local w, h = ppm.monitor_block_size(dev.getSize()) + + local function unset_mon() + purge_assignments(iface) + tool_ctl.gen_mon_list() + end + + TextBox{parent=line,x=33,y=1,width=4,height=1,text=w.."x"..h,fg_bg=cpair(colors.black,colors.white)} + PushButton{parent=line,x=37,y=1,min_width=5,height=1,text="SET",callback=function()tool_ctl.edit_monitor(iface,device)end,fg_bg=cpair(colors.black,colors.blue),active_fg_bg=btn_act_fg_bg} + local unset = PushButton{parent=line,x=42,y=1,min_width=7,height=1,text="UNSET",callback=unset_mon,fg_bg=cpair(colors.black,colors.red),active_fg_bg=btn_act_fg_bg,dis_fg_bg=cpair(colors.black,colors.gray)} + + if assignment == "Unused" then unset.disable() end + end + 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 crd_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)) + elseif f[1] == "LogMode" then val = util.trinary(raw == log.MODE.APPEND, "append", "replace") + elseif f[1] == "UnitDisplays" and type(cfg.UnitDisplays) == "table" then + val = "" + for idx = 1, #cfg.UnitDisplays do + val = val .. util.trinary(idx == 1, "", "\n") .. util.sprintf(" \x07 Unit %d - %s", idx, cfg.UnitDisplays[idx]) + end + 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 + + if (f[1] == "UnitDisplays") and (height == 1) and (val ~= "") then height = 2 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 +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 coordinator configurator
+-- start_fail of 0 is OK (default if not provided), 1 is bad config, 2 is bad monitor config +---@param start_code? 0|1|2 indicate error state when called from the startup app +---@param message? any string message to display on a start_fail of 2 +function configurator.configure(start_code, message) + tool_ctl.start_fail = start_code or 0 + tool_ctl.fail_message = util.trinary(type(message) == "string", message, "") + + load_settings(settings_cfg, true) + tool_ctl.has_config = load_settings(ini_cfg) + + -- copy in some important values to start with + preset_monitor_fields() + + 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) + + tool_ctl.gen_mon_list() + + while true do + local event, param1, param2, param3, param4, param5 = 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.gen_mon_list() + elseif event == "peripheral" then + ppm.mount(param1) + tool_ctl.gen_mon_list() + elseif event == "monitor_resize" then + tool_ctl.gen_mon_list() + elseif event == "modem_message" and tool_ctl.nic ~= nil and tool_ctl.net_listen then + local s_pkt = tool_ctl.nic.receive(param1, param2, param3, param4, param5) + + if s_pkt and s_pkt.protocol() == PROTOCOL.SCADA_MGMT then + local mgmt_pkt = comms.mgmt_packet() + if mgmt_pkt.decode(s_pkt) then + tcd.abort(handle_timeout) + handle_packet(mgmt_pkt.get()) + end + end + 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/coordinator/coordinator.lua b/coordinator/coordinator.lua index 80c21b6..61ee56f 100644 --- a/coordinator/coordinator.lua +++ b/coordinator/coordinator.lua @@ -9,11 +9,6 @@ local process = require("coordinator.process") local apisessions = require("coordinator.session.apisessions") -local dialog = require("coordinator.ui.dialog") - -local print = util.print -local println = util.println - local PROTOCOL = comms.PROTOCOL local DEVICE_TYPE = comms.DEVICE_TYPE local ESTABLISH_ACK = comms.ESTABLISH_ACK @@ -26,32 +21,75 @@ local LINK_TIMEOUT = 60.0 local coordinator = {} --- request the user to select a monitor ----@nodiscard ----@param names table available monitors ----@return boolean|string|nil -local function ask_monitor(names) - println("available monitors:") - for i = 1, #names do - print(" " .. names[i]) - end - println("") - println("select a monitor or type c to cancel") +---@type crd_config +local config = {} - local iface = dialog.ask_options(names, "c") +coordinator.config = config - if iface ~= false and iface ~= nil then - util.filter_table(names, function (x) return x ~= iface end) +-- load the coordinator configuration
+-- status of 0 is OK, 1 is bad config, 2 is bad monitor config +---@return 0|1|2 status, nil|monitors_struct|string monitors (or error message) +function coordinator.load_config() + if not settings.load("/coordinator.settings") then return 1 end + + config.UnitCount = settings.get("UnitCount") + config.SpeakerVolume = settings.get("SpeakerVolume") + config.Time24Hour = settings.get("Time24Hour") + + config.DisableFlowView = settings.get("DisableFlowView") + config.MainDisplay = settings.get("MainDisplay") + config.FlowDisplay = settings.get("FlowDisplay") + config.UnitDisplays = settings.get("UnitDisplays") + + config.SVR_Channel = settings.get("SVR_Channel") + config.CRD_Channel = settings.get("CRD_Channel") + config.PKT_Channel = settings.get("PKT_Channel") + config.SVR_Timeout = settings.get("SVR_Timeout") + config.API_Timeout = settings.get("API_Timeout") + 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_int(config.UnitCount) + cfv.assert_range(config.UnitCount, 1, 4) + cfv.assert_type_bool(config.Time24Hour) + + cfv.assert_type_bool(config.DisableFlowView) + cfv.assert_type_table(config.UnitDisplays) + + cfv.assert_type_num(config.SpeakerVolume) + cfv.assert_range(config.SpeakerVolume, 0, 3) + + cfv.assert_channel(config.SVR_Channel) + cfv.assert_channel(config.CRD_Channel) + cfv.assert_channel(config.PKT_Channel) + + cfv.assert_type_num(config.SVR_Timeout) + cfv.assert_min(config.SVR_Timeout, 2) + cfv.assert_type_num(config.API_Timeout) + cfv.assert_min(config.API_Timeout, 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 - return iface -end + cfv.assert_type_int(config.LogMode) + cfv.assert_range(config.LogMode, 0, 1) + cfv.assert_type_str(config.LogPath) + cfv.assert_type_bool(config.LogDebug) + + -- Monitor Setup --- configure monitor layout ----@param num_units integer number of units expected ----@param disable_flow_view boolean disable flow view (legacy) ----@return boolean success, monitors_struct? monitors -function coordinator.configure_monitors(num_units, disable_flow_view) ---@class monitors_struct local monitors = { primary = nil, ---@type table|nil @@ -62,146 +100,64 @@ function coordinator.configure_monitors(num_units, disable_flow_view) unit_name_map = {} } - local monitors_avail = ppm.get_monitor_list() - local names = {} - local available = {} + local mon_cfv = util.new_validator() -- get all interface names - for iface, _ in pairs(monitors_avail) do - table.insert(names, iface) - table.insert(available, iface) - end + local names = {} + for iface, _ in pairs(ppm.get_monitor_list()) do table.insert(names, iface) end - -- we need a certain number of monitors (1 per unit + 1 primary display + 1 flow display) - local num_displays_needed = num_units + util.trinary(disable_flow_view, 1, 2) - if #names < num_displays_needed then - local message = "not enough monitors connected (need " .. num_displays_needed .. ")" - println(message) - log.warning(message) - return false - end + local function setup_monitors() + mon_cfv.assert_type_str(config.MainDisplay) + if not config.DisableFlowView then mon_cfv.assert_type_str(config.FlowDisplay) end + mon_cfv.assert_eq(#config.UnitDisplays, config.UnitCount) - -- attempt to load settings - if not settings.load("/coord.settings") then - log.warning("configure_monitors(): failed to load coordinator settings file (may not exist yet)") - else - local _primary = settings.get("PRIMARY_DISPLAY") - local _flow = settings.get("FLOW_DISPLAY") - local _unitd = settings.get("UNIT_DISPLAYS") + if mon_cfv.valid() then + local w, h, _ - -- filter out already assigned monitors - util.filter_table(available, function (x) return x ~= _primary end) - util.filter_table(available, function (x) return x ~= _flow end) - if type(_unitd) == "table" then - util.filter_table(available, function (x) return not util.table_contains(_unitd, x) end) - end - end - - --------------------- - -- PRIMARY DISPLAY -- - --------------------- - - local iface_primary_display = settings.get("PRIMARY_DISPLAY") ---@type boolean|string|nil - - if not util.table_contains(names, iface_primary_display) then - println("primary display is not connected") - local response = dialog.ask_y_n("would you like to change it", true) - if response == false then return false end - iface_primary_display = nil - end - - while iface_primary_display == nil and #available > 0 do - iface_primary_display = ask_monitor(available) - end - - if type(iface_primary_display) ~= "string" then return false end - - settings.set("PRIMARY_DISPLAY", iface_primary_display) - util.filter_table(available, function (x) return x ~= iface_primary_display end) - - monitors.primary = ppm.get_periph(iface_primary_display) - monitors.primary_name = iface_primary_display - - -------------------------- - -- FLOW MONITOR DISPLAY -- - -------------------------- - - if not disable_flow_view then - local iface_flow_display = settings.get("FLOW_DISPLAY") ---@type boolean|string|nil - - if not util.table_contains(names, iface_flow_display) then - println("flow monitor display is not connected") - local response = dialog.ask_y_n("would you like to change it", true) - if response == false then return false end - iface_flow_display = nil - end - - while iface_flow_display == nil and #available > 0 do - iface_flow_display = ask_monitor(available) - end - - if type(iface_flow_display) ~= "string" then return false end - - settings.set("FLOW_DISPLAY", iface_flow_display) - util.filter_table(available, function (x) return x ~= iface_flow_display end) - - monitors.flow = ppm.get_periph(iface_flow_display) - monitors.flow_name = iface_flow_display - end - - ------------------- - -- UNIT DISPLAYS -- - ------------------- - - local unit_displays = settings.get("UNIT_DISPLAYS") - - if unit_displays == nil then - unit_displays = {} - for i = 1, num_units do - local display = nil - - while display == nil and #available > 0 do - println("please select monitor for unit #" .. i) - display = ask_monitor(available) + if not util.table_contains(names, config.MainDisplay) then + return 2, "Main monitor is not connected." end - if display == false then return false end + monitors.primary = ppm.get_periph(config.MainDisplay) + monitors.primary_name = config.MainDisplay - unit_displays[i] = display - end - else - -- make sure all displays are connected - for i = 1, num_units do - local display = unit_displays[i] + w, _ = ppm.monitor_block_size(monitors.primary.getSize()) + if w ~= 8 then return 2, "Main monitor width is incorrect." end - if not util.table_contains(names, display) then - println("unit #" .. i .. " display is not connected") - local response = dialog.ask_y_n("would you like to change it", true) - if response == false then return false end - display = nil + if not config.DisableFlowView then + if not util.table_contains(names, config.FlowDisplay) then + return 2, "Flow monitor is not connected." + end + + monitors.flow = ppm.get_periph(config.FlowDisplay) + monitors.flow_name = config.FlowDisplay + + w, _ = ppm.monitor_block_size(monitors.flow.getSize()) + if w ~= 8 then return 2, "Flow monitor width is incorrect." end end - while display == nil and #available > 0 do - display = ask_monitor(available) + for i = 1, config.UnitCount do + local display = config.UnitDisplays[i] + if type(display) ~= "string" or not util.table_contains(names, display) then + return 2, "Unit " .. i .. " monitor is not connected." + end + + monitors.unit_displays[i] = ppm.get_periph(display) + monitors.unit_name_map[i] = display + + w, h = ppm.monitor_block_size(monitors.unit_displays[i].getSize()) + if w ~= 4 or h ~= 4 then return 2, "Unit " .. i .. " monitor size is incorrect." end end - - if display == false then return false end - - unit_displays[i] = display - end + else return 2, "Monitor configuration invalid." end end - settings.set("UNIT_DISPLAYS", unit_displays) - if not settings.save("/coord.settings") then - log.warning("configure_monitors(): failed to save coordinator settings file") - end + if cfv.valid() then + local ok, result, message = pcall(setup_monitors) + assert(ok, util.c("fatal error while trying to verify monitors: ", result)) + if result == 2 then return 2, message end + else return 1 end - for i = 1, #unit_displays do - monitors.unit_displays[i] = ppm.get_periph(unit_displays[i]) - monitors.unit_name_map[i] = unit_displays[i] - end - - return true, monitors + return 0, monitors end -- dmesg print wrapper @@ -246,13 +202,8 @@ end ---@nodiscard ---@param version string coordinator version ---@param nic nic network interface device ----@param num_units integer number of configured units for number of monitors, checked against SV ----@param crd_channel integer port of configured supervisor ----@param svr_channel integer listening port for supervisor replys ----@param pkt_channel integer listening port for pocket API ----@param range integer trusted device connection range ---@param sv_watchdog watchdog -function coordinator.comms(version, nic, num_units, crd_channel, svr_channel, pkt_channel, range, sv_watchdog) +function coordinator.comms(version, nic, sv_watchdog) local self = { sv_linked = false, sv_addr = comms.BROADCAST, @@ -267,16 +218,16 @@ function coordinator.comms(version, nic, num_units, crd_channel, svr_channel, pk est_task_done = nil } - comms.set_trusted_range(range) - - -- PRIVATE FUNCTIONS -- + comms.set_trusted_range(config.TrustedRange) -- configure network channels nic.closeAll() - nic.open(crd_channel) + nic.open(config.CRD_Channel) - -- link nic to apisessions - apisessions.init(nic) + -- pass config to apisessions + apisessions.init(nic, config) + + -- PRIVATE FUNCTIONS -- -- send a packet to the supervisor ---@param msg_type MGMT_TYPE|CRDN_TYPE @@ -296,7 +247,7 @@ function coordinator.comms(version, nic, num_units, crd_channel, svr_channel, pk pkt.make(msg_type, msg) s_pkt.make(self.sv_addr, self.sv_seq_num, protocol, pkt.raw_sendable()) - nic.transmit(svr_channel, crd_channel, s_pkt) + nic.transmit(config.SVR_Channel, config.CRD_Channel, s_pkt) self.sv_seq_num = self.sv_seq_num + 1 end @@ -310,7 +261,7 @@ function coordinator.comms(version, nic, num_units, crd_channel, svr_channel, pk m_pkt.make(MGMT_TYPE.ESTABLISH, { ack }) s_pkt.make(packet.src_addr(), packet.seq_num() + 1, PROTOCOL.SCADA_MGMT, m_pkt.raw_sendable()) - nic.transmit(pkt_channel, crd_channel, s_pkt) + nic.transmit(config.PKT_Channel, config.CRD_Channel, s_pkt) self.last_api_est_acks[packet.src_addr()] = ack end @@ -343,7 +294,7 @@ function coordinator.comms(version, nic, num_units, crd_channel, svr_channel, pk self.est_last = self.est_start self.est_tick_waiting, self.est_task_done = - coordinator.log_comms_connecting("attempting to connect to configured supervisor on channel " .. svr_channel) + coordinator.log_comms_connecting("attempting to connect to configured supervisor on channel " .. config.SVR_Channel) _send_establish() else @@ -356,7 +307,7 @@ function coordinator.comms(version, nic, num_units, crd_channel, svr_channel, pk if abort then coordinator.log_comms("supervisor connection attempt cancelled by user") elseif self.sv_config_err then - coordinator.log_comms("supervisor cooling configuration invalid, check supervisor config file") + coordinator.log_comms("supervisor unit count does not match coordinator unit count, check configs") elseif not self.sv_linked then if self.last_est_ack == ESTABLISH_ACK.DENY then coordinator.log_comms("supervisor connection attempt denied") @@ -371,7 +322,7 @@ function coordinator.comms(version, nic, num_units, crd_channel, svr_channel, pk ok = false elseif self.sv_config_err then - coordinator.log_comms("supervisor cooling configuration invalid, check supervisor config file") + coordinator.log_comms("supervisor unit count does not match coordinator unit count, check configs") ok = false elseif (util.time_s() - self.est_last) > 1.0 then _send_establish() @@ -405,10 +356,10 @@ function coordinator.comms(version, nic, num_units, crd_channel, svr_channel, pk end -- send the auto process control configuration with a start command - ---@param config coord_auto_config configuration - function public.send_auto_start(config) + ---@param auto_cfg coord_auto_config configuration + function public.send_auto_start(auto_cfg) _send_sv(PROTOCOL.SCADA_CRDN, CRDN_TYPE.FAC_CMD, { - FAC_COMMAND.START, config.mode, config.burn_target, config.charge_target, config.gen_target, config.limits + FAC_COMMAND.START, auto_cfg.mode, auto_cfg.burn_target, auto_cfg.charge_target, auto_cfg.gen_target, auto_cfg.limits }) end @@ -464,9 +415,9 @@ function coordinator.comms(version, nic, num_units, crd_channel, svr_channel, pk local src_addr = packet.scada_frame.src_addr() local protocol = packet.scada_frame.protocol() - if l_chan ~= crd_channel then + if l_chan ~= config.CRD_Channel then log.debug("received packet on unconfigured channel " .. l_chan, true) - elseif r_chan == pkt_channel then + elseif r_chan == config.PKT_Channel then if not self.sv_linked then log.debug("discarding pocket API packet before linked to supervisor") elseif protocol == PROTOCOL.SCADA_CRDN then @@ -526,7 +477,7 @@ function coordinator.comms(version, nic, num_units, crd_channel, svr_channel, pk else log.debug("illegal packet type " .. protocol .. " on pocket channel", true) end - elseif r_chan == svr_channel then + elseif r_chan == config.SVR_Channel then -- check sequence number if self.sv_r_seq_num == nil then self.sv_r_seq_num = packet.scada_frame.seq_num() @@ -699,22 +650,22 @@ function coordinator.comms(version, nic, num_units, crd_channel, svr_channel, pk -- connection with supervisor established if packet.length == 2 then local est_ack = packet.data[1] - local config = packet.data[2] + local sv_config = packet.data[2] if est_ack == ESTABLISH_ACK.ALLOW then -- reset to disconnected before validating iocontrol.fp_link_state(types.PANEL_LINK_STATE.DISCONNECTED) - if type(config) == "table" and #config == 2 then + if type(sv_config) == "table" and #sv_config == 2 then -- get configuration ---@class facility_conf local conf = { - num_units = config[1], ---@type integer - cooling = config[2] ---@type sv_cooling_conf + num_units = sv_config[1], ---@type integer + cooling = sv_config[2] ---@type sv_cooling_conf } - if conf.num_units == num_units then + if conf.num_units == config.UnitCount then -- init io controller iocontrol.init(conf, public) diff --git a/coordinator/process.lua b/coordinator/process.lua index 5551c93..581fcd9 100644 --- a/coordinator/process.lua +++ b/coordinator/process.lua @@ -19,15 +19,20 @@ local process = {} local self = { io = nil, ---@type ioctl comms = nil, ---@type coord_comms - ---@class coord_auto_config - config = { - mode = PROCESS.INACTIVE, - burn_target = 0.0, - charge_target = 0.0, - gen_target = 0.0, - limits = {}, - waste_product = PRODUCT.PLUTONIUM, - pu_fallback = false + ---@class coord_control_states + control_states = { + ---@class coord_auto_config + process = { + mode = PROCESS.INACTIVE, + burn_target = 0.0, + charge_target = 0.0, + gen_target = 0.0, + limits = {}, + waste_product = PRODUCT.PLUTONIUM, + pu_fallback = false + }, + waste_modes = {}, + priority_groups = {} } } @@ -42,63 +47,64 @@ function process.init(iocontrol, coord_comms) self.io = iocontrol self.comms = coord_comms + local ctl_proc = self.control_states.process + for i = 1, self.io.facility.num_units do - self.config.limits[i] = 0.1 + ctl_proc.limits[i] = 0.1 end - -- load settings - if not settings.load("/coord.settings") then - log.error("process.init(): failed to load coordinator settings file") - end + local ctrl_states = settings.get("ControlStates", {}) + local config = ctrl_states.process ---@type coord_auto_config -- facility auto control configuration - local config = settings.get("PROCESS") ---@type coord_auto_config|nil if type(config) == "table" then - self.config.mode = config.mode - self.config.burn_target = config.burn_target - self.config.charge_target = config.charge_target - self.config.gen_target = config.gen_target - self.config.limits = config.limits - self.config.waste_product = config.waste_product - self.config.pu_fallback = config.pu_fallback + ctl_proc.mode = config.mode + ctl_proc.burn_target = config.burn_target + ctl_proc.charge_target = config.charge_target + ctl_proc.gen_target = config.gen_target + ctl_proc.limits = config.limits + ctl_proc.waste_product = config.waste_product + ctl_proc.pu_fallback = config.pu_fallback - self.io.facility.ps.publish("process_mode", self.config.mode) - self.io.facility.ps.publish("process_burn_target", self.config.burn_target) - self.io.facility.ps.publish("process_charge_target", self.config.charge_target) - self.io.facility.ps.publish("process_gen_target", self.config.gen_target) - self.io.facility.ps.publish("process_waste_product", self.config.waste_product) - self.io.facility.ps.publish("process_pu_fallback", self.config.pu_fallback) + self.io.facility.ps.publish("process_mode", ctl_proc.mode) + self.io.facility.ps.publish("process_burn_target", ctl_proc.burn_target) + self.io.facility.ps.publish("process_charge_target", ctl_proc.charge_target) + self.io.facility.ps.publish("process_gen_target", ctl_proc.gen_target) + self.io.facility.ps.publish("process_waste_product", ctl_proc.waste_product) + self.io.facility.ps.publish("process_pu_fallback", ctl_proc.pu_fallback) - for id = 1, math.min(#self.config.limits, self.io.facility.num_units) do + for id = 1, math.min(#ctl_proc.limits, self.io.facility.num_units) do local unit = self.io.units[id] ---@type ioctl_unit - unit.unit_ps.publish("burn_limit", self.config.limits[id]) + unit.unit_ps.publish("burn_limit", ctl_proc.limits[id]) end - log.info("PROCESS: loaded auto control settings from coord.settings") + log.info("PROCESS: loaded auto control settings") -- notify supervisor of auto waste config - self.comms.send_fac_command(FAC_COMMAND.SET_WASTE_MODE, self.config.waste_product) - self.comms.send_fac_command(FAC_COMMAND.SET_PU_FB, self.config.pu_fallback) + self.comms.send_fac_command(FAC_COMMAND.SET_WASTE_MODE, ctl_proc.waste_product) + self.comms.send_fac_command(FAC_COMMAND.SET_PU_FB, ctl_proc.pu_fallback) end -- unit waste states - local waste_modes = settings.get("WASTE_MODES") ---@type table|nil + local waste_modes = ctrl_states.waste_modes ---@type table|nil if type(waste_modes) == "table" then for id, mode in pairs(waste_modes) do + self.control_states.waste_modes[id] = mode self.comms.send_unit_command(UNIT_COMMAND.SET_WASTE, id, mode) end - log.info("PROCESS: loaded unit waste mode settings from coord.settings") + log.info("PROCESS: loaded unit waste mode settings") end -- unit priority groups - local prio_groups = settings.get("PRIORITY_GROUPS") ---@type table|nil + local prio_groups = ctrl_states.priority_groups ---@type table|nil if type(prio_groups) == "table" then for id, group in pairs(prio_groups) do + self.control_states.priority_groups[id] = group self.comms.send_unit_command(UNIT_COMMAND.SET_GROUP, id, group) end - log.info("PROCESS: loaded priority groups settings from coord.settings") + log.info("PROCESS: loaded priority groups settings") end end @@ -155,15 +161,10 @@ function process.set_unit_waste(id, mode) self.comms.send_unit_command(UNIT_COMMAND.SET_WASTE, id, mode) log.debug(util.c("PROCESS: UNIT[", id, "] SET WASTE ", mode)) - local waste_mode = settings.get("WASTE_MODES") ---@type table|nil + self.control_states.waste_modes[id] = mode + settings.set("ControlStates", self.control_states) - if type(waste_mode) ~= "table" then waste_mode = {} end - - waste_mode[id] = mode - - settings.set("WASTE_MODES", waste_mode) - - if not settings.save("/coord.settings") then + if not settings.save("/coordinator.settings") then log.error("process.set_unit_waste(): failed to save coordinator settings file") end end @@ -198,15 +199,10 @@ function process.set_group(unit_id, group_id) self.comms.send_unit_command(UNIT_COMMAND.SET_GROUP, unit_id, group_id) log.debug(util.c("PROCESS: UNIT[", unit_id, "] SET GROUP ", group_id)) - local prio_groups = settings.get("PRIORITY_GROUPS") ---@type table|nil + self.control_states.priority_groups[unit_id] = group_id + settings.set("ControlStates", self.control_states) - if type(prio_groups) ~= "table" then prio_groups = {} end - - prio_groups[unit_id] = group_id - - settings.set("PRIORITY_GROUPS", prio_groups) - - if not settings.save("/coord.settings") then + if not settings.save("/coordinator.settings") then log.error("process.set_group(): failed to save coordinator settings file") end end @@ -217,20 +213,14 @@ end -- write auto process control to config file local function _write_auto_config() - -- attempt to load settings - if not settings.load("/coord.settings") then - log.warning("process._write_auto_config(): failed to load coordinator settings file") - end - -- save config - settings.set("PROCESS", self.config) - local saved = settings.save("/coord.settings") - + settings.set("ControlStates", self.control_states) + local saved = settings.save("/coordinator.settings") if not saved then log.warning("process._write_auto_config(): failed to save coordinator settings file") end - return not not saved + return saved end -- stop automatic process control @@ -241,7 +231,7 @@ end -- start automatic process control function process.start_auto() - self.comms.send_auto_start(self.config) + self.comms.send_auto_start(self.control_states.process) log.debug("PROCESS: START AUTO CTL") end @@ -253,7 +243,7 @@ function process.set_process_waste(product) log.debug(util.c("PROCESS: SET WASTE ", product)) -- update config table and save - self.config.waste_product = product + self.control_states.process.waste_product = product _write_auto_config() end @@ -265,7 +255,7 @@ function process.set_pu_fallback(enabled) log.debug(util.c("PROCESS: SET PU FALLBACK ", enabled)) -- update config table and save - self.config.pu_fallback = enabled + self.control_states.process.pu_fallback = enabled _write_auto_config() end @@ -279,11 +269,12 @@ function process.save(mode, burn_target, charge_target, gen_target, limits) log.debug("PROCESS: SAVE") -- update config table - self.config.mode = mode - self.config.burn_target = burn_target - self.config.charge_target = charge_target - self.config.gen_target = gen_target - self.config.limits = limits + local ctl_proc = self.control_states.process + ctl_proc.mode = mode + ctl_proc.burn_target = burn_target + ctl_proc.charge_target = charge_target + ctl_proc.gen_target = gen_target + ctl_proc.limits = limits -- save config self.io.facility.save_cfg_ack(_write_auto_config()) @@ -294,22 +285,23 @@ end function process.start_ack_handle(response) local ack = response[1] - self.config.mode = response[2] - self.config.burn_target = response[3] - self.config.charge_target = response[4] - self.config.gen_target = response[5] + local ctl_proc = self.control_states.process + ctl_proc.mode = response[2] + ctl_proc.burn_target = response[3] + ctl_proc.charge_target = response[4] + ctl_proc.gen_target = response[5] for i = 1, math.min(#response[6], self.io.facility.num_units) do - self.config.limits[i] = response[6][i] + ctl_proc.limits[i] = response[6][i] local unit = self.io.units[i] ---@type ioctl_unit - unit.unit_ps.publish("burn_limit", self.config.limits[i]) + unit.unit_ps.publish("burn_limit", ctl_proc.limits[i]) end - self.io.facility.ps.publish("process_mode", self.config.mode) - self.io.facility.ps.publish("process_burn_target", self.config.burn_target) - self.io.facility.ps.publish("process_charge_target", self.config.charge_target) - self.io.facility.ps.publish("process_gen_target", self.config.gen_target) + self.io.facility.ps.publish("process_mode", ctl_proc.mode) + self.io.facility.ps.publish("process_burn_target", ctl_proc.burn_target) + self.io.facility.ps.publish("process_charge_target", ctl_proc.charge_target) + self.io.facility.ps.publish("process_gen_target", ctl_proc.gen_target) self.io.facility.start_ack(ack) end @@ -317,14 +309,14 @@ end -- record waste product state after attempting to change it ---@param response WASTE_PRODUCT supervisor waste product state function process.waste_ack_handle(response) - self.config.waste_product = response + self.control_states.process.waste_product = response self.io.facility.ps.publish("process_waste_product", response) end -- record plutonium fallback state after attempting to change it ---@param response boolean supervisor plutonium fallback state function process.pu_fb_ack_handle(response) - self.config.pu_fallback = response + self.control_states.process.pu_fallback = response self.io.facility.ps.publish("process_pu_fallback", response) end diff --git a/coordinator/renderer.lua b/coordinator/renderer.lua index 5aadbb2..07282c4 100644 --- a/coordinator/renderer.lua +++ b/coordinator/renderer.lua @@ -3,7 +3,6 @@ -- local log = require("scada-common.log") -local util = require("scada-common.util") local iocontrol = require("coordinator.iocontrol") @@ -93,39 +92,6 @@ function renderer.init_displays() end end --- check main display width ----@nodiscard ----@return boolean width_okay -function renderer.validate_main_display_width() - local w, _ = engine.monitors.primary.getSize() - return w == 164 -end - --- check flow display width ----@nodiscard ----@return boolean width_okay -function renderer.validate_flow_display_width() - local w, _ = engine.monitors.flow.getSize() - return w == 164 -end - --- check display sizes ----@nodiscard ----@return boolean valid all unit display dimensions OK -function renderer.validate_unit_display_sizes() - local valid = true - - for id, monitor in ipairs(engine.monitors.unit_displays) do - local w, h = monitor.getSize() - if w ~= 79 or h ~= 52 then - log.warning(util.c("RENDERER: unit ", id, " display resolution not 79 wide by 52 tall: ", w, ", ", h)) - valid = false - end - end - - return valid -end - -- initialize the dmesg output window function renderer.init_dmesg() local disp_x, disp_y = engine.monitors.primary.getSize() diff --git a/coordinator/session/apisessions.lua b/coordinator/session/apisessions.lua index 6e8c771..516b91b 100644 --- a/coordinator/session/apisessions.lua +++ b/coordinator/session/apisessions.lua @@ -3,7 +3,6 @@ local log = require("scada-common.log") local mqueue = require("scada-common.mqueue") local util = require("scada-common.util") -local config = require("coordinator.config") local iocontrol = require("coordinator.iocontrol") local pocket = require("coordinator.session.pocket") @@ -11,7 +10,8 @@ local pocket = require("coordinator.session.pocket") local apisessions = {} local self = { - nic = nil, + nic = nil, ---@type nic + config = nil, ---@type crd_config next_id = 0, sessions = {} } @@ -32,7 +32,7 @@ local function _api_handle_outq(session) if msg ~= nil then if msg.qtype == mqueue.TYPE.PACKET then -- handle a packet to be sent - self.nic.transmit(config.PKT_CHANNEL, config.CRD_CHANNEL, msg.message) + self.nic.transmit(self.config.PKT_Channel, self.config.CRD_Channel, msg.message) elseif msg.qtype == mqueue.TYPE.COMMAND then -- handle instruction/notification elseif msg.qtype == mqueue.TYPE.DATA then @@ -59,7 +59,7 @@ local function _shutdown(session) while session.out_queue.ready() do local msg = session.out_queue.pop() if msg ~= nil and msg.qtype == mqueue.TYPE.PACKET then - self.nic.transmit(config.PKT_CHANNEL, config.CRD_CHANNEL, msg.message) + self.nic.transmit(self.config.PKT_Channel, self.config.CRD_Channel, msg.message) end end @@ -69,9 +69,11 @@ end -- PUBLIC FUNCTIONS -- -- initialize apisessions ----@param nic nic -function apisessions.init(nic) +---@param nic nic network interface +---@param config crd_config coordinator config +function apisessions.init(nic, config) self.nic = nic + self.config = config end -- find a session by remote port @@ -103,7 +105,7 @@ function apisessions.establish_session(source_addr, version) local id = self.next_id - pkt_s.instance = pocket.new_session(id, source_addr, pkt_s.in_queue, pkt_s.out_queue, config.API_TIMEOUT) + pkt_s.instance = pocket.new_session(id, source_addr, pkt_s.in_queue, pkt_s.out_queue, self.config.API_Timeout) table.insert(self.sessions, pkt_s) local mt = { diff --git a/coordinator/startup.lua b/coordinator/startup.lua index 3a52029..4301bac 100644 --- a/coordinator/startup.lua +++ b/coordinator/startup.lua @@ -14,7 +14,7 @@ local util = require("scada-common.util") local core = require("graphics.core") -local config = require("coordinator.config") +local configure = require("coordinator.configure") local coordinator = require("coordinator.coordinator") local iocontrol = require("coordinator.iocontrol") local renderer = require("coordinator.renderer") @@ -22,7 +22,7 @@ local sounder = require("coordinator.sounder") local apisessions = require("coordinator.session.apisessions") -local COORDINATOR_VERSION = "v1.1.0" +local COORDINATOR_VERSION = "v1.2.0" local println = util.println local println_ts = util.println_ts @@ -34,32 +34,34 @@ local log_comms = coordinator.log_comms local log_crypto = coordinator.log_crypto ---------------------------------------- --- config validation +-- get configuration ---------------------------------------- -local cfv = util.new_validator() +-- mount connected devices (required for monitor setup) +ppm.mount_all() -cfv.assert_channel(config.SVR_CHANNEL) -cfv.assert_channel(config.CRD_CHANNEL) -cfv.assert_channel(config.PKT_CHANNEL) -cfv.assert_type_int(config.TRUSTED_RANGE) -cfv.assert_type_num(config.SV_TIMEOUT) -cfv.assert_min(config.SV_TIMEOUT, 2) -cfv.assert_type_num(config.API_TIMEOUT) -cfv.assert_min(config.API_TIMEOUT, 2) -cfv.assert_type_int(config.NUM_UNITS) -cfv.assert_type_num(config.SOUNDER_VOLUME) -cfv.assert_type_bool(config.TIME_24_HOUR) -cfv.assert_type_str(config.LOG_PATH) -cfv.assert_type_int(config.LOG_MODE) +local loaded, monitors = coordinator.load_config() +if loaded ~= 0 then + -- try to reconfigure (user action) + local success, error = configure.configure(loaded, monitors) + if success then + loaded, monitors = coordinator.load_config() + assert(loaded == 0, util.trinary(loaded == 1, "failed to load valid configuration", "monitor configuration invalid")) + else + assert(success, "coordinator configuration error: " .. error) + end +end -assert(cfv.valid(), "bad config file: missing/invalid fields") +-- passed checks, good now +---@cast monitors monitors_struct + +local config = coordinator.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 coordinator.startup " .. COORDINATOR_VERSION) @@ -77,39 +79,13 @@ local function main() -- system startup ---------------------------------------- - -- mount connected devices - ppm.mount_all() - -- report versions/init fp PSIL iocontrol.init_fp(COORDINATOR_VERSION, comms.version) - -- setup monitors - local configured, monitors = coordinator.configure_monitors(config.NUM_UNITS, config.DISABLE_FLOW_VIEW == true) - if not configured or monitors == nil then - println("startup> monitor setup failed") - log.fatal("monitor configuration failed") - return - end - -- init renderer - renderer.legacy_disable_flow_view(config.DISABLE_FLOW_VIEW == true) + renderer.legacy_disable_flow_view(config.DisableFlowView) renderer.set_displays(monitors) renderer.init_displays() - - if not renderer.validate_main_display_width() then - println("startup> main display must be 8 blocks wide") - log.fatal("main display not wide enough") - return - elseif (config.DISABLE_FLOW_VIEW ~= true) and not renderer.validate_flow_display_width() then - println("startup> flow display must be 8 blocks wide") - log.fatal("flow display not wide enough") - return - elseif not renderer.validate_unit_display_sizes() then - println("startup> one or more unit display dimensions incorrect; they must be 4x4 blocks") - log.fatal("unit display dimensions incorrect") - return - end - renderer.init_dmesg() -- lets get started! @@ -132,7 +108,7 @@ local function main() else local sounder_start = util.time_ms() log_boot("annunciator alarm speaker connected") - sounder.init(speaker, config.SOUNDER_VOLUME) + sounder.init(speaker, config.SpeakerVolume) log_boot("tone generation took " .. (util.time_ms() - sounder_start) .. "ms") log_sys("annunciator alarm configured") iocontrol.fp_has_speaker(true) @@ -143,8 +119,8 @@ local function main() ---------------------------------------- -- message authentication init - if type(config.AUTH_KEY) == "string" then - local init_time = network.init_mac(config.AUTH_KEY) + if type(config.AuthKey) == "string" and string.len(config.AuthKey) > 0 then + local init_time = network.init_mac(config.AuthKey) log_crypto("HMAC init took " .. init_time .. "ms") end @@ -161,14 +137,13 @@ local function main() end -- create connection watchdog - local conn_watchdog = util.new_watchdog(config.SV_TIMEOUT) + local conn_watchdog = util.new_watchdog(config.SVR_Timeout) conn_watchdog.cancel() log.debug("startup> conn watchdog created") -- create network interface then setup comms local nic = network.nic(modem) - local coord_comms = coordinator.comms(COORDINATOR_VERSION, nic, config.NUM_UNITS, config.CRD_CHANNEL, - config.SVR_CHANNEL, config.PKT_CHANNEL, config.TRUSTED_RANGE, conn_watchdog) + local coord_comms = coordinator.comms(COORDINATOR_VERSION, nic, conn_watchdog) log.debug("startup> comms init") log_comms("comms initialized") @@ -214,7 +189,7 @@ local function main() local link_failed = false local ui_ok = true - local date_format = util.trinary(config.TIME_24_HOUR, "%X \x04 %A, %B %d %Y", "%r \x04 %A, %B %d %Y") + local date_format = util.trinary(config.Time24Hour, "%X \x04 %A, %B %d %Y", "%r \x04 %A, %B %d %Y") -- start clock loop_clock.start() diff --git a/coordinator/ui/dialog.lua b/coordinator/ui/dialog.lua deleted file mode 100644 index 676ae2b..0000000 --- a/coordinator/ui/dialog.lua +++ /dev/null @@ -1,52 +0,0 @@ -local completion = require("cc.completion") - -local util = require("scada-common.util") - -local print = util.print - -local dialog = {} - --- ask the user yes or no ----@nodiscard ----@param question string ----@param default boolean ----@return boolean|nil -function dialog.ask_y_n(question, default) - print(question) - - if default == true then - print(" (Y/n)? ") - else - print(" (y/N)? ") - end - - local response = read(nil, nil) - - if response == "" then - return default - elseif response == "Y" or response == "y" then - return true - elseif response == "N" or response == "n" then - return false - else - return nil - end -end - --- ask the user for an input within a set of options ----@nodiscard ----@param options table ----@param cancel string ----@return boolean|string|nil -function dialog.ask_options(options, cancel) - print("> ") - local response = read(nil, nil, function(text) return completion.choice(text, options) end) - - if response == cancel then return false end - - if util.table_contains(options, response) then - return response - else return nil end -end - -return dialog diff --git a/graphics/core.lua b/graphics/core.lua index 13f97ec..3e53b83 100644 --- a/graphics/core.lua +++ b/graphics/core.lua @@ -7,7 +7,7 @@ local flasher = require("graphics.flasher") local core = {} -core.version = "2.1.0" +core.version = "2.1.1" core.flasher = flasher core.events = events diff --git a/graphics/element.lua b/graphics/element.lua index 6debaf4..939efd6 100644 --- a/graphics/element.lua +++ b/graphics/element.lua @@ -843,9 +843,12 @@ function element.new(args, child_offset_x, child_offset_y) -- re-draw this element and all its children function public.redraw() + local bg, fg = protected.window.getBackgroundColor(), protected.window.getTextColor() protected.window.setBackgroundColor(protected.fg_bg.bkg) protected.window.setTextColor(protected.fg_bg.fgd) protected.window.clear() + protected.window.setBackgroundColor(bg) + protected.window.setTextColor(fg) protected.redraw() for _, child in pairs(protected.children) do child.get().redraw() end end diff --git a/reactor-plc/plc.lua b/reactor-plc/plc.lua index 7287d3d..c5e6be3 100644 --- a/reactor-plc/plc.lua +++ b/reactor-plc/plc.lua @@ -37,14 +37,17 @@ function plc.load_config() config.Networked = settings.get("Networked") config.UnitID = settings.get("UnitID") + config.EmerCoolEnable = settings.get("EmerCoolEnable") config.EmerCoolSide = settings.get("EmerCoolSide") config.EmerCoolColor = settings.get("EmerCoolColor") + config.SVR_Channel = settings.get("SVR_Channel") config.PLC_Channel = settings.get("PLC_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") @@ -71,6 +74,7 @@ function plc.load_config() end cfv.assert_type_int(config.LogMode) + cfv.assert_range(config.LogMode, 0, 1) cfv.assert_type_str(config.LogPath) cfv.assert_type_bool(config.LogDebug) diff --git a/reactor-plc/startup.lua b/reactor-plc/startup.lua index 1e066d0..d73037d 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.9" +local R_PLC_VERSION = "v1.6.10" local println = util.println local println_ts = util.println_ts @@ -31,7 +31,7 @@ if not plc.load_config() then -- try to reconfigure (user action) local success, error = configure.configure(true) if success then - assert(plc.load_config(), "failed to load valid reactor PLC configuration") + assert(plc.load_config(), "failed to load valid configuration") else assert(success, "reactor PLC configuration error: " .. error) end diff --git a/rtu/rtu.lua b/rtu/rtu.lua index b76ce30..6c2b01f 100644 --- a/rtu/rtu.lua +++ b/rtu/rtu.lua @@ -41,6 +41,8 @@ function rtu.load_config() local cfv = util.new_validator() cfv.assert_type_num(config.SpeakerVolume) + cfv.assert_range(config.SpeakerVolume, 0, 3) + cfv.assert_channel(config.SVR_Channel) cfv.assert_channel(config.RTU_Channel) cfv.assert_type_num(config.ConnTimeout) @@ -55,6 +57,7 @@ function rtu.load_config() end cfv.assert_type_int(config.LogMode) + cfv.assert_range(config.LogMode, 0, 1) cfv.assert_type_str(config.LogPath) cfv.assert_type_bool(config.LogDebug) diff --git a/rtu/startup.lua b/rtu/startup.lua index 0a3753d..e8e85fc 100644 --- a/rtu/startup.lua +++ b/rtu/startup.lua @@ -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.7.11" +local RTU_VERSION = "v1.7.12" local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE local RTU_UNIT_HW_STATE = databus.RTU_UNIT_HW_STATE @@ -47,7 +47,7 @@ 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") + assert(rtu.load_config(), "failed to load valid configuration") else assert(success, "RTU configuration error: " .. error) end diff --git a/scada-common/ppm.lua b/scada-common/ppm.lua index d54fec8..df64b68 100644 --- a/scada-common/ppm.lua +++ b/scada-common/ppm.lua @@ -421,4 +421,15 @@ function ppm.get_monitor_list() return list end +-- HELPER FUNCTIONS + +-- get the block size of a monitor given its width and height at a text scale of 0.5 +---@nodiscard +---@param width integer character width +---@param height integer character height +---@return integer block_width, integer block_height +function ppm.monitor_block_size(width, height) + return math.floor((width - 15) / 21) + 1, math.floor((height - 10) / 14) + 1 +end + return ppm diff --git a/scada-common/util.lua b/scada-common/util.lua index 28974de..f407e36 100644 --- a/scada-common/util.lua +++ b/scada-common/util.lua @@ -22,7 +22,7 @@ local t_pack = table.pack local util = {} -- scada-common version -util.version = "1.1.12" +util.version = "1.1.13" util.TICK_TIME_S = 0.05 util.TICK_TIME_MS = 50 @@ -348,6 +348,16 @@ function util.table_contains(t, element) return false end +-- count the length of a table, even if the values are not sequential or contain named keys +---@nodiscard +---@param t table +---@return integer length +function util.table_len(t) + local n = 0 + for _, _ in pairs(t) do n = n + 1 end + return n +end + --#endregion --#region MEKANISM POWER diff --git a/startup.lua b/startup.lua index 7dd95f6..525402b 100644 --- a/startup.lua +++ b/startup.lua @@ -2,7 +2,7 @@ local util = require("scada-common.util") local println = util.println -local BOOTLOADER_VERSION = "0.5" +local BOOTLOADER_VERSION = "0.6" println("SCADA BOOTLOADER V" .. BOOTLOADER_VERSION) println("BOOT> SCANNING FOR APPLICATIONS...") diff --git a/supervisor/configure.lua b/supervisor/configure.lua index 745c082..b8c4e00 100644 --- a/supervisor/configure.lua +++ b/supervisor/configure.lua @@ -331,7 +331,7 @@ local function config_view(display) TextBox{parent=div,x=1,y=1,width=33,height=1,text="Unit "..i.." will be connected to..."} TextBox{parent=div,x=6,y=2,width=3,height=1,text="..."} - local tank_opt = Radio2D{parent=div,x=10,y=2,rows=1,columns=2,default=val,options={"its own Unit Tank","a Facility Tank"},radio_colors=cpair(colors.lightGray,colors.black),select_color=colors.yellow,disable_color=colors.gray,disable_fg_bg=g_lg_fg_bg} + local tank_opt = Radio2D{parent=div,x=9,y=2,rows=1,columns=2,default=val,options={"its own Unit Tank","a Facility Tank"},radio_colors=cpair(colors.lightGray,colors.black),select_color=colors.yellow,disable_color=colors.gray,disable_fg_bg=g_lg_fg_bg} local no_tank = TextBox{parent=div,x=9,y=2,width=34,height=1,text="no tank (as you set two steps ago)",fg_bg=cpair(colors.gray,colors.lightGray),hidden=true} tool_ctl.tank_elems[i] = { div = div, tank_opt = tank_opt, no_tank = no_tank } @@ -968,7 +968,7 @@ local function config_view(display) if f[1] == "AuthKey" then val = string.rep("*", string.len(val)) elseif f[1] == "LogMode" then val = util.trinary(raw == log.MODE.APPEND, "append", "replace") - elseif f[1] == "CoolingConfig" and cfg.CoolingConfig then + elseif f[1] == "CoolingConfig" and type(cfg.CoolingConfig) == "table" then val = "" for idx = 1, #cfg.CoolingConfig do @@ -982,7 +982,7 @@ local function config_view(display) if val == "" then val = "no facility tanks" end elseif f[1] == "FacilityTankMode" and raw == 0 then val = "0 (n/a, unit mode)" - elseif f[1] == "FacilityTankDefs" and cfg.FacilityTankDefs then + elseif f[1] == "FacilityTankDefs" and type(cfg.FacilityTankDefs) == "table" then val = "" for idx = 1, #cfg.FacilityTankDefs do diff --git a/supervisor/startup.lua b/supervisor/startup.lua index e4ab974..a983ee4 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.2.5" +local SUPERVISOR_VERSION = "v1.2.6" local println = util.println local println_ts = util.println_ts @@ -34,7 +34,7 @@ if not supervisor.load_config() then -- try to reconfigure (user action) local success, error = configure.configure(true) if success then - assert(supervisor.load_config(), "failed to load valid supervisor configuration") + assert(supervisor.load_config(), "failed to load valid configuration") else assert(success, "supervisor configuration error: " .. error) end diff --git a/supervisor/supervisor.lua b/supervisor/supervisor.lua index 755893f..7b2b87e 100644 --- a/supervisor/supervisor.lua +++ b/supervisor/supervisor.lua @@ -69,6 +69,7 @@ function supervisor.load_config() cfv.assert_min(config.PKT_Timeout, 2) cfv.assert_type_num(config.TrustedRange) + cfv.assert_min(config.TrustedRange, 0) if type(config.AuthKey) == "string" then local len = string.len(config.AuthKey) @@ -76,6 +77,7 @@ function supervisor.load_config() end cfv.assert_type_int(config.LogMode) + cfv.assert_range(config.LogMode, 0, 1) cfv.assert_type_str(config.LogPath) cfv.assert_type_bool(config.LogDebug)