diff --git a/ccmsi.lua b/ccmsi.lua index 36a74c5..4c94771 100644 --- a/ccmsi.lua +++ b/ccmsi.lua @@ -1,8 +1,6 @@ --- --- ComputerCraft Mekanism SCADA System Installer Utility --- - --[[ +CC-MEK-SCADA Installer Utility + Copyright (c) 2023 Mikayla Fischler Permission is hereby granted, free of charge, to any person obtaining a copy of this software and @@ -20,7 +18,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. local function println(message) print(tostring(message)) end local function print(message) term.write(tostring(message)) end -local CCMSI_VERSION = "v1.7d" +local CCMSI_VERSION = "v1.9" local install_dir = "/.install-cache" local manifest_path = "https://mikaylafischler.github.io/cc-mek-scada/manifests/" @@ -63,16 +61,15 @@ end local function pkg_message(message, package) white();print(message .. " ");blue();println(package);white() end -- indicate actions to be taken based on package differences for installs/updates -local function show_pkg_change(name, v_local, v_remote) - if v_local ~= nil then - if v_local ~= v_remote then - print("[" .. name .. "] updating ");blue();print(v_local);white();print(" \xbb ");blue();println(v_remote);white() +local function show_pkg_change(name, v) + if v.v_local ~= nil then + if v.v_local ~= v.v_remote then + print("[" .. name .. "] updating ");blue();print(v.v_local);white();print(" \xbb ");blue();println(v.v_remote);white() elseif mode == "install" then - pkg_message("[" .. name .. "] reinstalling", v_local) + pkg_message("[" .. name .. "] reinstalling", v.v_local) end - else - pkg_message("[" .. name .. "] new install of", v_remote) - end + else pkg_message("[" .. name .. "] new install of", v.v_remote) end + return v.v_local ~= v.v_remote end -- read the local manifest file @@ -91,7 +88,7 @@ end local function get_remote_manifest() local response, error = http.get(install_manifest) if response == nil then - orange();println("failed to get installation manifest from GitHub, cannot update or install") + orange();println("Failed to get installation manifest from GitHub, cannot update or install.") red();println("HTTP error: " .. error);white() return false, {} end @@ -216,8 +213,8 @@ if #opts == 0 or opts[1] == "help" then println(" supervisor - supervisor server application") println(" coordinator - coordinator application") println(" pocket - pocket application") - white();println("");yellow() - println(" second parameter when used with check") + println(" installer - ccmsi installer (update only)") + white();println("") lgray();println(" main (default) | latest | devel");white() return else @@ -227,10 +224,13 @@ else return end - app = get_opt(opts[2], { "reactor-plc", "rtu", "supervisor", "coordinator", "pocket" }) + app = get_opt(opts[2], { "reactor-plc", "rtu", "supervisor", "coordinator", "pocket", "installer" }) if app == nil and mode ~= "check" then red();println("Unrecognized application.");white() return + elseif app == "installer" and mode ~= "update" then + red();println("Installer app only supports 'update' option.");white() + return end -- determine target @@ -276,7 +276,12 @@ if mode == "check" then print(value);white();println(")") end end + + if manifest.versions.installer ~= local_manifest.versions.installer then + yellow();println("\nA newer version of the installer is available, it is recommended to update (use 'ccmsi update installer').");white() + end elseif mode == "install" or mode == "update" then + local update_installer = app == "installer" local ok, manifest = get_remote_manifest() if not ok then return end @@ -284,38 +289,60 @@ elseif mode == "install" or mode == "update" then app = { v_local = nil, v_remote = nil, changed = false }, boot = { v_local = nil, v_remote = nil, changed = false }, comms = { v_local = nil, v_remote = nil, changed = false }, + common = { v_local = nil, v_remote = nil, changed = false }, graphics = { v_local = nil, v_remote = nil, changed = false }, lockbox = { v_local = nil, v_remote = nil, changed = false } } -- try to find local versions - local local_ok, local_manifest = read_local_manifest() + local local_ok, lmnf = read_local_manifest() if not local_ok then if mode == "update" then - red();println("failed to load local installation information, cannot update");white() + red();println("Failed to load local installation information, cannot update.");white() return end - else - ver.boot.v_local = local_manifest.versions.bootloader - ver.app.v_local = local_manifest.versions[app] - ver.comms.v_local = local_manifest.versions.comms - ver.graphics.v_local = local_manifest.versions.graphics - ver.lockbox.v_local = local_manifest.versions.lockbox + elseif not update_installer then + ver.boot.v_local = lmnf.versions.bootloader + ver.app.v_local = lmnf.versions[app] + ver.comms.v_local = lmnf.versions.comms + ver.common.v_local = lmnf.versions.common + ver.graphics.v_local = lmnf.versions.graphics + ver.lockbox.v_local = lmnf.versions.lockbox - if local_manifest.versions[app] == nil then - red();println("another application is already installed, please purge it before installing a new application");white() + if lmnf.versions[app] == nil then + red();println("Another application is already installed, please purge it before installing a new application.");white() return end + end - local_manifest.versions.installer = CCMSI_VERSION - if manifest.versions.installer ~= CCMSI_VERSION then - yellow();println("a newer version of the installer is available, it is recommended to download it");white() + lmnf.versions.installer = CCMSI_VERSION + if manifest.versions.installer ~= CCMSI_VERSION then + if not update_installer then yellow();println("A newer version of the installer is available, it is recommended to update to it.");white() end + if update_installer or ask_y_n("Would you like to update now") then + lgray();println("GET ccmsi.lua") + local dl, err = http.get(repo_path .. "ccmsi.lua") + + if dl == nil then + red();println("HTTP Error " .. err) + println("Installer download failed.");white() + else + local handle = fs.open(debug.getinfo(1, "S").source:sub(2), "w") -- this file, regardless of name or location + handle.write(dl.readAll()) + handle.close() + green();println("Installer updated successfully.");white() + end + + return end + elseif update_installer then + green();println("Installer already up-to-date.");white() + return end ver.boot.v_remote = manifest.versions.bootloader ver.app.v_remote = manifest.versions[app] ver.comms.v_remote = manifest.versions.comms + ver.common.v_remote = manifest.versions.common ver.graphics.v_remote = manifest.versions.graphics ver.lockbox.v_remote = manifest.versions.lockbox @@ -327,28 +354,15 @@ elseif mode == "install" or mode == "update" then end white() - -- display bootloader version change information - show_pkg_change("bootldr", ver.boot.v_local, ver.boot.v_remote) - ver.boot.changed = ver.boot.v_local ~= ver.boot.v_remote - - -- display app version change information - show_pkg_change(app, ver.app.v_local, ver.app.v_remote) - ver.app.changed = ver.app.v_local ~= ver.app.v_remote - - -- display comms version change information - show_pkg_change("comms", ver.comms.v_local, ver.comms.v_remote) - ver.comms.changed = ver.comms.v_local ~= ver.comms.v_remote + ver.boot.changed = show_pkg_change("bootldr", ver.boot) + ver.common.changed = show_pkg_change("common", ver.common) + ver.comms.changed = show_pkg_change("comms", ver.comms) if ver.comms.changed and ver.comms.v_local ~= nil then print("[comms] ");yellow();println("other devices on the network will require an update");white() end - - -- display graphics version change information - show_pkg_change("graphics", ver.graphics.v_local, ver.graphics.v_remote) - ver.graphics.changed = ver.graphics.v_local ~= ver.graphics.v_remote - - -- display lockbox version change information - show_pkg_change("lockbox", ver.lockbox.v_local, ver.lockbox.v_remote) - ver.lockbox.changed = ver.lockbox.v_local ~= ver.lockbox.v_remote + ver.app.changed = show_pkg_change(app, ver.app) + ver.graphics.changed = show_pkg_change("graphics", ver.graphics) + ver.lockbox.changed = show_pkg_change("lockbox", ver.lockbox) -- ask for confirmation if not ask_y_n("Continue", false) then return end @@ -379,7 +393,7 @@ elseif mode == "install" or mode == "update" then yellow();println("WARNING: Insufficient space available for a full download!");white() println("Files can be downloaded one by one, so if you are replacing a current install this will not be a problem unless installation fails.") if mode == "update" then println("If installation still fails, delete this device's log file or uninstall the app (not purge) and try again.") end - if not ask_y_n("Do you wish to continue?", false) then + if not ask_y_n("Do you wish to continue", false) then println("Operation cancelled.") return end @@ -392,16 +406,13 @@ elseif mode == "install" or mode == "update" then if dependency == "system" then return not ver.boot.changed elseif dependency == "graphics" then return not ver.graphics.changed elseif dependency == "lockbox" then return not ver.lockbox.changed - elseif dependency == "common" then return not (ver.app.changed or ver.comms.changed) + elseif dependency == "common" then return not (ver.common.changed or ver.comms.changed) elseif dependency == app then return not ver.app.changed else return true end end if not single_file_mode then - if fs.exists(install_dir) then - fs.delete(install_dir) - fs.makeDir(install_dir) - end + if fs.exists(install_dir) then fs.delete(install_dir);fs.makeDir(install_dir) end -- download all dependencies for _, dependency in pairs(dependencies) do @@ -417,7 +428,7 @@ elseif mode == "install" or mode == "update" then local dl, err = http.get(repo_path .. file) if dl == nil then - red();println("GET HTTP Error " .. err) + red();println("HTTP Error " .. err) success = false break else @@ -482,7 +493,7 @@ elseif mode == "install" or mode == "update" then local dl, err = http.get(repo_path .. file) if dl == nil then - red();println("GET HTTP Error " .. err) + red();println("HTTP Error " .. err) success = false break else @@ -552,7 +563,7 @@ elseif mode == "remove" or mode == "purge" then end) if not log_deleted then - red();println("failed to delete log file") + red();println("Failed to delete log file.") white();println("press any key to continue...") any_key();lgray() end diff --git a/coordinator/config.lua b/coordinator/config.lua index 7ea6ea2..bdf01e2 100644 --- a/coordinator/config.lua +++ b/coordinator/config.lua @@ -26,6 +26,9 @@ 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 diff --git a/coordinator/coordinator.lua b/coordinator/coordinator.lua index c12f9fb..93f39b1 100644 --- a/coordinator/coordinator.lua +++ b/coordinator/coordinator.lua @@ -49,12 +49,15 @@ end -- 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) +function coordinator.configure_monitors(num_units, disable_flow_view) ---@class monitors_struct local monitors = { primary = nil, primary_name = "", + flow = nil, + flow_name = "", unit_displays = {}, unit_name_map = {} } @@ -69,8 +72,8 @@ function coordinator.configure_monitors(num_units) table.insert(available, iface) end - -- we need a certain number of monitors (1 per unit + 1 primary display) - local num_displays_needed = num_units + 1 + -- 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) @@ -83,10 +86,12 @@ function coordinator.configure_monitors(num_units) 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") -- 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 @@ -106,7 +111,6 @@ function coordinator.configure_monitors(num_units) end while iface_primary_display == nil and #available > 0 do - -- lets get a monitor iface_primary_display = ask_monitor(available) end @@ -118,6 +122,33 @@ function coordinator.configure_monitors(num_units) 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 -- ------------------- @@ -130,7 +161,6 @@ function coordinator.configure_monitors(num_units) local display = nil while display == nil and #available > 0 do - -- lets get a monitor println("please select monitor for unit #" .. i) display = ask_monitor(available) end @@ -152,7 +182,6 @@ function coordinator.configure_monitors(num_units) end while display == nil and #available > 0 do - -- lets get a monitor display = ask_monitor(available) end @@ -217,12 +246,13 @@ 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, crd_channel, svr_channel, pkt_channel, range, sv_watchdog) +function coordinator.comms(version, nic, num_units, crd_channel, svr_channel, pkt_channel, range, sv_watchdog) local self = { sv_linked = false, sv_addr = comms.BROADCAST, @@ -681,21 +711,16 @@ function coordinator.comms(version, nic, crd_channel, svr_channel, pkt_channel, -- reset to disconnected before validating iocontrol.fp_link_state(types.PANEL_LINK_STATE.DISCONNECTED) - if type(config) == "table" and #config > 1 then + if type(config) == "table" and #config == 2 then -- get configuration ---@class facility_conf local conf = { num_units = config[1], ---@type integer - defs = {} -- boilers and turbines + cooling = config[2] ---@type sv_cooling_conf } - if (#config - 1) == (conf.num_units * 2) then - -- record sequence of pairs of [#boilers, #turbines] per unit - for i = 2, #config do - table.insert(conf.defs, config[i]) - end - + if conf.num_units == num_units then -- init io controller iocontrol.init(conf, public) @@ -707,7 +732,7 @@ function coordinator.comms(version, nic, crd_channel, svr_channel, pkt_channel, iocontrol.fp_link_state(types.PANEL_LINK_STATE.LINKED) else self.sv_config_err = true - log.warning("invalid supervisor configuration definitions received, establish failed") + log.warning("supervisor config's number of units don't match coordinator's config, establish failed") end else log.debug("invalid supervisor configuration table received, establish failed") diff --git a/coordinator/iocontrol.lua b/coordinator/iocontrol.lua index ce6d667..5ccffae 100644 --- a/coordinator/iocontrol.lua +++ b/coordinator/iocontrol.lua @@ -38,9 +38,7 @@ local function __generic_ack(success) end ---@param comms_v string comms version function iocontrol.init_fp(firmware_v, comms_v) ---@class ioctl_front_panel - io.fp = { - ps = psil.create() - } + io.fp = { ps = psil.create() } io.fp.ps.publish("version", firmware_v) io.fp.ps.publish("comms_version", comms_v) @@ -50,9 +48,12 @@ end ---@param conf facility_conf configuration ---@param comms coord_comms comms reference function iocontrol.init(conf, comms) + -- facility data structure ---@class ioctl_facility io.facility = { - num_units = conf.num_units, ---@type integer + num_units = conf.num_units, + tank_mode = conf.cooling.fac_tank_mode, + tank_defs = conf.cooling.fac_tank_defs, all_sys_ok = false, rtu_count = 0, @@ -83,6 +84,8 @@ function iocontrol.init(conf, comms) scram_ack = __generic_ack, ack_alarms_ack = __generic_ack, + alarm_tones = { false, false, false, false, false, false, false, false }, + ps = psil.create(), induction_ps_tbl = {}, @@ -104,6 +107,101 @@ function iocontrol.init(conf, comms) table.insert(io.facility.sps_ps_tbl, psil.create()) table.insert(io.facility.sps_data_tbl, {}) + -- determine tank information + if io.facility.tank_mode == 0 then + io.facility.tank_defs = {} + -- on facility tank mode 0, setup tank defs to match unit TANK option + for i = 1, conf.num_units do + io.facility.tank_defs[i] = util.trinary(conf.cooling.r_cool[i].TANK, 1, 0) + end + + io.facility.tank_list = { table.unpack(io.facility.tank_defs) } + else + -- decode the layout of tanks from the connections definitions + local tank_mode = io.facility.tank_mode + local tank_defs = io.facility.tank_defs + local tank_list = { table.unpack(tank_defs) } + + local function calc_fdef(start_idx, end_idx) + local first = 4 + for i = start_idx, end_idx do + if io.facility.tank_defs[i] == 2 then + if i < first then first = i end + end + end + return first + end + + if tank_mode == 1 then + -- (1) 1 total facility tank (A A A A) + local first_fdef = calc_fdef(1, #tank_defs) + for i = 1, #tank_defs do + if i > first_fdef and tank_defs[i] == 2 then + tank_list[i] = 0 + end + end + elseif tank_mode == 2 then + -- (2) 2 total facility tanks (A A A B) + local first_fdef = calc_fdef(1, math.min(3, #tank_defs)) + for i = 1, #tank_defs do + if (i ~= 4) and (i > first_fdef) and (tank_defs[i] == 2) then + tank_list[i] = 0 + end + end + elseif tank_mode == 3 then + -- (3) 2 total facility tanks (A A B B) + for _, a in pairs({ 1, 3 }) do + local b = a + 1 + if (tank_defs[a] == 2) and (tank_defs[b] == 2) then + tank_list[b] = 0 + end + end + elseif tank_mode == 4 then + -- (4) 2 total facility tanks (A B B B) + local first_fdef = calc_fdef(2, #tank_defs) + for i = 1, #tank_defs do + if (i ~= 1) and (i > first_fdef) and (tank_defs[i] == 2) then + tank_list[i] = 0 + end + end + elseif tank_mode == 5 then + -- (5) 3 total facility tanks (A A B C) + local first_fdef = calc_fdef(1, math.min(2, #tank_defs)) + for i = 1, #tank_defs do + if (not (i == 3 or i == 4)) and (i > first_fdef) and (tank_defs[i] == 2) then + tank_list[i] = 0 + end + end + elseif tank_mode == 6 then + -- (6) 3 total facility tanks (A B B C) + local first_fdef = calc_fdef(2, math.min(3, #tank_defs)) + for i = 1, #tank_defs do + if (not (i == 1 or i == 4)) and (i > first_fdef) and (tank_defs[i] == 2) then + tank_list[i] = 0 + end + end + elseif tank_mode == 7 then + -- (7) 3 total facility tanks (A B C C) + local first_fdef = calc_fdef(3, #tank_defs) + for i = 1, #tank_defs do + if (not (i == 1 or i == 2)) and (i > first_fdef) and (tank_defs[i] == 2) then + tank_list[i] = 0 + end + end + end + + io.facility.tank_list = tank_list + end + + -- create facility tank tables + for i = 1, #io.facility.tank_list do + if io.facility.tank_list[i] == 2 then + table.insert(io.facility.tank_ps_tbl, psil.create()) + table.insert(io.facility.tank_data_tbl, {}) + end + end + + -- create unit data structures io.units = {} for i = 1, conf.num_units do local function ack(alarm) process.ack_alarm(i, alarm) end @@ -116,6 +214,7 @@ function iocontrol.init(conf, comms) num_boilers = 0, num_turbines = 0, num_snas = 0, + has_tank = conf.cooling.r_cool[i].TANK, control_state = false, burn_rate_cmd = 0.0, @@ -190,18 +289,27 @@ function iocontrol.init(conf, comms) tank_data_tbl = {} } + -- on other facility modes, overwrite unit TANK option with facility tank defs + if io.facility.tank_mode ~= 0 then + entry.has_tank = conf.cooling.fac_tank_defs[i] > 0 + end + -- create boiler tables - for _ = 1, conf.defs[(i * 2) - 1] do - local data = {} ---@type boilerv_session_db + for _ = 1, conf.cooling.r_cool[i].BOILERS do table.insert(entry.boiler_ps_tbl, psil.create()) - table.insert(entry.boiler_data_tbl, data) + table.insert(entry.boiler_data_tbl, {}) end -- create turbine tables - for _ = 1, conf.defs[i * 2] do - local data = {} ---@type turbinev_session_db + for _ = 1, conf.cooling.r_cool[i].TURBINES do table.insert(entry.turbine_ps_tbl, psil.create()) - table.insert(entry.turbine_data_tbl, data) + table.insert(entry.turbine_data_tbl, {}) + end + + -- create tank tables + if io.facility.tank_defs[i] == 1 then + table.insert(entry.tank_ps_tbl, psil.create()) + table.insert(entry.tank_data_tbl, {}) end entry.num_boilers = #entry.boiler_data_tbl @@ -232,11 +340,21 @@ function iocontrol.fp_has_speaker(has_speaker) io.fp.ps.publish("has_speaker", h function iocontrol.fp_link_state(state) io.fp.ps.publish("link_state", state) end -- report monitor connection state ----@param id integer unit ID or 0 for main +---@param id string|integer unit ID for unit monitor, "main" for main monitor, or "flow" for flow monitor function iocontrol.fp_monitor_state(id, connected) - local name = "main_monitor" - if id > 0 then name = "unit_monitor_" .. id end - io.fp.ps.publish(name, connected) + local name = nil + + if id == "main" then + name = "main_monitor" + elseif id == "flow" then + name = "flow_monitor" + elseif type(id) == "number" then + name = "unit_monitor_" .. id + end + + if name ~= nil then + io.fp.ps.publish(name, connected) + end end -- report PKT firmware version and PKT session connection state @@ -664,6 +782,16 @@ function iocontrol.update_facility_status(status) end fac.ps.publish("rtu_count", fac.rtu_count) + + -- alarm tone commands + + if (type(status[3]) == "table") and (#status[3] == 8) then + fac.alarm_tones = status[3] + sounder.set(fac.alarm_tones) + else + log.debug(log_header .. "alarm tones not a table or length mismatch") + valid = false + end end return valid @@ -684,8 +812,7 @@ function iocontrol.update_unit_statuses(statuses) else local burn_rate_sum = 0.0 local sna_count_sum = 0 - local pu_rate = 0.0 - local po_rate = 0.0 + local pu_rate, po_rate, po_pl_rate, po_am_rate, spent_rate = 0.0, 0.0, 0.0, 0.0, 0.0 -- get all unit statuses for i = 1, #statuses do @@ -696,7 +823,7 @@ function iocontrol.update_unit_statuses(statuses) local burn_rate = 0.0 - if type(status) ~= "table" or #status ~= 5 then + if type(status) ~= "table" or #status ~= 6 then log.debug(log_header .. "invalid status entry in unit statuses (not a table or invalid length)") valid = false else @@ -779,6 +906,8 @@ function iocontrol.update_unit_statuses(statuses) if type(rtu_statuses) == "table" then -- boiler statuses if type(rtu_statuses.boilers) == "table" then + local boil_sum = 0 + for id = 1, #unit.boiler_ps_tbl do if rtu_statuses.boilers[i] == nil then -- disconnected @@ -796,6 +925,8 @@ function iocontrol.update_unit_statuses(statuses) if rtu_faulted then ps.publish("computed_status", 3) -- faulted elseif data.formed then + boil_sum = boil_sum + data.state.boil_rate + if data.state.boil_rate > 0 then ps.publish("computed_status", 5) -- active else @@ -809,6 +940,8 @@ function iocontrol.update_unit_statuses(statuses) valid = false end end + + unit.unit_ps.publish("boiler_boil_sum", boil_sum) else log.debug(log_header .. "boiler list not a table") valid = false @@ -816,6 +949,8 @@ function iocontrol.update_unit_statuses(statuses) -- turbine statuses if type(rtu_statuses.turbines) == "table" then + local flow_sum = 0 + for id = 1, #unit.turbine_ps_tbl do if rtu_statuses.turbines[i] == nil then -- disconnected @@ -833,6 +968,8 @@ function iocontrol.update_unit_statuses(statuses) if rtu_faulted then ps.publish("computed_status", 3) -- faulted elseif data.formed then + flow_sum = flow_sum + data.state.flow_rate + if data.tanks.energy_fill >= 0.99 then ps.publish("computed_status", 6) -- trip elseif data.state.flow_rate < 100 then @@ -848,6 +985,8 @@ function iocontrol.update_unit_statuses(statuses) valid = false end end + + unit.unit_ps.publish("turbine_flow_sum", flow_sum) else log.debug(log_header .. "turbine list not a table") valid = false @@ -877,7 +1016,7 @@ function iocontrol.update_unit_statuses(statuses) elseif data.tanks.fill < 0.20 then ps.publish("computed_status", 5) -- low else - ps.publish("computed_status", 5) -- active + ps.publish("computed_status", 4) -- on-line end else ps.publish("computed_status", 2) -- not formed @@ -896,8 +1035,11 @@ function iocontrol.update_unit_statuses(statuses) if type(rtu_statuses.sna) == "table" then unit.num_snas = rtu_statuses.sna[1] ---@type integer unit.sna_prod_rate = rtu_statuses.sna[2] ---@type number + unit.sna_peak_rate = rtu_statuses.sna[3] ---@type number + unit.unit_ps.publish("sna_count", unit.num_snas) unit.unit_ps.publish("sna_prod_rate", unit.sna_prod_rate) + unit.unit_ps.publish("sna_peak_rate", unit.sna_peak_rate) sna_count_sum = sna_count_sum + unit.num_snas else @@ -1002,10 +1144,63 @@ function iocontrol.update_unit_statuses(statuses) valid = false end + -- valve states + local valve_states = status[6] + + if type(valve_states) == "table" then + if #valve_states == 5 then + unit.unit_ps.publish("V_pu_conn", valve_states[1] > 0) + unit.unit_ps.publish("V_pu_state", valve_states[1] == 2) + unit.unit_ps.publish("V_po_conn", valve_states[2] > 0) + unit.unit_ps.publish("V_po_state", valve_states[2] == 2) + unit.unit_ps.publish("V_pl_conn", valve_states[3] > 0) + unit.unit_ps.publish("V_pl_state", valve_states[3] == 2) + unit.unit_ps.publish("V_am_conn", valve_states[4] > 0) + unit.unit_ps.publish("V_am_state", valve_states[4] == 2) + unit.unit_ps.publish("V_emc_conn", valve_states[5] > 0) + unit.unit_ps.publish("V_emc_state", valve_states[5] == 2) + else + log.debug(log_header .. "valve states length mismatch") + valid = false + end + else + log.debug(log_header .. "valve states not a table") + valid = false + end + -- determine waste production for this unit, add to statistics + local is_pu = unit.waste_product == types.WASTE_PRODUCT.PLUTONIUM - pu_rate = pu_rate + util.trinary(is_pu, burn_rate / 10.0, 0.0) - po_rate = po_rate + util.trinary(not is_pu, math.min(burn_rate / 10.0, unit.sna_prod_rate), 0.0) + local waste_rate = burn_rate / 10.0 + + local u_spent_rate = waste_rate + local u_pu_rate = util.trinary(is_pu, waste_rate, 0.0) + local u_po_rate = util.trinary(not is_pu, math.min(waste_rate, unit.sna_prod_rate), 0.0) + + unit.unit_ps.publish("pu_rate", u_pu_rate) + unit.unit_ps.publish("po_rate", u_po_rate) + + unit.unit_ps.publish("sna_in", util.trinary(is_pu, 0, burn_rate)) + + if unit.waste_product == types.WASTE_PRODUCT.POLONIUM then + unit.unit_ps.publish("po_pl_rate", u_po_rate) + unit.unit_ps.publish("po_am_rate", 0) + po_pl_rate = po_pl_rate + u_po_rate + elseif unit.waste_product == types.WASTE_PRODUCT.ANTI_MATTER then + unit.unit_ps.publish("po_pl_rate", 0) + unit.unit_ps.publish("po_am_rate", u_po_rate) + po_am_rate = po_am_rate + u_po_rate + u_spent_rate = 0 + else + unit.unit_ps.publish("po_pl_rate", 0) + unit.unit_ps.publish("po_am_rate", 0) + end + + unit.unit_ps.publish("ws_rate", u_spent_rate) + + pu_rate = pu_rate + u_pu_rate + po_rate = po_rate + u_po_rate + spent_rate = spent_rate + u_spent_rate end end @@ -1013,9 +1208,9 @@ function iocontrol.update_unit_statuses(statuses) io.facility.ps.publish("sna_count", sna_count_sum) io.facility.ps.publish("pu_rate", pu_rate) io.facility.ps.publish("po_rate", po_rate) - - -- update alarm sounder - sounder.eval(io.units) + io.facility.ps.publish("po_pl_rate", po_pl_rate) + io.facility.ps.publish("po_am_rate", po_am_rate) + io.facility.ps.publish("spent_waste_rate", spent_rate) end return valid diff --git a/coordinator/renderer.lua b/coordinator/renderer.lua index 5a6a605..d64ca98 100644 --- a/coordinator/renderer.lua +++ b/coordinator/renderer.lua @@ -10,6 +10,7 @@ local iocontrol = require("coordinator.iocontrol") local style = require("coordinator.ui.style") local pgi = require("coordinator.ui.pgi") +local flow_view = require("coordinator.ui.layout.flow_view") local panel_view = require("coordinator.ui.layout.front_panel") local main_view = require("coordinator.ui.layout.main_view") local unit_view = require("coordinator.ui.layout.unit_view") @@ -29,8 +30,10 @@ local engine = { ui = { front_panel = nil, ---@type graphics_element|nil main_display = nil, ---@type graphics_element|nil + flow_display = nil, ---@type graphics_element|nil unit_displays = {} - } + }, + disable_flow_view = false } -- init a display to the "default", but set text scale to 0.5 @@ -48,20 +51,28 @@ local function _init_display(monitor) end end +-- disable the flow view +---@param disable boolean +function renderer.legacy_disable_flow_view(disable) + engine.disable_flow_view = disable +end + -- link to the monitor peripherals ---@param monitors monitors_struct function renderer.set_displays(monitors) engine.monitors = monitors -- report to front panel as connected - iocontrol.fp_monitor_state(0, true) + iocontrol.fp_monitor_state("main", engine.monitors.primary ~= nil) + iocontrol.fp_monitor_state("flow", engine.monitors.flow ~= nil) for i = 1, #engine.monitors.unit_displays do iocontrol.fp_monitor_state(i, true) end end -- init all displays in use by the renderer function renderer.init_displays() - -- init primary monitor + -- init primary and flow monitors _init_display(engine.monitors.primary) + if not engine.disable_flow_view then _init_display(engine.monitors.flow) end -- init unit displays for _, monitor in ipairs(engine.monitors.unit_displays) do @@ -88,6 +99,14 @@ function renderer.validate_main_display_width() 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 @@ -169,6 +188,12 @@ function renderer.start_ui() main_view(engine.ui.main_display) end + -- show flow view on flow monitor + if engine.monitors.flow ~= nil then + engine.ui.flow_display = DisplayBox{window=engine.monitors.flow,fg_bg=style.root} + flow_view(engine.ui.flow_display) + end + -- show unit views on unit displays for idx, display in pairs(engine.monitors.unit_displays) do engine.ui.unit_displays[idx] = DisplayBox{window=display,fg_bg=style.root} @@ -192,6 +217,7 @@ function renderer.close_ui() -- delete element trees if engine.ui.main_display ~= nil then engine.ui.main_display.delete() end + if engine.ui.flow_display ~= nil then engine.ui.flow_display.delete() end for _, display in pairs(engine.ui.unit_displays) do display.delete() end -- report ui as not ready @@ -199,6 +225,7 @@ function renderer.close_ui() -- clear root UI elements engine.ui.main_display = nil + engine.ui.flow_display = nil engine.ui.unit_displays = {} -- clear unit monitors @@ -236,7 +263,18 @@ function renderer.handle_disconnect(device) engine.monitors.primary = nil engine.ui.main_display = nil - iocontrol.fp_monitor_state(0, false) + iocontrol.fp_monitor_state("main", false) + elseif engine.monitors.flow == device then + if engine.ui.flow_display ~= nil then + -- delete element tree and clear root UI elements + engine.ui.flow_display.delete() + end + + is_used = true + engine.monitors.flow = nil + engine.ui.flow_display = nil + + iocontrol.fp_monitor_state("flow", false) else for idx, monitor in pairs(engine.monitors.unit_displays) do if monitor == device then @@ -284,7 +322,18 @@ function renderer.handle_reconnect(name, device) engine.dmesg_window.redraw() end - iocontrol.fp_monitor_state(0, true) + iocontrol.fp_monitor_state("main", true) + elseif engine.monitors.flow_name == name then + is_used = true + _init_display(device) + engine.monitors.flow = device + + if engine.ui_ready and (engine.ui.flow_display == nil) then + engine.ui.flow_display = DisplayBox{window=device,fg_bg=style.root} + flow_view(engine.ui.flow_display) + end + + iocontrol.fp_monitor_state("flow", true) else for idx, monitor in ipairs(engine.monitors.unit_name_map) do if monitor == name then @@ -317,6 +366,8 @@ function renderer.handle_mouse(event) elseif engine.ui_ready then if event.monitor == engine.monitors.primary_name then engine.ui.main_display.handle_mouse(event) + elseif event.monitor == engine.monitors.flow_name then + engine.ui.flow_display.handle_mouse(event) else for id, monitor in ipairs(engine.monitors.unit_name_map) do if event.monitor == monitor then diff --git a/coordinator/sounder.lua b/coordinator/sounder.lua index 86fb9b4..e3b2051 100644 --- a/coordinator/sounder.lua +++ b/coordinator/sounder.lua @@ -2,269 +2,25 @@ -- Alarm Sounder -- +local audio = require("scada-common.audio") local log = require("scada-common.log") -local types = require("scada-common.types") -local util = require("scada-common.util") - -local ALARM = types.ALARM -local ALARM_STATE = types.ALARM_STATE ---@class sounder local sounder = {} --- note: max samples = 0x20000 (128 * 1024 samples) - -local _2_PI = 2 * math.pi -- 2 whole pies, hope you're hungry -local _DRATE = 48000 -- 48kHz audio -local _MAX_VAL = 127 / 2 -- max signed integer in this 8-bit audio -local _05s_SAMPLES = 24000 -- half a second worth of samples - -local test_alarms = { false, false, false, false, false, false, false, false, false, false, false, false } - local alarm_ctl = { speaker = nil, volume = 0.5, - playing = false, - num_active = 0, - next_block = 1, - -- split audio up into 0.5s samples so specific components can be ended quicker - quad_buffer = { {}, {}, {}, {} } + stream = audio.new_stream() } --- sounds modeled after https://www.e2s.com/references-and-guidelines/listen-and-download-alarm-tones - -local T_340Hz_Int_2Hz = 1 -local T_544Hz_440Hz_Alt = 2 -local T_660Hz_Int_125ms = 3 -local T_745Hz_Int_1Hz = 4 -local T_800Hz_Int = 5 -local T_800Hz_1000Hz_Alt = 6 -local T_1000Hz_Int = 7 -local T_1800Hz_Int_4Hz = 8 - -local TONES = { - { active = false, component = { {}, {}, {}, {} } }, -- 340Hz @ 2Hz Intermittent - { active = false, component = { {}, {}, {}, {} } }, -- 544Hz 100mS / 440Hz 400mS Alternating - { active = false, component = { {}, {}, {}, {} } }, -- 660Hz @ 125ms On 125ms Off - { active = false, component = { {}, {}, {}, {} } }, -- 745Hz @ 1Hz Intermittent - { active = false, component = { {}, {}, {}, {} } }, -- 800Hz @ 0.25s On 1.75s Off - { active = false, component = { {}, {}, {}, {} } }, -- 800/1000Hz @ 0.25s Alternating - { active = false, component = { {}, {}, {}, {} } }, -- 1KHz 1s on, 1s off Intermittent - { active = false, component = { {}, {}, {}, {} } } -- 1.8KHz @ 4Hz Intermittent -} - --- calculate how many samples are in the given number of milliseconds ----@nodiscard ----@param ms integer milliseconds ----@return integer samples -local function ms_to_samples(ms) return math.floor(ms * 48) end - ---#region Tone Generation (the Maths) - --- 340Hz @ 2Hz Intermittent -local function gen_tone_1() - local t, dt = 0, _2_PI * 340 / _DRATE - - for i = 1, _05s_SAMPLES do - local val = math.floor(math.sin(t) * _MAX_VAL) - TONES[1].component[1][i] = val - TONES[1].component[3][i] = val - TONES[1].component[2][i] = 0 - TONES[1].component[4][i] = 0 - t = (t + dt) % _2_PI - end -end - --- 544Hz 100mS / 440Hz 400mS Alternating -local function gen_tone_2() - local t1, dt1 = 0, _2_PI * 544 / _DRATE - local t2, dt2 = 0, _2_PI * 440 / _DRATE - local alternate_at = ms_to_samples(100) - - for i = 1, _05s_SAMPLES do - local value - - if i <= alternate_at then - value = math.floor(math.sin(t1) * _MAX_VAL) - t1 = (t1 + dt1) % _2_PI - else - value = math.floor(math.sin(t2) * _MAX_VAL) - t2 = (t2 + dt2) % _2_PI - end - - TONES[2].component[1][i] = value - TONES[2].component[2][i] = value - TONES[2].component[3][i] = value - TONES[2].component[4][i] = value - end -end - --- 660Hz @ 125ms On 125ms Off -local function gen_tone_3() - local elapsed_samples = 0 - local alternate_after = ms_to_samples(125) - local alternate_at = alternate_after - local mode = true - - local t, dt = 0, _2_PI * 660 / _DRATE - - for set = 1, 4 do - for i = 1, _05s_SAMPLES do - if mode then - local val = math.floor(math.sin(t) * _MAX_VAL) - TONES[3].component[set][i] = val - t = (t + dt) % _2_PI - else - t = 0 - TONES[3].component[set][i] = 0 - end - - if elapsed_samples == alternate_at then - mode = not mode - alternate_at = elapsed_samples + alternate_after - end - - elapsed_samples = elapsed_samples + 1 - end - end -end - --- 745Hz @ 1Hz Intermittent -local function gen_tone_4() - local t, dt = 0, _2_PI * 745 / _DRATE - - for i = 1, _05s_SAMPLES do - local val = math.floor(math.sin(t) * _MAX_VAL) - TONES[4].component[1][i] = val - TONES[4].component[3][i] = val - TONES[4].component[2][i] = 0 - TONES[4].component[4][i] = 0 - t = (t + dt) % _2_PI - end -end - --- 800Hz @ 0.25s On 1.75s Off -local function gen_tone_5() - local t, dt = 0, _2_PI * 800 / _DRATE - local stop_at = ms_to_samples(250) - - for i = 1, _05s_SAMPLES do - local val = math.floor(math.sin(t) * _MAX_VAL) - - if i > stop_at then - TONES[5].component[1][i] = val - else - TONES[5].component[1][i] = 0 - end - - TONES[5].component[2][i] = 0 - TONES[5].component[3][i] = 0 - TONES[5].component[4][i] = 0 - - t = (t + dt) % _2_PI - end -end - --- 1000/800Hz @ 0.25s Alternating -local function gen_tone_6() - local t1, dt1 = 0, _2_PI * 1000 / _DRATE - local t2, dt2 = 0, _2_PI * 800 / _DRATE - - local alternate_at = ms_to_samples(250) - - for i = 1, _05s_SAMPLES do - local val - if i <= alternate_at then - val = math.floor(math.sin(t1) * _MAX_VAL) - t1 = (t1 + dt1) % _2_PI - else - val = math.floor(math.sin(t2) * _MAX_VAL) - t2 = (t2 + dt2) % _2_PI - end - - TONES[6].component[1][i] = val - TONES[6].component[2][i] = val - TONES[6].component[3][i] = val - TONES[6].component[4][i] = val - end -end - --- 1KHz 1s on, 1s off Intermittent -local function gen_tone_7() - local t, dt = 0, _2_PI * 1000 / _DRATE - - for i = 1, _05s_SAMPLES do - local val = math.floor(math.sin(t) * _MAX_VAL) - TONES[7].component[1][i] = val - TONES[7].component[2][i] = val - TONES[7].component[3][i] = 0 - TONES[7].component[4][i] = 0 - t = (t + dt) % _2_PI - end -end - --- 1800Hz @ 4Hz Intermittent -local function gen_tone_8() - local t, dt = 0, _2_PI * 1800 / _DRATE - - local off_at = ms_to_samples(250) - - for i = 1, _05s_SAMPLES do - local val = 0 - - if i <= off_at then - val = math.floor(math.sin(t) * _MAX_VAL) - t = (t + dt) % _2_PI - end - - TONES[8].component[1][i] = val - TONES[8].component[2][i] = val - TONES[8].component[3][i] = val - TONES[8].component[4][i] = val - end -end - ---#endregion - --- hard audio limiter ----@nodiscard ----@param output number output level ----@return number limited -128.0 to 127.0 -local function limit(output) - return math.max(-128, math.min(127, output)) -end - --- zero the alarm audio buffer -local function zero() - for i = 1, 4 do - for s = 1, _05s_SAMPLES do alarm_ctl.quad_buffer[i][s] = 0 end - end -end - --- add an alarm to the output buffer ----@param alarm_idx integer tone ID -local function add(alarm_idx) - alarm_ctl.num_active = alarm_ctl.num_active + 1 - TONES[alarm_idx].active = true - - for i = 1, 4 do - for s = 1, _05s_SAMPLES do - alarm_ctl.quad_buffer[i][s] = limit(alarm_ctl.quad_buffer[i][s] + TONES[alarm_idx].component[i][s]) - end - end -end - -- start audio or continue audio on buffer empty ---@return boolean success successfully added buffer to audio output local function play() if not alarm_ctl.playing then alarm_ctl.playing = true - alarm_ctl.next_block = 1 - return sounder.continue() - else - return true - end + else return true end end -- initialize the annunciator alarm system @@ -273,23 +29,10 @@ end function sounder.init(speaker, volume) alarm_ctl.speaker = speaker alarm_ctl.speaker.stop() - alarm_ctl.volume = volume - alarm_ctl.playing = false - alarm_ctl.num_active = 0 - alarm_ctl.next_block = 1 + alarm_ctl.stream.stop() - zero() - - -- generate tones - gen_tone_1() - gen_tone_2() - gen_tone_3() - gen_tone_4() - gen_tone_5() - gen_tone_6() - gen_tone_7() - gen_tone_8() + audio.generate_tones() end -- reconnect the speaker peripheral @@ -297,173 +40,40 @@ end function sounder.reconnect(speaker) alarm_ctl.speaker = speaker alarm_ctl.playing = false - alarm_ctl.next_block = 1 - alarm_ctl.num_active = 0 - for id = 1, #TONES do TONES[id].active = false end + alarm_ctl.stream.stop() end --- check alarm state to enable/disable alarms ----@param units table|nil unit list or nil to use test mode -function sounder.eval(units) - local changed = false - local any_active = false - local new_states = { false, false, false, false, false, false, false, false } - local alarms = { false, false, false, false, false, false, false, false, false, false, false, false } +-- set alarm tones +---@param states table alarm tone commands from supervisor +function sounder.set(states) + -- set tone states + for id = 1, #states do alarm_ctl.stream.set_active(id, states[id]) end - if units ~= nil then - -- check all alarms for all units - for i = 1, #units do - local unit = units[i] ---@type ioctl_unit - for id = 1, #unit.alarms do - alarms[id] = alarms[id] or (unit.alarms[id] == ALARM_STATE.TRIPPED) - end - end - else - alarms = test_alarms - end - - -- containment breach is worst case CRITICAL alarm, this takes priority - if alarms[ALARM.ContainmentBreach] then - new_states[T_1800Hz_Int_4Hz] = true - else - -- critical damage is highest priority CRITICAL level alarm - if alarms[ALARM.CriticalDamage] then - new_states[T_660Hz_Int_125ms] = true - else - -- EMERGENCY level alarms + URGENT over temp - if alarms[ALARM.ReactorDamage] or alarms[ALARM.ReactorOverTemp] or alarms[ALARM.ReactorWasteLeak] then - new_states[T_544Hz_440Hz_Alt] = true - -- URGENT level turbine trip - elseif alarms[ALARM.TurbineTrip] then - new_states[T_745Hz_Int_1Hz] = true - -- URGENT level reactor lost - elseif alarms[ALARM.ReactorLost] then - new_states[T_340Hz_Int_2Hz] = true - -- TIMELY level alarms - elseif alarms[ALARM.ReactorHighTemp] or alarms[ALARM.ReactorHighWaste] or alarms[ALARM.RCSTransient] then - new_states[T_800Hz_Int] = true - end - end - - -- check RPS transient URGENT level alarm - if alarms[ALARM.RPSTransient] then - new_states[T_1000Hz_Int] = true - -- disable really painful audio combination - new_states[T_340Hz_Int_2Hz] = false - end - end - - -- radiation is a big concern, always play this CRITICAL level alarm if active - if alarms[ALARM.ContainmentRadiation] then - new_states[T_800Hz_1000Hz_Alt] = true - -- we are going to disable the RPS trip alarm audio due to conflict, and if it was enabled - -- then we can re-enable the reactor lost alarm audio since it doesn't painfully combine with this one - if new_states[T_1000Hz_Int] and alarms[ALARM.ReactorLost] then new_states[T_340Hz_Int_2Hz] = true end - -- it sounds *really* bad if this is in conjunction with these other tones, so disable them - new_states[T_745Hz_Int_1Hz] = false - new_states[T_800Hz_Int] = false - new_states[T_1000Hz_Int] = false - end - - -- check if any changed, check if any active, update active flags - for id = 1, #TONES do - if new_states[id] ~= TONES[id].active then - TONES[id].active = new_states[id] - changed = true - end - - if TONES[id].active then any_active = true end - end - - -- zero and re-add tones if changed - if changed then - zero() - - for id = 1, #TONES do - if TONES[id].active then add(id) end - end - end - - if any_active then play() else sounder.stop() end + -- re-compute output if needed, then play audio if available + if alarm_ctl.stream.is_recompute_needed() then alarm_ctl.stream.compute_buffer() end + if alarm_ctl.stream.has_next_block() then play() else sounder.stop() end end -- stop all audio and clear output buffer function sounder.stop() alarm_ctl.playing = false alarm_ctl.speaker.stop() - alarm_ctl.next_block = 1 - alarm_ctl.num_active = 0 - for id = 1, #TONES do TONES[id].active = false end - zero() + alarm_ctl.stream.stop() end -- continue audio on buffer empty ---@return boolean success successfully added buffer to audio output function sounder.continue() + local success = false + if alarm_ctl.playing then - if alarm_ctl.speaker ~= nil and #alarm_ctl.quad_buffer[alarm_ctl.next_block] > 0 then - local success = alarm_ctl.speaker.playAudio(alarm_ctl.quad_buffer[alarm_ctl.next_block], alarm_ctl.volume) - - alarm_ctl.next_block = alarm_ctl.next_block + 1 - if alarm_ctl.next_block > 4 then alarm_ctl.next_block = 1 end - - if not success then - log.debug("SOUNDER: error playing audio") - end - - return success - else - return false - end - else - return false - end -end - ---#region Test Functions - -function sounder.test_1() add(1) play() end -- play tone T_340Hz_Int_2Hz -function sounder.test_2() add(2) play() end -- play tone T_544Hz_440Hz_Alt -function sounder.test_3() add(3) play() end -- play tone T_660Hz_Int_125ms -function sounder.test_4() add(4) play() end -- play tone T_745Hz_Int_1Hz -function sounder.test_5() add(5) play() end -- play tone T_800Hz_Int -function sounder.test_6() add(6) play() end -- play tone T_800Hz_1000Hz_Alt -function sounder.test_7() add(7) play() end -- play tone T_1000Hz_Int -function sounder.test_8() add(8) play() end -- play tone T_1800Hz_Int_4Hz - -function sounder.test_breach(active) test_alarms[ALARM.ContainmentBreach] = active end ---@param active boolean -function sounder.test_rad(active) test_alarms[ALARM.ContainmentRadiation] = active end ---@param active boolean -function sounder.test_lost(active) test_alarms[ALARM.ReactorLost] = active end ---@param active boolean -function sounder.test_crit(active) test_alarms[ALARM.CriticalDamage] = active end ---@param active boolean -function sounder.test_dmg(active) test_alarms[ALARM.ReactorDamage] = active end ---@param active boolean -function sounder.test_overtemp(active) test_alarms[ALARM.ReactorOverTemp] = active end ---@param active boolean -function sounder.test_hightemp(active) test_alarms[ALARM.ReactorHighTemp] = active end ---@param active boolean -function sounder.test_wasteleak(active) test_alarms[ALARM.ReactorWasteLeak] = active end ---@param active boolean -function sounder.test_highwaste(active) test_alarms[ALARM.ReactorHighWaste] = active end ---@param active boolean -function sounder.test_rps(active) test_alarms[ALARM.RPSTransient] = active end ---@param active boolean -function sounder.test_rcs(active) test_alarms[ALARM.RCSTransient] = active end ---@param active boolean -function sounder.test_turbinet(active) test_alarms[ALARM.TurbineTrip] = active end ---@param active boolean - --- power rescaling limiter test -function sounder.test_power_scale() - local start = util.time_ms() - - zero() - - for id = 1, #TONES do - if TONES[id].active then - for i = 1, 4 do - for s = 1, _05s_SAMPLES do - alarm_ctl.quad_buffer[i][s] = limit(alarm_ctl.quad_buffer[i][s] + - (TONES[id].component[i][s] / math.sqrt(alarm_ctl.num_active))) - end - end + if alarm_ctl.speaker ~= nil and alarm_ctl.stream.has_next_block() then + success = alarm_ctl.speaker.playAudio(alarm_ctl.stream.get_next_block(), alarm_ctl.volume) + if not success then log.error("SOUNDER: error playing audio") end end end - log.debug("SOUNDER: power rescale test took " .. (util.time_ms() - start) .. "ms") + return success end ---#endregion - return sounder diff --git a/coordinator/startup.lua b/coordinator/startup.lua index a3cf6fa..97af3eb 100644 --- a/coordinator/startup.lua +++ b/coordinator/startup.lua @@ -22,7 +22,7 @@ local sounder = require("coordinator.sounder") local apisessions = require("coordinator.session.apisessions") -local COORDINATOR_VERSION = "v0.21.2" +local COORDINATOR_VERSION = "v1.0.1" local println = util.println local println_ts = util.println_ts @@ -84,7 +84,7 @@ local function main() iocontrol.init_fp(COORDINATOR_VERSION, comms.version) -- setup monitors - local configured, monitors = coordinator.configure_monitors(config.NUM_UNITS) + 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") @@ -92,6 +92,7 @@ local function main() end -- init renderer + renderer.legacy_disable_flow_view(config.DISABLE_FLOW_VIEW == true) renderer.set_displays(monitors) renderer.init_displays() @@ -99,6 +100,10 @@ local function main() 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") @@ -162,8 +167,8 @@ local function main() -- create network interface then setup comms local nic = network.nic(modem) - local coord_comms = coordinator.comms(COORDINATOR_VERSION, nic, config.CRD_CHANNEL, config.SVR_CHANNEL, - config.PKT_CHANNEL, config.TRUSTED_RANGE, conn_watchdog) + 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) log.debug("startup> comms init") log_comms("comms initialized") diff --git a/coordinator/ui/components/imatrix.lua b/coordinator/ui/components/imatrix.lua index a234cbc..2d553a5 100644 --- a/coordinator/ui/components/imatrix.lua +++ b/coordinator/ui/components/imatrix.lua @@ -83,9 +83,7 @@ local function new_view(root, x, y, data, ps, id) local function calc_saturation(val) if (type(data.build) == "table") and (type(data.build.transfer_cap) == "number") and (data.build.transfer_cap > 0) then return val / data.build.transfer_cap - else - return 0 - end + else return 0 end end charge.register(ps, "energy_fill", charge.update) diff --git a/coordinator/ui/components/process_ctl.lua b/coordinator/ui/components/process_ctl.lua index 0716619..a8a46e7 100644 --- a/coordinator/ui/components/process_ctl.lua +++ b/coordinator/ui/components/process_ctl.lua @@ -28,6 +28,16 @@ local TEXT_ALIGN = core.TEXT_ALIGN local cpair = core.cpair local border = core.border +local bw_fg_bg = style.bw_fg_bg +local lu_cpair = style.lu_colors +local hzd_fg_bg = style.hzd_fg_bg +local dis_colors = style.dis_colors + +local ind_grn = style.ind_grn +local ind_yel = style.ind_yel +local ind_red = style.ind_red +local ind_wht = style.ind_wht + local period = core.flasher.PERIOD -- new process control view @@ -40,11 +50,6 @@ local function new_view(root, x, y) local facility = iocontrol.get_db().facility local units = iocontrol.get_db().units - local bw_fg_bg = cpair(colors.black, colors.white) - local hzd_fg_bg = cpair(colors.white, colors.gray) - local lu_cpair = cpair(colors.gray, colors.gray) - local dis_colors = cpair(colors.white, colors.lightGray) - local main = Div{parent=root,width=128,height=24,x=x,y=y} local scram = HazardButton{parent=main,x=1,y=1,text="FAC SCRAM",accent=colors.yellow,dis_colors=dis_colors,callback=process.fac_scram,fg_bg=hzd_fg_bg} @@ -53,10 +58,10 @@ local function new_view(root, x, y) facility.scram_ack = scram.on_response facility.ack_alarms_ack = ack_a.on_response - local all_ok = IndicatorLight{parent=main,y=5,label="Unit Systems Online",colors=cpair(colors.green,colors.red)} + local all_ok = IndicatorLight{parent=main,y=5,label="Unit Systems Online",colors=ind_grn} local rad_mon = TriIndicatorLight{parent=main,label="Radiation Monitor",c1=colors.gray,c2=colors.yellow,c3=colors.green} - local ind_mat = IndicatorLight{parent=main,label="Induction Matrix",colors=cpair(colors.green,colors.gray)} - local sps = IndicatorLight{parent=main,label="SPS Connected",colors=cpair(colors.green,colors.gray)} + local ind_mat = IndicatorLight{parent=main,label="Induction Matrix",colors=ind_grn} + local sps = IndicatorLight{parent=main,label="SPS Connected",colors=ind_grn} all_ok.register(facility.ps, "all_sys_ok", all_ok.update) rad_mon.register(facility.ps, "rad_computed_status", rad_mon.update) @@ -65,10 +70,10 @@ local function new_view(root, x, y) main.line_break() - local auto_ready = IndicatorLight{parent=main,label="Configured Units Ready",colors=cpair(colors.green,colors.red)} - local auto_act = IndicatorLight{parent=main,label="Process Active",colors=cpair(colors.green,colors.gray)} - local auto_ramp = IndicatorLight{parent=main,label="Process Ramping",colors=cpair(colors.white,colors.gray),flash=true,period=period.BLINK_250_MS} - local auto_sat = IndicatorLight{parent=main,label="Min/Max Burn Rate",colors=cpair(colors.yellow,colors.gray)} + local auto_ready = IndicatorLight{parent=main,label="Configured Units Ready",colors=ind_grn} + local auto_act = IndicatorLight{parent=main,label="Process Active",colors=ind_grn} + local auto_ramp = IndicatorLight{parent=main,label="Process Ramping",colors=ind_wht,flash=true,period=period.BLINK_250_MS} + local auto_sat = IndicatorLight{parent=main,label="Min/Max Burn Rate",colors=ind_yel} auto_ready.register(facility.ps, "auto_ready", auto_ready.update) auto_act.register(facility.ps, "auto_active", auto_act.update) @@ -77,12 +82,12 @@ local function new_view(root, x, y) main.line_break() - local auto_scram = IndicatorLight{parent=main,label="Automatic SCRAM",colors=cpair(colors.red,colors.gray),flash=true,period=period.BLINK_250_MS} - local matrix_dc = IndicatorLight{parent=main,label="Matrix Disconnected",colors=cpair(colors.yellow,colors.gray),flash=true,period=period.BLINK_500_MS} - local matrix_fill = IndicatorLight{parent=main,label="Matrix Charge High",colors=cpair(colors.red,colors.gray),flash=true,period=period.BLINK_500_MS} - local unit_crit = IndicatorLight{parent=main,label="Unit Critical Alarm",colors=cpair(colors.red,colors.gray),flash=true,period=period.BLINK_250_MS} - local fac_rad_h = IndicatorLight{parent=main,label="Facility Radiation High",colors=cpair(colors.red,colors.gray),flash=true,period=period.BLINK_250_MS} - local gen_fault = IndicatorLight{parent=main,label="Gen. Control Fault",colors=cpair(colors.yellow,colors.gray),flash=true,period=period.BLINK_500_MS} + local auto_scram = IndicatorLight{parent=main,label="Automatic SCRAM",colors=ind_red,flash=true,period=period.BLINK_250_MS} + local matrix_dc = IndicatorLight{parent=main,label="Matrix Disconnected",colors=ind_yel,flash=true,period=period.BLINK_500_MS} + local matrix_fill = IndicatorLight{parent=main,label="Matrix Charge High",colors=ind_red,flash=true,period=period.BLINK_500_MS} + local unit_crit = IndicatorLight{parent=main,label="Unit Critical Alarm",colors=ind_red,flash=true,period=period.BLINK_250_MS} + local fac_rad_h = IndicatorLight{parent=main,label="Facility Radiation High",colors=ind_red,flash=true,period=period.BLINK_250_MS} + local gen_fault = IndicatorLight{parent=main,label="Gen. Control Fault",colors=ind_yel,flash=true,period=period.BLINK_500_MS} auto_scram.register(facility.ps, "auto_scram", auto_scram.update) matrix_dc.register(facility.ps, "as_matrix_dc", matrix_dc.update) @@ -198,12 +203,12 @@ local function new_view(root, x, y) local stat_div = Div{parent=proc,width=22,height=24,x=57,y=6} for i = 1, 4 do - local tag_fg_bg = cpair(colors.gray,colors.white) - local ind_fg_bg = cpair(colors.lightGray,colors.white) + local tag_fg_bg = cpair(colors.gray, colors.white) + local ind_fg_bg = cpair(colors.lightGray, colors.white) local ind_off = colors.lightGray if i <= facility.num_units then - tag_fg_bg = cpair(colors.black,colors.cyan) + tag_fg_bg = cpair(colors.black, colors.cyan) ind_fg_bg = bw_fg_bg ind_off = colors.gray end @@ -307,7 +312,7 @@ local function new_view(root, x, y) local unit = units[i] ---@type ioctl_unit TextBox{parent=waste_status,y=i,text="U"..i.." Waste",width=8,height=1} - local a_waste = IndicatorLight{parent=waste_status,x=10,y=i,label="Auto",colors=cpair(colors.white,colors.gray)} + local a_waste = IndicatorLight{parent=waste_status,x=10,y=i,label="Auto",colors=ind_wht} local waste_m = StateIndicator{parent=waste_status,x=17,y=i,states=style.waste.states_abbrv,value=1,min_width=6} a_waste.register(unit.unit_ps, "U_AutoWaste", a_waste.update) @@ -330,7 +335,7 @@ local function new_view(root, x, y) waste_prod.register(facility.ps, "process_waste_product", waste_prod.set_value) pu_fallback.register(facility.ps, "process_pu_fallback", pu_fallback.set_value) - local fb_active = IndicatorLight{parent=rect,x=2,y=9,label="Fallback Active",colors=cpair(colors.white,colors.gray)} + local fb_active = IndicatorLight{parent=rect,x=2,y=9,label="Fallback Active",colors=ind_wht} fb_active.register(facility.ps, "pu_fallback_active", fb_active.update) @@ -341,7 +346,7 @@ local function new_view(root, x, y) local po_rate = DataIndicator{parent=rect,x=2,label="",unit="mB/t",format="%12.2f",value=0,lu_colors=lu_cpair,fg_bg=bw_fg_bg,width=17} TextBox{parent=rect,x=2,y=17,text="Antimatter Rate",height=1,width=17,fg_bg=style.label} - local am_rate = DataIndicator{parent=rect,x=2,label="",unit="\xb5B/t",format="%12.2f",value=0,lu_colors=lu_cpair,fg_bg=bw_fg_bg,width=17} + local am_rate = DataIndicator{parent=rect,x=2,label="",unit="\xb5B/t",format="%12d",value=0,lu_colors=lu_cpair,fg_bg=bw_fg_bg,width=17} pu_rate.register(facility.ps, "pu_rate", pu_rate.update) po_rate.register(facility.ps, "po_rate", po_rate.update) diff --git a/coordinator/ui/components/turbine.lua b/coordinator/ui/components/turbine.lua index 0e4cb21..9fb6f50 100644 --- a/coordinator/ui/components/turbine.lua +++ b/coordinator/ui/components/turbine.lua @@ -31,7 +31,7 @@ local function new_view(root, x, y, ps) local flow_rate = DataIndicator{parent=turbine,x=5,y=4,lu_colors=lu_col,label="",unit="mB/t",format="%10.0f",value=0,commas=true,width=16,fg_bg=text_fg_bg} status.register(ps, "computed_status", status.update) - prod_rate.register(ps, "prod_rate", function (val) prod_rate.update(util.joules_to_fe(val)) end) + prod_rate.register(ps, "steam_input_rate", function (val) prod_rate.update(util.joules_to_fe(val)) end) flow_rate.register(ps, "flow_rate", flow_rate.update) local steam = VerticalBar{parent=turbine,x=2,y=1,fg_bg=cpair(colors.white,colors.gray),height=4,width=1} diff --git a/coordinator/ui/components/unit_detail.lua b/coordinator/ui/components/unit_detail.lua index 7d7bc92..985e577 100644 --- a/coordinator/ui/components/unit_detail.lua +++ b/coordinator/ui/components/unit_detail.lua @@ -31,6 +31,15 @@ local TEXT_ALIGN = core.TEXT_ALIGN local cpair = core.cpair local border = core.border +local bw_fg_bg = style.bw_fg_bg +local lu_cpair = style.lu_colors +local hzd_fg_bg = style.hzd_fg_bg + +local ind_grn = style.ind_grn +local ind_yel = style.ind_yel +local ind_red = style.ind_red +local ind_wht = style.ind_wht + local period = core.flasher.PERIOD -- create a unit view @@ -50,10 +59,6 @@ local function init(parent, id) TextBox{parent=main,text="Reactor Unit #" .. id,alignment=TEXT_ALIGN.CENTER,height=1,fg_bg=style.header} - local bw_fg_bg = cpair(colors.black, colors.white) - local hzd_fg_bg = cpair(colors.white, colors.gray) - local lu_cpair = cpair(colors.gray, colors.gray) - ----------------------------- -- main stats and core map -- ----------------------------- @@ -140,7 +145,7 @@ local function init(parent, id) -- connectivity local plc_online = IndicatorLight{parent=annunciator,label="PLC Online",colors=cpair(colors.green,colors.red)} - local plc_hbeat = IndicatorLight{parent=annunciator,label="PLC Heartbeat",colors=cpair(colors.white,colors.gray)} + local plc_hbeat = IndicatorLight{parent=annunciator,label="PLC Heartbeat",colors=ind_wht} local rad_mon = TriIndicatorLight{parent=annunciator,label="Radiation Monitor",c1=colors.gray,c2=colors.yellow,c3=colors.green} plc_online.register(u_ps, "PLCOnline", plc_online.update) @@ -150,25 +155,25 @@ local function init(parent, id) annunciator.line_break() -- operating state - local r_active = IndicatorLight{parent=annunciator,label="Active",colors=cpair(colors.green,colors.gray)} - local r_auto = IndicatorLight{parent=annunciator,label="Automatic Control",colors=cpair(colors.white,colors.gray)} + local r_active = IndicatorLight{parent=annunciator,label="Active",colors=ind_grn} + local r_auto = IndicatorLight{parent=annunciator,label="Automatic Control",colors=ind_wht} r_active.register(u_ps, "status", r_active.update) r_auto.register(u_ps, "AutoControl", r_auto.update) -- main unit transient/warning annunciator panel - local r_scram = IndicatorLight{parent=annunciator,label="Reactor SCRAM",colors=cpair(colors.red,colors.gray)} - local r_mscrm = IndicatorLight{parent=annunciator,label="Manual Reactor SCRAM",colors=cpair(colors.red,colors.gray)} - local r_ascrm = IndicatorLight{parent=annunciator,label="Auto Reactor SCRAM",colors=cpair(colors.red,colors.gray)} - local rad_wrn = IndicatorLight{parent=annunciator,label="Radiation Warning",colors=cpair(colors.yellow,colors.gray)} - local r_rtrip = IndicatorLight{parent=annunciator,label="RCP Trip",colors=cpair(colors.red,colors.gray)} - local r_cflow = IndicatorLight{parent=annunciator,label="RCS Flow Low",colors=cpair(colors.yellow,colors.gray)} - local r_clow = IndicatorLight{parent=annunciator,label="Coolant Level Low",colors=cpair(colors.yellow,colors.gray)} - local r_temp = IndicatorLight{parent=annunciator,label="Reactor Temp. High",colors=cpair(colors.red,colors.gray)} - local r_rhdt = IndicatorLight{parent=annunciator,label="Reactor High Delta T",colors=cpair(colors.yellow,colors.gray)} - local r_firl = IndicatorLight{parent=annunciator,label="Fuel Input Rate Low",colors=cpair(colors.yellow,colors.gray)} - local r_wloc = IndicatorLight{parent=annunciator,label="Waste Line Occlusion",colors=cpair(colors.yellow,colors.gray)} - local r_hsrt = IndicatorLight{parent=annunciator,label="Startup Rate High",colors=cpair(colors.yellow,colors.gray)} + local r_scram = IndicatorLight{parent=annunciator,label="Reactor SCRAM",colors=ind_red} + local r_mscrm = IndicatorLight{parent=annunciator,label="Manual Reactor SCRAM",colors=ind_red} + local r_ascrm = IndicatorLight{parent=annunciator,label="Auto Reactor SCRAM",colors=ind_red} + local rad_wrn = IndicatorLight{parent=annunciator,label="Radiation Warning",colors=ind_yel} + local r_rtrip = IndicatorLight{parent=annunciator,label="RCP Trip",colors=ind_red} + local r_cflow = IndicatorLight{parent=annunciator,label="RCS Flow Low",colors=ind_yel} + local r_clow = IndicatorLight{parent=annunciator,label="Coolant Level Low",colors=ind_yel} + local r_temp = IndicatorLight{parent=annunciator,label="Reactor Temp. High",colors=ind_red} + local r_rhdt = IndicatorLight{parent=annunciator,label="Reactor High Delta T",colors=ind_yel} + local r_firl = IndicatorLight{parent=annunciator,label="Fuel Input Rate Low",colors=ind_yel} + local r_wloc = IndicatorLight{parent=annunciator,label="Waste Line Occlusion",colors=ind_yel} + local r_hsrt = IndicatorLight{parent=annunciator,label="Startup Rate High",colors=ind_yel} r_scram.register(u_ps, "ReactorSCRAM", r_scram.update) r_mscrm.register(u_ps, "ManualReactorSCRAM", r_mscrm.update) @@ -189,15 +194,15 @@ local function init(parent, id) local rps = Rectangle{parent=main,border=border(1,colors.cyan,true),thin=true,width=33,height=12,x=46,y=9} local rps_annunc = Div{parent=rps,width=31,height=10,x=2,y=1} - local rps_trp = IndicatorLight{parent=rps_annunc,label="RPS Trip",colors=cpair(colors.red,colors.gray),flash=true,period=period.BLINK_250_MS} - local rps_dmg = IndicatorLight{parent=rps_annunc,label="Damage Level High",colors=cpair(colors.red,colors.gray),flash=true,period=period.BLINK_250_MS} - local rps_exh = IndicatorLight{parent=rps_annunc,label="Excess Heated Coolant",colors=cpair(colors.yellow,colors.gray)} - local rps_exw = IndicatorLight{parent=rps_annunc,label="Excess Waste",colors=cpair(colors.yellow,colors.gray)} - local rps_tmp = IndicatorLight{parent=rps_annunc,label="Core Temperature High",colors=cpair(colors.red,colors.gray),flash=true,period=period.BLINK_250_MS} - local rps_nof = IndicatorLight{parent=rps_annunc,label="No Fuel",colors=cpair(colors.yellow,colors.gray)} - local rps_loc = IndicatorLight{parent=rps_annunc,label="Coolant Level Low Low",colors=cpair(colors.yellow,colors.gray)} - local rps_flt = IndicatorLight{parent=rps_annunc,label="PPM Fault",colors=cpair(colors.yellow,colors.gray),flash=true,period=period.BLINK_500_MS} - local rps_tmo = IndicatorLight{parent=rps_annunc,label="Connection Timeout",colors=cpair(colors.yellow,colors.gray),flash=true,period=period.BLINK_500_MS} + local rps_trp = IndicatorLight{parent=rps_annunc,label="RPS Trip",colors=ind_red,flash=true,period=period.BLINK_250_MS} + local rps_dmg = IndicatorLight{parent=rps_annunc,label="Damage Level High",colors=ind_red,flash=true,period=period.BLINK_250_MS} + local rps_exh = IndicatorLight{parent=rps_annunc,label="Excess Heated Coolant",colors=ind_yel} + local rps_exw = IndicatorLight{parent=rps_annunc,label="Excess Waste",colors=ind_yel} + local rps_tmp = IndicatorLight{parent=rps_annunc,label="Core Temperature High",colors=ind_red,flash=true,period=period.BLINK_250_MS} + local rps_nof = IndicatorLight{parent=rps_annunc,label="No Fuel",colors=ind_yel} + local rps_loc = IndicatorLight{parent=rps_annunc,label="Coolant Level Low Low",colors=ind_yel} + local rps_flt = IndicatorLight{parent=rps_annunc,label="PPM Fault",colors=ind_yel,flash=true,period=period.BLINK_500_MS} + local rps_tmo = IndicatorLight{parent=rps_annunc,label="Connection Timeout",colors=ind_yel,flash=true,period=period.BLINK_500_MS} local rps_sfl = IndicatorLight{parent=rps_annunc,label="System Failure",colors=cpair(colors.orange,colors.gray),flash=true,period=period.BLINK_500_MS} rps_trp.register(u_ps, "rps_tripped", rps_trp.update) @@ -218,12 +223,12 @@ local function init(parent, id) local rcs_annunc = Div{parent=rcs,width=27,height=22,x=3,y=1} local rcs_tags = Div{parent=rcs,width=2,height=16,x=1,y=7} - local c_flt = IndicatorLight{parent=rcs_annunc,label="RCS Hardware Fault",colors=cpair(colors.yellow,colors.gray)} + local c_flt = IndicatorLight{parent=rcs_annunc,label="RCS Hardware Fault",colors=ind_yel} local c_emg = TriIndicatorLight{parent=rcs_annunc,label="Emergency Coolant",c1=colors.gray,c2=colors.white,c3=colors.green} - local c_cfm = IndicatorLight{parent=rcs_annunc,label="Coolant Feed Mismatch",colors=cpair(colors.yellow,colors.gray)} - local c_brm = IndicatorLight{parent=rcs_annunc,label="Boil Rate Mismatch",colors=cpair(colors.yellow,colors.gray)} - local c_sfm = IndicatorLight{parent=rcs_annunc,label="Steam Feed Mismatch",colors=cpair(colors.yellow,colors.gray)} - local c_mwrf = IndicatorLight{parent=rcs_annunc,label="Max Water Return Feed",colors=cpair(colors.yellow,colors.gray)} + local c_cfm = IndicatorLight{parent=rcs_annunc,label="Coolant Feed Mismatch",colors=ind_yel} + local c_brm = IndicatorLight{parent=rcs_annunc,label="Boil Rate Mismatch",colors=ind_yel} + local c_sfm = IndicatorLight{parent=rcs_annunc,label="Steam Feed Mismatch",colors=ind_yel} + local c_mwrf = IndicatorLight{parent=rcs_annunc,label="Max Water Return Feed",colors=ind_yel} c_flt.register(u_ps, "RCSFault", c_flt.update) c_emg.register(u_ps, "EmergencyCoolant", c_emg.update) @@ -246,11 +251,11 @@ local function init(parent, id) if unit.num_boilers > 0 then TextBox{parent=rcs_tags,x=1,text="B1",width=2,height=1,fg_bg=bw_fg_bg} - local b1_wll = IndicatorLight{parent=rcs_annunc,label="Water Level Low",colors=cpair(colors.red,colors.gray)} + local b1_wll = IndicatorLight{parent=rcs_annunc,label="Water Level Low",colors=ind_red} b1_wll.register(b_ps[1], "WasterLevelLow", b1_wll.update) TextBox{parent=rcs_tags,text="B1",width=2,height=1,fg_bg=bw_fg_bg} - local b1_hr = IndicatorLight{parent=rcs_annunc,label="Heating Rate Low",colors=cpair(colors.yellow,colors.gray)} + local b1_hr = IndicatorLight{parent=rcs_annunc,label="Heating Rate Low",colors=ind_yel} b1_hr.register(b_ps[1], "HeatingRateLow", b1_hr.update) end if unit.num_boilers > 1 then @@ -262,11 +267,11 @@ local function init(parent, id) end TextBox{parent=rcs_tags,text="B2",width=2,height=1,fg_bg=bw_fg_bg} - local b2_wll = IndicatorLight{parent=rcs_annunc,label="Water Level Low",colors=cpair(colors.red,colors.gray)} + local b2_wll = IndicatorLight{parent=rcs_annunc,label="Water Level Low",colors=ind_red} b2_wll.register(b_ps[2], "WasterLevelLow", b2_wll.update) TextBox{parent=rcs_tags,text="B2",width=2,height=1,fg_bg=bw_fg_bg} - local b2_hr = IndicatorLight{parent=rcs_annunc,label="Heating Rate Low",colors=cpair(colors.yellow,colors.gray)} + local b2_hr = IndicatorLight{parent=rcs_annunc,label="Heating Rate Low",colors=ind_yel} b2_hr.register(b_ps[2], "HeatingRateLow", b2_hr.update) end @@ -279,15 +284,15 @@ local function init(parent, id) t1_sdo.register(t_ps[1], "SteamDumpOpen", t1_sdo.update) TextBox{parent=rcs_tags,text="T1",width=2,height=1,fg_bg=bw_fg_bg} - local t1_tos = IndicatorLight{parent=rcs_annunc,label="Turbine Over Speed",colors=cpair(colors.red,colors.gray)} + local t1_tos = IndicatorLight{parent=rcs_annunc,label="Turbine Over Speed",colors=ind_red} t1_tos.register(t_ps[1], "TurbineOverSpeed", t1_tos.update) TextBox{parent=rcs_tags,text="T1",width=2,height=1,fg_bg=bw_fg_bg} - local t1_gtrp = IndicatorLight{parent=rcs_annunc,label="Generator Trip",colors=cpair(colors.yellow,colors.gray),flash=true,period=period.BLINK_250_MS} + local t1_gtrp = IndicatorLight{parent=rcs_annunc,label="Generator Trip",colors=ind_yel,flash=true,period=period.BLINK_250_MS} t1_gtrp.register(t_ps[1], "GeneratorTrip", t1_gtrp.update) TextBox{parent=rcs_tags,text="T1",width=2,height=1,fg_bg=bw_fg_bg} - local t1_trp = IndicatorLight{parent=rcs_annunc,label="Turbine Trip",colors=cpair(colors.red,colors.gray),flash=true,period=period.BLINK_250_MS} + local t1_trp = IndicatorLight{parent=rcs_annunc,label="Turbine Trip",colors=ind_red,flash=true,period=period.BLINK_250_MS} t1_trp.register(t_ps[1], "TurbineTrip", t1_trp.update) if unit.num_turbines > 1 then @@ -300,15 +305,15 @@ local function init(parent, id) t2_sdo.register(t_ps[2], "SteamDumpOpen", t2_sdo.update) TextBox{parent=rcs_tags,text="T2",width=2,height=1,fg_bg=bw_fg_bg} - local t2_tos = IndicatorLight{parent=rcs_annunc,label="Turbine Over Speed",colors=cpair(colors.red,colors.gray)} + local t2_tos = IndicatorLight{parent=rcs_annunc,label="Turbine Over Speed",colors=ind_red} t2_tos.register(t_ps[2], "TurbineOverSpeed", t2_tos.update) TextBox{parent=rcs_tags,text="T2",width=2,height=1,fg_bg=bw_fg_bg} - local t2_gtrp = IndicatorLight{parent=rcs_annunc,label="Generator Trip",colors=cpair(colors.yellow,colors.gray),flash=true,period=period.BLINK_250_MS} + local t2_gtrp = IndicatorLight{parent=rcs_annunc,label="Generator Trip",colors=ind_yel,flash=true,period=period.BLINK_250_MS} t2_gtrp.register(t_ps[2], "GeneratorTrip", t2_gtrp.update) TextBox{parent=rcs_tags,text="T2",width=2,height=1,fg_bg=bw_fg_bg} - local t2_trp = IndicatorLight{parent=rcs_annunc,label="Turbine Trip",colors=cpair(colors.red,colors.gray),flash=true,period=period.BLINK_250_MS} + local t2_trp = IndicatorLight{parent=rcs_annunc,label="Turbine Trip",colors=ind_red,flash=true,period=period.BLINK_250_MS} t2_trp.register(t_ps[2], "TurbineTrip", t2_trp.update) end @@ -320,15 +325,15 @@ local function init(parent, id) t3_sdo.register(t_ps[3], "SteamDumpOpen", t3_sdo.update) TextBox{parent=rcs_tags,text="T3",width=2,height=1,fg_bg=bw_fg_bg} - local t3_tos = IndicatorLight{parent=rcs_annunc,label="Turbine Over Speed",colors=cpair(colors.red,colors.gray)} + local t3_tos = IndicatorLight{parent=rcs_annunc,label="Turbine Over Speed",colors=ind_red} t3_tos.register(t_ps[3], "TurbineOverSpeed", t3_tos.update) TextBox{parent=rcs_tags,text="T3",width=2,height=1,fg_bg=bw_fg_bg} - local t3_gtrp = IndicatorLight{parent=rcs_annunc,label="Generator Trip",colors=cpair(colors.yellow,colors.gray),flash=true,period=period.BLINK_250_MS} + local t3_gtrp = IndicatorLight{parent=rcs_annunc,label="Generator Trip",colors=ind_yel,flash=true,period=period.BLINK_250_MS} t3_gtrp.register(t_ps[3], "GeneratorTrip", t3_gtrp.update) TextBox{parent=rcs_tags,text="T3",width=2,height=1,fg_bg=bw_fg_bg} - local t3_trp = IndicatorLight{parent=rcs_annunc,label="Turbine Trip",colors=cpair(colors.red,colors.gray),flash=true,period=period.BLINK_250_MS} + local t3_trp = IndicatorLight{parent=rcs_annunc,label="Turbine Trip",colors=ind_red,flash=true,period=period.BLINK_250_MS} t3_trp.register(t_ps[3], "TurbineTrip", t3_trp.update) end @@ -343,7 +348,7 @@ local function init(parent, id) TextBox{parent=burn_control,x=9,y=2,text="mB/t"} local set_burn = function () unit.set_burn(burn_rate.get_value()) end - local set_burn_btn = PushButton{parent=burn_control,x=14,y=2,text="SET",min_width=5,fg_bg=cpair(colors.black,colors.yellow),active_fg_bg=cpair(colors.white,colors.gray),dis_fg_bg=dis_colors,callback=set_burn} + local set_burn_btn = PushButton{parent=burn_control,x=14,y=2,text="SET",min_width=5,fg_bg=cpair(colors.black,colors.yellow),active_fg_bg=style.wh_gray,dis_fg_bg=dis_colors,callback=set_burn} burn_rate.register(u_ps, "burn_rate", burn_rate.set_value) burn_rate.register(u_ps, "max_burn", burn_rate.set_max) @@ -475,7 +480,7 @@ local function init(parent, id) auto_div.line_break() local function set_group() unit.set_group(group.get_value() - 1) end - local set_grp_btn = PushButton{parent=auto_div,text="SET",x=4,min_width=5,fg_bg=cpair(colors.black,colors.yellow),active_fg_bg=cpair(colors.white,colors.gray),dis_fg_bg=cpair(colors.gray,colors.white),callback=set_group} + local set_grp_btn = PushButton{parent=auto_div,text="SET",x=4,min_width=5,fg_bg=cpair(colors.black,colors.yellow),active_fg_bg=style.wh_gray,dis_fg_bg=cpair(colors.gray,colors.white),callback=set_group} auto_div.line_break() @@ -486,8 +491,8 @@ local function init(parent, id) auto_div.line_break() - local a_rdy = IndicatorLight{parent=auto_div,label="Ready",x=2,colors=cpair(colors.green,colors.gray)} - local a_stb = IndicatorLight{parent=auto_div,label="Standby",x=2,colors=cpair(colors.white,colors.gray),flash=true,period=period.BLINK_1000_MS} + local a_rdy = IndicatorLight{parent=auto_div,label="Ready",x=2,colors=ind_grn} + local a_stb = IndicatorLight{parent=auto_div,label="Standby",x=2,colors=ind_wht,flash=true,period=period.BLINK_1000_MS} a_rdy.register(u_ps, "U_AutoReady", a_rdy.update) diff --git a/coordinator/ui/components/unit_flow.lua b/coordinator/ui/components/unit_flow.lua new file mode 100644 index 0000000..ffee652 --- /dev/null +++ b/coordinator/ui/components/unit_flow.lua @@ -0,0 +1,225 @@ +-- +-- Basic Unit Flow Overview +-- + +local util = require("scada-common.util") + +local style = require("coordinator.ui.style") + +local core = require("graphics.core") + +local Div = require("graphics.elements.div") +local PipeNetwork = require("graphics.elements.pipenet") +local TextBox = require("graphics.elements.textbox") + +local Rectangle = require("graphics.elements.rectangle") + +local DataIndicator = require("graphics.elements.indicators.data") + +local IndicatorLight = require("graphics.elements.indicators.light") +local TriIndicatorLight = require("graphics.elements.indicators.trilight") + +local TEXT_ALIGN = core.TEXT_ALIGN + +local sprintf = util.sprintf + +local cpair = core.cpair +local border = core.border +local pipe = core.pipe + +local wh_gray = style.wh_gray +local bw_fg_bg = style.bw_fg_bg +local text_c = style.text_colors +local lu_c = style.lu_colors + +local ind_grn = style.ind_grn +local ind_wht = style.ind_wht + +-- make a new unit flow window +---@param parent graphics_element parent +---@param x integer top left x +---@param y integer top left y +---@param wide boolean whether to render wide version +---@param unit ioctl_unit unit database entry +local function make(parent, x, y, wide, unit) + local height = 16 + + local v_start = 1 + ((unit.unit_id - 1) * 5) + local prv_start = 1 + ((unit.unit_id - 1) * 3) + local v_fields = { "pu", "po", "pl", "am" } + local v_names = { + sprintf("PV%02d-PU", v_start), + sprintf("PV%02d-PO", v_start + 1), + sprintf("PV%02d-PL", v_start + 2), + sprintf("PV%02d-AM", v_start + 3), + sprintf("PRV%02d", prv_start), + sprintf("PRV%02d", prv_start + 1), + sprintf("PRV%02d", prv_start + 2) + } + + assert(parent.get_height() >= (y + height), "flow display not of sufficient vertical resolution (add an additional row of monitors) " .. y .. "," .. parent.get_height()) + + local function _wide(a, b) return util.trinary(wide, a, b) end + + -- bounding box div + local root = Div{parent=parent,x=x,y=y,width=_wide(136, 114),height=height} + + local lg_gray = cpair(colors.lightGray, colors.gray) + + ------------------ + -- COOLING LOOP -- + ------------------ + + local reactor = Rectangle{parent=root,x=1,y=1,border=border(1,colors.gray,true),width=19,height=5,fg_bg=wh_gray} + TextBox{parent=reactor,y=1,text="FISSION REACTOR",alignment=TEXT_ALIGN.CENTER,height=1} + TextBox{parent=reactor,y=3,text="UNIT #"..unit.unit_id,alignment=TEXT_ALIGN.CENTER,height=1} + TextBox{parent=root,x=19,y=2,text="\x1b \x80 \x1a",width=1,height=3,fg_bg=lg_gray} + TextBox{parent=root,x=3,y=5,text="\x19",width=1,height=1,fg_bg=lg_gray} + + local rc_pipes = {} + + local emc_x = 42 -- emergency coolant connection x point + + if unit.num_boilers > 0 then + table.insert(rc_pipes, pipe(0, 1, _wide(28, 19), 1, colors.lightBlue, true)) + table.insert(rc_pipes, pipe(0, 3, _wide(28, 19), 3, colors.orange, true)) + table.insert(rc_pipes, pipe(_wide(46 ,39), 1, _wide(72,58), 1, colors.blue, true)) + table.insert(rc_pipes, pipe(_wide(46,39), 3, _wide(72,58), 3, colors.white, true)) + else + emc_x = 3 + table.insert(rc_pipes, pipe(0, 1, _wide(72,58), 1, colors.blue, true)) + table.insert(rc_pipes, pipe(0, 3, _wide(72,58), 3, colors.white, true)) + end + + if unit.has_tank then + table.insert(rc_pipes, pipe(emc_x, 1, emc_x, 0, colors.blue, true, true)) + end + + local prv_yo = math.max(3 - unit.num_turbines, 0) + for i = 1, unit.num_turbines do + local py = 2 * (i - 1) + prv_yo + table.insert(rc_pipes, pipe(_wide(92, 78), py, _wide(104, 83), py, colors.white, true)) + end + + PipeNetwork{parent=root,x=20,y=1,pipes=rc_pipes,bg=colors.lightGray} + + if unit.num_boilers > 0 then + local cc_rate = DataIndicator{parent=root,x=_wide(25,22),y=5,lu_colors=lu_c,label="",unit="mB/t",format="%11.0f",value=0,commas=true,width=16,fg_bg=bw_fg_bg} + local hc_rate = DataIndicator{parent=root,x=_wide(25,22),y=3,lu_colors=lu_c,label="",unit="mB/t",format="%11.0f",value=0,commas=true,width=16,fg_bg=bw_fg_bg} + + cc_rate.register(unit.unit_ps, "boil_sum", cc_rate.update) + hc_rate.register(unit.unit_ps, "heating_rate", hc_rate.update) + + local boiler = Rectangle{parent=root,x=_wide(47,40),y=1,border=border(1, colors.gray, true),width=19,height=5,fg_bg=wh_gray} + TextBox{parent=boiler,y=1,text="THERMO-ELECTRIC",alignment=TEXT_ALIGN.CENTER,height=1} + TextBox{parent=boiler,y=3,text="BOILERS",alignment=TEXT_ALIGN.CENTER,height=1} + TextBox{parent=root,x=_wide(47,40),y=2,text="\x1b \x80 \x1a",width=1,height=3,fg_bg=lg_gray} + TextBox{parent=root,x=_wide(65,58),y=2,text="\x1b \x80 \x1a",width=1,height=3,fg_bg=lg_gray} + + local wt_rate = DataIndicator{parent=root,x=_wide(71,61),y=3,lu_colors=lu_c,label="",unit="mB/t",format="%11.0f",value=0,commas=true,width=16,fg_bg=bw_fg_bg} + local st_rate = DataIndicator{parent=root,x=_wide(71,61),y=5,lu_colors=lu_c,label="",unit="mB/t",format="%11.0f",value=0,commas=true,width=16,fg_bg=bw_fg_bg} + + wt_rate.register(unit.unit_ps, "turbine_flow_sum", wt_rate.update) + st_rate.register(unit.unit_ps, "boil_sum", st_rate.update) + else + local wt_rate = DataIndicator{parent=root,x=28,y=3,lu_colors=lu_c,label="",unit="mB/t",format="%11.0f",value=0,commas=true,width=16,fg_bg=bw_fg_bg} + local st_rate = DataIndicator{parent=root,x=28,y=5,lu_colors=lu_c,label="",unit="mB/t",format="%11.0f",value=0,commas=true,width=16,fg_bg=bw_fg_bg} + + wt_rate.register(unit.unit_ps, "turbine_flow_sum", wt_rate.update) + st_rate.register(unit.unit_ps, "heating_rate", st_rate.update) + end + + local turbine = Rectangle{parent=root,x=_wide(93,79),y=1,border=border(1, colors.gray, true),width=19,height=5,fg_bg=wh_gray} + TextBox{parent=turbine,y=1,text="STEAM TURBINE",alignment=TEXT_ALIGN.CENTER,height=1} + TextBox{parent=turbine,y=3,text=util.trinary(unit.num_turbines>1,"GENERATORS","GENERATOR"),alignment=TEXT_ALIGN.CENTER,height=1} + TextBox{parent=root,x=_wide(93,79),y=2,text="\x1b \x80 \x1a",width=1,height=3,fg_bg=lg_gray} + + for i = 1, unit.num_turbines do + local ry = 1 + (2 * (i - 1)) + prv_yo + TextBox{parent=root,x=_wide(125,103),y=ry,text="\x10\x11\x7f",fg_bg=text_c,width=3,height=1} + local state = TriIndicatorLight{parent=root,x=_wide(129,107),y=ry,label=v_names[i+4],c1=colors.gray,c2=colors.yellow,c3=colors.red} + state.register(unit.turbine_ps_tbl[i], "SteamDumpOpen", state.update) + end + + ---------------------- + -- WASTE PROCESSING -- + ---------------------- + + local waste = Div{parent=root,x=3,y=6} + + local waste_pipes = { + pipe(0, 0, _wide(19, 16), 1, colors.brown, true), + pipe(_wide(14, 13), 1, _wide(19, 17), 5, colors.brown, true), + pipe(_wide(22, 19), 1, _wide(49, 45), 1, colors.brown, true), + pipe(_wide(22, 19), 5, _wide(28, 24), 5, colors.brown, true), + + pipe(_wide(64, 53), 1, _wide(95, 81), 1, colors.green, true), + + pipe(_wide(48, 43), 4, _wide(71, 61), 4, colors.cyan, true), + pipe(_wide(66, 57), 4, _wide(71, 61), 8, colors.cyan, true), + pipe(_wide(74, 63), 4, _wide(95, 81), 4, colors.cyan, true), + pipe(_wide(74, 63), 8, _wide(133, 111), 8, colors.cyan, true), + + pipe(_wide(108, 94), 1, _wide(132, 110), 6, colors.black, true, true), + pipe(_wide(108, 94), 4, _wide(111, 95), 1, colors.black, true, true), + pipe(_wide(132, 110), 6, _wide(130, 108), 6, colors.black, true, true) + } + + PipeNetwork{parent=waste,x=1,y=1,pipes=waste_pipes,bg=colors.lightGray} + + local function _valve(vx, vy, n) + TextBox{parent=waste,x=vx,y=vy,text="\x10\x11",fg_bg=text_c,width=2,height=1} + local conn = IndicatorLight{parent=waste,x=vx-3,y=vy+1,label=v_names[n],colors=ind_grn} + local open = IndicatorLight{parent=waste,x=vx-3,y=vy+2,label="OPEN",colors=ind_wht} + conn.register(unit.unit_ps, util.c("V_", v_fields[n], "_conn"), conn.update) + open.register(unit.unit_ps, util.c("V_", v_fields[n], "_state"), open.update) + end + + local function _machine(mx, my, name) + local l = string.len(name) + 2 + TextBox{parent=waste,x=mx,y=my,text=string.rep("\x8f",l),alignment=TEXT_ALIGN.CENTER,fg_bg=lg_gray,width=l,height=1} + TextBox{parent=waste,x=mx,y=my+1,text=name,alignment=TEXT_ALIGN.CENTER,fg_bg=wh_gray,width=l,height=1} + end + + local waste_rate = DataIndicator{parent=waste,x=1,y=3,lu_colors=lu_c,label="",unit="mB/t",format="%7.2f",value=0,width=12,fg_bg=bw_fg_bg} + local pu_rate = DataIndicator{parent=waste,x=_wide(82,70),y=3,lu_colors=lu_c,label="",unit="mB/t",format="%7.3f",value=0,width=12,fg_bg=bw_fg_bg} + local po_rate = DataIndicator{parent=waste,x=_wide(52,45),y=6,lu_colors=lu_c,label="",unit="mB/t",format="%7.3f",value=0,width=12,fg_bg=bw_fg_bg} + local popl_rate = DataIndicator{parent=waste,x=_wide(82,70),y=6,lu_colors=lu_c,label="",unit="mB/t",format="%7.3f",value=0,width=12,fg_bg=bw_fg_bg} + local poam_rate = DataIndicator{parent=waste,x=_wide(82,70),y=10,lu_colors=lu_c,label="",unit="mB/t",format="%7.3f",value=0,width=12,fg_bg=bw_fg_bg} + local spent_rate = DataIndicator{parent=waste,x=_wide(117,99),y=3,lu_colors=lu_c,label="",unit="mB/t",format="%7.3f",value=0,width=12,fg_bg=bw_fg_bg} + + waste_rate.register(unit.unit_ps, "act_burn_rate", waste_rate.update) + pu_rate.register(unit.unit_ps, "pu_rate", pu_rate.update) + po_rate.register(unit.unit_ps, "po_rate", po_rate.update) + popl_rate.register(unit.unit_ps, "po_pl_rate", popl_rate.update) + poam_rate.register(unit.unit_ps, "po_am_rate", poam_rate.update) + spent_rate.register(unit.unit_ps, "ws_rate", spent_rate.update) + + _valve(_wide(21, 18), 2, 1) + _valve(_wide(21, 18), 6, 2) + _valve(_wide(73, 62), 5, 3) + _valve(_wide(73, 62), 9, 4) + + _machine(_wide(51, 45), 1, "CENTRIFUGE \x1a"); + _machine(_wide(97, 83), 1, "PRC [Pu] \x1a"); + _machine(_wide(97, 83), 4, "PRC [Po] \x1a"); + _machine(_wide(116, 94), 6, "SPENT WASTE \x1b") + + TextBox{parent=waste,x=_wide(30,25),y=3,text="SNAs [Po]",alignment=TEXT_ALIGN.CENTER,width=19,height=1,fg_bg=wh_gray} + local sna_po = Rectangle{parent=waste,x=_wide(30,25),y=4,border=border(1,colors.gray,true),width=19,height=7,thin=true,fg_bg=bw_fg_bg} + local sna_act = IndicatorLight{parent=sna_po,label="ACTIVE",colors=ind_grn} + local sna_cnt = DataIndicator{parent=sna_po,x=12,y=1,lu_colors=lu_c,label="CNT",unit="",format="%2d",value=0,width=7} + local sna_pk = DataIndicator{parent=sna_po,y=3,lu_colors=lu_c,label="PEAK",unit="mB/t",format="%7.2f",value=0,width=17} + local sna_max = DataIndicator{parent=sna_po,lu_colors=lu_c,label="MAX",unit="mB/t",format="%8.2f",value=0,width=17} + local sna_in = DataIndicator{parent=sna_po,lu_colors=lu_c,label="IN",unit="mB/t",format="%9.2f",value=0,width=17} + + sna_act.register(unit.unit_ps, "po_rate", function (r) sna_act.update(r > 0) end) + sna_cnt.register(unit.unit_ps, "sna_count", sna_cnt.update) + sna_pk.register(unit.unit_ps, "sna_peak_rate", sna_pk.update) + sna_max.register(unit.unit_ps, "sna_prod_rate", sna_max.update) + sna_in.register(unit.unit_ps, "sna_in", sna_in.update) + + return root +end + +return make diff --git a/coordinator/ui/layout/flow_view.lua b/coordinator/ui/layout/flow_view.lua new file mode 100644 index 0000000..54b14b5 --- /dev/null +++ b/coordinator/ui/layout/flow_view.lua @@ -0,0 +1,381 @@ +-- +-- Flow Monitor GUI +-- + +local types = require("scada-common.types") +local util = require("scada-common.util") + +local iocontrol = require("coordinator.iocontrol") + +local style = require("coordinator.ui.style") + +local unit_flow = require("coordinator.ui.components.unit_flow") + +local core = require("graphics.core") + +local Div = require("graphics.elements.div") +local PipeNetwork = require("graphics.elements.pipenet") +local Rectangle = require("graphics.elements.rectangle") +local TextBox = require("graphics.elements.textbox") + +local DataIndicator = require("graphics.elements.indicators.data") +local HorizontalBar = require("graphics.elements.indicators.hbar") +local IndicatorLight = require("graphics.elements.indicators.light") +local StateIndicator = require("graphics.elements.indicators.state") + +local CONTAINER_MODE = types.CONTAINER_MODE + +local TEXT_ALIGN = core.TEXT_ALIGN + +local cpair = core.cpair +local border = core.border +local pipe = core.pipe + +local wh_gray = style.wh_gray +local bw_fg_bg = style.bw_fg_bg +local text_col = style.text_colors +local lu_col = style.lu_colors + +-- create new flow view +---@param main graphics_element main displaybox +local function init(main) + local facility = iocontrol.get_db().facility + local units = iocontrol.get_db().units + + local tank_defs = facility.tank_defs + local tank_list = facility.tank_list + + -- window header message + local header = TextBox{parent=main,y=1,text="Facility Coolant and Waste Flow Monitor",alignment=TEXT_ALIGN.CENTER,height=1,fg_bg=style.header} + -- max length example: "01:23:45 AM - Wednesday, September 28 2022" + local datetime = TextBox{parent=main,x=(header.get_width()-42),y=1,text="",alignment=TEXT_ALIGN.RIGHT,width=42,height=1,fg_bg=style.header} + + datetime.register(facility.ps, "date_time", datetime.set_value) + + local po_pipes = {} + local water_pipes = {} + + -- get the y offset for this unit index + ---@param idx integer unit index + local function y_ofs(idx) return ((idx - 1) * 20) end + + -- determinte facility tank start/end from the definitions list + ---@param start_idx integer start index of table iteration + ---@param end_idx integer end index of table iteration + local function find_fdef(start_idx, end_idx) + local first, last = 4, 0 + for i = start_idx, end_idx do + if tank_defs[i] == 2 then + last = i + if i < first then first = i end + end + end + return first, last + end + + if facility.tank_mode == 0 or facility.tank_mode == 8 then + -- (0) tanks belong to reactor units OR (8) 4 total facility tanks (A B C D) + for i = 1, facility.num_units do + if units[i].has_tank then + local y = y_ofs(i) + table.insert(water_pipes, pipe(2, y, 2, y + 3, colors.blue, true)) + table.insert(water_pipes, pipe(2, y, 21, y, colors.blue, true)) + + local u = units[i] ---@type ioctl_unit + local x = util.trinary(u.num_boilers == 0, 45, 84) + table.insert(water_pipes, pipe(21, y, x, y + 2, colors.blue, true, true)) + end + end + else + -- setup connections for units with emergency coolant, always the same + for i = 1, #tank_defs do + if tank_defs[i] > 0 then + local y = y_ofs(i) + + if tank_defs[i] == 2 then + table.insert(water_pipes, pipe(1, y, 21, y, colors.blue, true)) + else + table.insert(water_pipes, pipe(2, y, 2, y + 3, colors.blue, true)) + table.insert(water_pipes, pipe(2, y, 21, y, colors.blue, true)) + end + + local u = units[i] ---@type ioctl_unit + local x = util.trinary(u.num_boilers == 0, 45, 84) + table.insert(water_pipes, pipe(21, y, x, y + 2, colors.blue, true, true)) + end + end + + if facility.tank_mode == 1 then + -- (1) 1 total facility tank (A A A A) + local first_fdef, last_fdef = find_fdef(1, #tank_defs) + + for i = 1, #tank_defs do + local y = y_ofs(i) + if i == first_fdef then + table.insert(water_pipes, pipe(0, y, 1, y + 5, colors.blue, true)) + elseif i > first_fdef then + if i == last_fdef then + table.insert(water_pipes, pipe(0, y - 14, 0, y, colors.blue, true)) + elseif i < last_fdef then + table.insert(water_pipes, pipe(0, y - 14, 0, y + 5, colors.blue, true)) + end + end + end + elseif facility.tank_mode == 2 then + -- (2) 2 total facility tanks (A A A B) + local first_fdef, last_fdef = find_fdef(1, math.min(3, #tank_defs)) + + for i = 1, #tank_defs do + local y = y_ofs(i) + if i == 4 then + if tank_defs[i] == 2 then + table.insert(water_pipes, pipe(0, y, 1, y + 5, colors.blue, true)) + end + elseif i == first_fdef then + table.insert(water_pipes, pipe(0, y, 1, y + 5, colors.blue, true)) + elseif i > first_fdef then + if i == last_fdef then + table.insert(water_pipes, pipe(0, y - 14, 0, y, colors.blue, true)) + elseif i < last_fdef then + table.insert(water_pipes, pipe(0, y - 14, 0, y + 5, colors.blue, true)) + end + end + end + elseif facility.tank_mode == 3 then + -- (3) 2 total facility tanks (A A B B) + for _, a in pairs({ 1, 3 }) do + local b = a + 1 + if tank_defs[a] == 2 then + table.insert(water_pipes, pipe(0, y_ofs(a), 1, y_ofs(a) + 6, colors.blue, true)) + if tank_defs[b] == 2 then + table.insert(water_pipes, pipe(0, y_ofs(b) - 13, 1, y_ofs(b), colors.blue, true)) + end + elseif tank_defs[b] == 2 then + table.insert(water_pipes, pipe(0, y_ofs(b), 1, y_ofs(b) + 6, colors.blue, true)) + end + end + elseif facility.tank_mode == 4 then + -- (4) 2 total facility tanks (A B B B) + local first_fdef, last_fdef = find_fdef(2, #tank_defs) + + for i = 1, #tank_defs do + local y = y_ofs(i) + if i == 1 then + if tank_defs[i] == 2 then + table.insert(water_pipes, pipe(0, y, 1, y + 5, colors.blue, true)) + end + elseif i == first_fdef then + table.insert(water_pipes, pipe(0, y, 1, y + 5, colors.blue, true)) + elseif i > first_fdef then + if i == last_fdef then + table.insert(water_pipes, pipe(0, y - 14, 0, y, colors.blue, true)) + elseif i < last_fdef then + table.insert(water_pipes, pipe(0, y - 14, 0, y + 5, colors.blue, true)) + end + end + end + elseif facility.tank_mode == 5 then + -- (5) 3 total facility tanks (A A B C) + local first_fdef, last_fdef = find_fdef(1, math.min(2, #tank_defs)) + + for i = 1, #tank_defs do + local y = y_ofs(i) + if i == 3 or i == 4 then + if tank_defs[i] == 2 then + table.insert(water_pipes, pipe(0, y, 1, y + 5, colors.blue, true)) + end + elseif i == first_fdef then + table.insert(water_pipes, pipe(0, y, 1, y + 5, colors.blue, true)) + elseif i > first_fdef then + if i == last_fdef then + table.insert(water_pipes, pipe(0, y - 14, 0, y, colors.blue, true)) + elseif i < last_fdef then + table.insert(water_pipes, pipe(0, y - 14, 0, y + 5, colors.blue, true)) + end + end + end + elseif facility.tank_mode == 6 then + -- (6) 3 total facility tanks (A B B C) + local first_fdef, last_fdef = find_fdef(2, math.min(3, #tank_defs)) + + for i = 1, #tank_defs do + local y = y_ofs(i) + if i == 1 or i == 4 then + if tank_defs[i] == 2 then + table.insert(water_pipes, pipe(0, y, 1, y + 5, colors.blue, true)) + end + elseif i == first_fdef then + table.insert(water_pipes, pipe(0, y, 1, y + 5, colors.blue, true)) + elseif i > first_fdef then + if i == last_fdef then + table.insert(water_pipes, pipe(0, y - 14, 0, y, colors.blue, true)) + elseif i < last_fdef then + table.insert(water_pipes, pipe(0, y - 14, 0, y + 5, colors.blue, true)) + end + end + end + elseif facility.tank_mode == 7 then + -- (7) 3 total facility tanks (A B C C) + local first_fdef, last_fdef = find_fdef(3, #tank_defs) + + for i = 1, #tank_defs do + local y = y_ofs(i) + if i == 1 or i == 2 then + if tank_defs[i] == 2 then + table.insert(water_pipes, pipe(0, y, 1, y + 5, colors.blue, true)) + end + elseif i == first_fdef then + table.insert(water_pipes, pipe(0, y, 1, y + 5, colors.blue, true)) + elseif i > first_fdef then + if i == last_fdef then + table.insert(water_pipes, pipe(0, y - 14, 0, y, colors.blue, true)) + elseif i < last_fdef then + table.insert(water_pipes, pipe(0, y - 14, 0, y + 5, colors.blue, true)) + end + end + end + end + end + + local flow_x = 3 + if #water_pipes > 0 then + flow_x = 25 + PipeNetwork{parent=main,x=2,y=3,pipes=water_pipes,bg=colors.lightGray} + end + + for i = 1, facility.num_units do + local y_offset = y_ofs(i) + unit_flow(main, flow_x, 5 + y_offset, #water_pipes == 0, units[i]) + table.insert(po_pipes, pipe(0, 3 + y_offset, 4, 0, colors.cyan, true, true)) + end + + PipeNetwork{parent=main,x=139,y=15,pipes=po_pipes,bg=colors.lightGray} + + ----------------- + -- tank valves -- + ----------------- + + local next_f_id = 1 + + for i = 1, #tank_defs do + if tank_defs[i] > 0 then + local vy = 3 + y_ofs(i) + + TextBox{parent=main,x=12,y=vy,text="\x10\x11",fg_bg=cpair(colors.black,colors.lightGray),width=2,height=1} + + local conn = IndicatorLight{parent=main,x=9,y=vy+1,label=util.sprintf("PV%02d-EMC", i * 5),colors=style.ind_grn} + local open = IndicatorLight{parent=main,x=9,y=vy+2,label="OPEN",colors=style.ind_wht} + + conn.register(units[i].unit_ps, "V_emc_conn", conn.update) + open.register(units[i].unit_ps, "V_emc_state", open.update) + end + end + + ------------------- + -- dynamic tanks -- + ------------------- + + for i = 1, #tank_list do + if tank_list[i] > 0 then + local id = "U-" .. i + local f_id = next_f_id + if tank_list[i] == 2 then + id = "F-" .. next_f_id + next_f_id = next_f_id + 1 + end + + local y_offset = y_ofs(i) + + local tank = Div{parent=main,x=3,y=7+y_offset,width=20,height=14} + + TextBox{parent=tank,text=" ",height=1,x=1,y=1,fg_bg=cpair(colors.lightGray,colors.gray)} + TextBox{parent=tank,text="DYNAMIC TANK "..id,alignment=TEXT_ALIGN.CENTER,height=1,fg_bg=style.wh_gray} + + local tank_box = Rectangle{parent=tank,border=border(1,colors.gray,true),width=20,height=12} + + local status = StateIndicator{parent=tank_box,x=3,y=1,states=style.dtank.states,value=1,min_width=14} + + TextBox{parent=tank_box,x=2,y=3,text="Fill",height=1,width=10,fg_bg=style.label} + local tank_pcnt = DataIndicator{parent=tank_box,x=10,y=3,label="",format="%5.2f",value=100,unit="%",lu_colors=lu_col,width=8,fg_bg=text_col} + local tank_amnt = DataIndicator{parent=tank_box,x=2,label="",format="%13d",value=0,unit="mB",lu_colors=lu_col,width=16,fg_bg=bw_fg_bg} + + TextBox{parent=tank_box,x=2,y=6,text="Water Level",height=1,width=11,fg_bg=style.label} + local level = HorizontalBar{parent=tank_box,x=2,y=7,bar_fg_bg=cpair(colors.blue,colors.gray),height=1,width=16} + + TextBox{parent=tank_box,x=2,y=9,text="In/Out Mode",height=1,width=11,fg_bg=style.label} + local can_fill = IndicatorLight{parent=tank_box,x=2,y=10,label="FILL",colors=style.ind_wht} + local can_empty = IndicatorLight{parent=tank_box,x=10,y=10,label="EMPTY",colors=style.ind_wht} + + local function _can_fill(mode) + can_fill.update((mode == CONTAINER_MODE.BOTH) or (mode == CONTAINER_MODE.FILL)) + end + + local function _can_empty(mode) + can_empty.update((mode == CONTAINER_MODE.BOTH) or (mode == CONTAINER_MODE.EMPTY)) + end + + if tank_list[i] == 1 then + status.register(units[i].tank_ps_tbl[1], "computed_status", status.update) + tank_pcnt.register(units[i].tank_ps_tbl[1], "fill", function (f) tank_pcnt.update(f * 100) end) + tank_amnt.register(units[i].tank_ps_tbl[1], "stored", function (sto) tank_amnt.update(sto.amount) end) + level.register(units[i].tank_ps_tbl[1], "fill", level.update) + can_fill.register(units[i].tank_ps_tbl[1], "container_mode", _can_fill) + can_empty.register(units[i].tank_ps_tbl[1], "container_mode", _can_empty) + else + status.register(facility.tank_ps_tbl[f_id], "computed_status", status.update) + tank_pcnt.register(facility.tank_ps_tbl[f_id], "fill", function (f) tank_pcnt.update(f * 100) end) + tank_amnt.register(facility.tank_ps_tbl[f_id], "stored", function (sto) tank_amnt.update(sto.amount) end) + level.register(facility.tank_ps_tbl[f_id], "fill", level.update) + can_fill.register(facility.tank_ps_tbl[f_id], "container_mode", _can_fill) + can_empty.register(facility.tank_ps_tbl[f_id], "container_mode", _can_empty) + end + end + end + + --------- + -- SPS -- + --------- + + local sps = Div{parent=main,x=140,y=3,height=12} + + TextBox{parent=sps,text=" ",width=24,height=1,x=1,y=1,fg_bg=cpair(colors.lightGray,colors.gray)} + TextBox{parent=sps,text="SPS",alignment=TEXT_ALIGN.CENTER,width=24,height=1,fg_bg=wh_gray} + + local sps_box = Rectangle{parent=sps,border=border(1,colors.gray,true),width=24,height=10} + + local status = StateIndicator{parent=sps_box,x=5,y=1,states=style.sps.states,value=1,min_width=14} + + status.register(facility.sps_ps_tbl[1], "computed_status", status.update) + + TextBox{parent=sps_box,x=2,y=3,text="Input Rate",height=1,width=10,fg_bg=style.label} + local sps_in = DataIndicator{parent=sps_box,x=2,label="",format="%15.3f",value=0,unit="mB/t",lu_colors=lu_col,width=20,fg_bg=bw_fg_bg} + + sps_in.register(facility.ps, "po_am_rate", sps_in.update) + + TextBox{parent=sps_box,x=2,y=6,text="Production Rate",height=1,width=15,fg_bg=style.label} + local sps_rate = DataIndicator{parent=sps_box,x=2,label="",format="%15d",value=0,unit="\xb5B/t",lu_colors=lu_col,width=20,fg_bg=bw_fg_bg} + + sps_rate.register(facility.sps_ps_tbl[1], "process_rate", function (r) sps_rate.update(r * 1000) end) + + ---------------- + -- statistics -- + ---------------- + + TextBox{parent=main,x=145,y=16,text="PROC. WASTE",alignment=TEXT_ALIGN.CENTER,width=19,height=1,fg_bg=wh_gray} + local pr_waste = Rectangle{parent=main,x=145,y=17,border=border(1,colors.gray,true),width=19,height=5,thin=true,fg_bg=bw_fg_bg} + local pu = DataIndicator{parent=pr_waste,lu_colors=lu_col,label="Pu",unit="mB/t",format="%9.3f",value=0,width=17} + local po = DataIndicator{parent=pr_waste,lu_colors=lu_col,label="Po",unit="mB/t",format="%9.3f",value=0,width=17} + local popl = DataIndicator{parent=pr_waste,lu_colors=lu_col,label="PoPl",unit="mB/t",format="%7.3f",value=0,width=17} + + pu.register(facility.ps, "pu_rate", pu.update) + po.register(facility.ps, "po_rate", po.update) + popl.register(facility.ps, "po_pl_rate", popl.update) + + TextBox{parent=main,x=145,y=23,text="SPENT WASTE",alignment=TEXT_ALIGN.CENTER,width=19,height=1,fg_bg=wh_gray} + local sp_waste = Rectangle{parent=main,x=145,y=24,border=border(1,colors.gray,true),width=19,height=3,thin=true,fg_bg=bw_fg_bg} + local sum_sp_waste = DataIndicator{parent=sp_waste,lu_colors=lu_col,label="SUM",unit="mB/t",format="%8.3f",value=0,width=17} + + sum_sp_waste.register(facility.ps, "spent_waste_rate", sum_sp_waste.update) +end + +return init diff --git a/coordinator/ui/layout/front_panel.lua b/coordinator/ui/layout/front_panel.lua index 207e213..11fe172 100644 --- a/coordinator/ui/layout/front_panel.lua +++ b/coordinator/ui/layout/front_panel.lua @@ -73,6 +73,9 @@ local function init(panel, num_units) local main_monitor = LED{parent=monitors,label="MAIN MONITOR",colors=cpair(colors.green,colors.green_off)} main_monitor.register(ps, "main_monitor", main_monitor.update) + local flow_monitor = LED{parent=monitors,label="FLOW MONITOR",colors=cpair(colors.green,colors.green_off)} + flow_monitor.register(ps, "flow_monitor", flow_monitor.update) + monitors.line_break() for i = 1, num_units do diff --git a/coordinator/ui/style.lua b/coordinator/ui/style.lua index ea52556..ad23989 100644 --- a/coordinator/ui/style.lua +++ b/coordinator/ui/style.lua @@ -68,7 +68,22 @@ style.colors = { -- { c = colors.brown, hex = 0x7f664c } } --- MAIN LAYOUT -- +-- COMMON COLOR PAIRS -- + +style.wh_gray = cpair(colors.white, colors.gray) + +style.bw_fg_bg = cpair(colors.black, colors.white) +style.text_colors = cpair(colors.black, colors.lightGray) +style.lu_colors = cpair(colors.gray, colors.gray) +style.hzd_fg_bg = style.wh_gray +style.dis_colors = cpair(colors.white, colors.lightGray) + +style.ind_grn = cpair(colors.green, colors.gray) +style.ind_yel = cpair(colors.yellow, colors.gray) +style.ind_red = cpair(colors.red, colors.gray) +style.ind_wht = style.wh_gray + +-- UI COMPONENTS -- style.reactor = { -- reactor states @@ -206,7 +221,7 @@ style.sps = { text = "RTU FAULT" }, { - color = cpair(colors.black, colors.gray), + color = cpair(colors.white, colors.gray), text = "IDLE" }, { @@ -216,6 +231,36 @@ style.sps = { } } +style.dtank = { + -- dynamic tank states + states = { + { + color = cpair(colors.black, colors.yellow), + text = "OFF-LINE" + }, + { + color = cpair(colors.black, colors.orange), + text = "NOT FORMED" + }, + { + color = cpair(colors.black, colors.orange), + text = "RTU FAULT" + }, + { + color = cpair(colors.black, colors.green), + text = "ONLINE" + }, + { + color = cpair(colors.black, colors.yellow), + text = "LOW FILL" + }, + { + color = cpair(colors.black, colors.green), + text = "FILLED" + }, + } +} + style.waste = { -- auto waste processing states states = { diff --git a/graphics/core.lua b/graphics/core.lua index 534d4e2..4e56714 100644 --- a/graphics/core.lua +++ b/graphics/core.lua @@ -7,7 +7,7 @@ local flasher = require("graphics.flasher") local core = {} -core.version = "1.0.2" +core.version = "1.1.1" core.flasher = flasher core.events = events diff --git a/graphics/element.lua b/graphics/element.lua index 145ee0c..5bfb7cd 100644 --- a/graphics/element.lua +++ b/graphics/element.lua @@ -20,6 +20,7 @@ local element = {} ---@alias graphics_args graphics_args_generic ---|waiting_args +---|app_button_args ---|checkbox_args ---|hazard_button_args ---|multi_button_args @@ -515,19 +516,21 @@ function element.new(args, child_offset_x, child_offset_y) -- FUNCTION CALLBACKS -- - -- handle a monitor touch or mouse click + -- handle a monitor touch or mouse click if this element is visible ---@param event mouse_interaction mouse interaction event function public.handle_mouse(event) - local x_ini, y_ini = event.initial.x, event.initial.y + if protected.window.isVisible() then + local x_ini, y_ini = event.initial.x, event.initial.y - local ini_in = protected.in_window_bounds(x_ini, y_ini) + local ini_in = protected.in_window_bounds(x_ini, y_ini) - if ini_in then - local event_T = core.events.mouse_transposed(event, self.position.x, self.position.y) + if ini_in then + local event_T = core.events.mouse_transposed(event, self.position.x, self.position.y) - -- handle the mouse event then pass to children - protected.handle_mouse(event_T) - for _, child in pairs(protected.children) do child.handle_mouse(event_T) end + -- handle the mouse event then pass to children + protected.handle_mouse(event_T) + for _, child in pairs(protected.children) do child.handle_mouse(event_T) end + end end end diff --git a/graphics/elements/controls/app.lua b/graphics/elements/controls/app.lua new file mode 100644 index 0000000..226946c --- /dev/null +++ b/graphics/elements/controls/app.lua @@ -0,0 +1,130 @@ +-- App Button Graphics Element + +local tcd = require("scada-common.tcd") + +local core = require("graphics.core") +local element = require("graphics.element") + +local CLICK_TYPE = core.events.CLICK_TYPE + +---@class app_button_args +---@field text string app icon text +---@field title string app title text +---@field callback function function to call on touch +---@field app_fg_bg cpair app icon foreground/background colors +---@field active_fg_bg? cpair foreground/background colors when pressed +---@field parent graphics_element +---@field id? string element id +---@field x? integer 1 if omitted +---@field y? integer auto incremented if omitted +---@field fg_bg? cpair foreground/background colors +---@field hidden? boolean true to hide on initial draw + +-- new app button +---@param args app_button_args +---@return graphics_element element, element_id id +local function app_button(args) + assert(type(args.text) == "string", "graphics.elements.controls.app: text is a required field") + assert(type(args.title) == "string", "graphics.elements.controls.app: title is a required field") + assert(type(args.callback) == "function", "graphics.elements.controls.app: callback is a required field") + assert(type(args.app_fg_bg) == "table", "graphics.elements.controls.app: app_fg_bg is a required field") + + args.height = 4 + args.width = 5 + + -- create new graphics element base object + local e = element.new(args) + + -- write app title, centered + e.window.setCursorPos(1, 4) + e.window.setCursorPos(math.floor((e.frame.w - string.len(args.title)) / 2) + 1, 4) + e.window.write(args.title) + + -- draw the app button + local function draw() + local fgd = args.app_fg_bg.fgd + local bkg = args.app_fg_bg.bkg + + if e.value then + fgd = args.active_fg_bg.fgd + bkg = args.active_fg_bg.bkg + end + + -- draw icon + e.window.setCursorPos(1, 1) + e.window.setTextColor(fgd) + e.window.setBackgroundColor(bkg) + e.window.write("\x9f\x83\x83\x83") + e.window.setTextColor(bkg) + e.window.setBackgroundColor(fgd) + e.window.write("\x90") + e.window.setTextColor(fgd) + e.window.setBackgroundColor(bkg) + e.window.setCursorPos(1, 2) + e.window.write("\x95 ") + e.window.setTextColor(bkg) + e.window.setBackgroundColor(fgd) + e.window.write("\x95") + e.window.setCursorPos(1, 3) + e.window.write("\x82\x8f\x8f\x8f\x81") + + -- write the icon text + e.window.setCursorPos(3, 2) + e.window.setTextColor(fgd) + e.window.setBackgroundColor(bkg) + e.window.write(args.text) + end + + -- draw the app button as pressed (if active_fg_bg set) + local function show_pressed() + if e.enabled and args.active_fg_bg ~= nil then + e.value = true + e.window.setTextColor(args.active_fg_bg.fgd) + e.window.setBackgroundColor(args.active_fg_bg.bkg) + draw() + end + end + + -- draw the app button as unpressed (if active_fg_bg set) + local function show_unpressed() + if e.enabled and args.active_fg_bg ~= nil then + e.value = false + e.window.setTextColor(e.fg_bg.fgd) + e.window.setBackgroundColor(e.fg_bg.bkg) + draw() + end + end + + -- handle mouse interaction + ---@param event mouse_interaction mouse event + function e.handle_mouse(event) + if e.enabled then + if event.type == CLICK_TYPE.TAP then + show_pressed() + -- show as unpressed in 0.25 seconds + if args.active_fg_bg ~= nil then tcd.dispatch(0.25, show_unpressed) end + args.callback() + elseif event.type == CLICK_TYPE.DOWN then + show_pressed() + elseif event.type == CLICK_TYPE.UP then + show_unpressed() + if e.in_frame_bounds(event.current.x, event.current.y) then + args.callback() + end + end + end + end + + -- set the value (true simulates pressing the app button) + ---@param val boolean new value + function e.set_value(val) + if val then e.handle_mouse(core.events.mouse_generic(core.events.CLICK_TYPE.UP, 1, 1)) end + end + + -- initial draw + draw() + + return e.complete() +end + +return app_button diff --git a/graphics/elements/pipenet.lua b/graphics/elements/pipenet.lua index efe6179..fe57757 100644 --- a/graphics/elements/pipenet.lua +++ b/graphics/elements/pipenet.lua @@ -14,6 +14,12 @@ local element = require("graphics.element") ---@field y? integer auto incremented if omitted ---@field hidden? boolean true to hide on initial draw +---@class _pipe_map_entry +---@field atr boolean align top right (or bottom left for false) +---@field thin boolean thin pipe or not +---@field fg string foreground blit +---@field bg string background blit + -- new pipe network ---@param args pipenet_args ---@return graphics_element element, element_id id @@ -44,102 +50,264 @@ local function pipenet(args) -- create new graphics element base object local e = element.new(args) - -- draw all pipes + -- determine if there are any thin pipes involved + local any_thin = false for p = 1, #args.pipes do - local pipe = args.pipes[p] ---@type pipe + any_thin = args.pipes[p].thin + if any_thin then break end + end - local x = 1 + pipe.x1 - local y = 1 + pipe.y1 + if not any_thin then + -- draw all pipes + for p = 1, #args.pipes do + local pipe = args.pipes[p] ---@type pipe - local x_step = util.trinary(pipe.x1 >= pipe.x2, -1, 1) - local y_step = util.trinary(pipe.y1 >= pipe.y2, -1, 1) + local x = 1 + pipe.x1 + local y = 1 + pipe.y1 - e.window.setCursorPos(x, y) + local x_step = util.trinary(pipe.x1 >= pipe.x2, -1, 1) + local y_step = util.trinary(pipe.y1 >= pipe.y2, -1, 1) - local c = core.cpair(pipe.color, e.fg_bg.bkg) + if pipe.thin then + x_step = util.trinary(pipe.x1 == pipe.x2, 0, x_step) + y_step = util.trinary(pipe.y1 == pipe.y2, 0, y_step) + end - if pipe.align_tr then - -- cross width then height - for i = 1, pipe.w do - if pipe.thin then - if i == pipe.w then - -- corner - if y_step > 0 then - e.window.blit("\x93", c.blit_bkg, c.blit_fgd) + e.window.setCursorPos(x, y) + + local c = core.cpair(pipe.color, e.fg_bg.bkg) + + if pipe.align_tr then + -- cross width then height + for i = 1, pipe.w do + if pipe.thin then + if i == pipe.w then + -- corner + if y_step > 0 then + e.window.blit("\x93", c.blit_bkg, c.blit_fgd) + else + e.window.blit("\x8e", c.blit_fgd, c.blit_bkg) + end else - e.window.blit("\x8e", c.blit_fgd, c.blit_bkg) + e.window.blit("\x8c", c.blit_fgd, c.blit_bkg) end else + if i == pipe.w and y_step > 0 then + -- corner + e.window.blit(" ", c.blit_bkg, c.blit_fgd) + else + e.window.blit("\x8f", c.blit_fgd, c.blit_bkg) + end + end + + x = x + x_step + e.window.setCursorPos(x, y) + end + + -- back up one + x = x - x_step + + for _ = 1, pipe.h - 1 do + y = y + y_step + e.window.setCursorPos(x, y) + + if pipe.thin then + e.window.blit("\x95", c.blit_bkg, c.blit_fgd) + else + e.window.blit(" ", c.blit_bkg, c.blit_fgd) + end + end + else + -- cross height then width + for i = 1, pipe.h do + if pipe.thin then + if i == pipe.h then + -- corner + if y_step < 0 then + e.window.blit("\x97", c.blit_bkg, c.blit_fgd) + elseif y_step > 0 then + e.window.blit("\x8d", c.blit_fgd, c.blit_bkg) + else + e.window.blit("\x8c", c.blit_fgd, c.blit_bkg) + end + else + e.window.blit("\x95", c.blit_fgd, c.blit_bkg) + end + else + if i == pipe.h and y_step < 0 then + -- corner + e.window.blit("\x83", c.blit_bkg, c.blit_fgd) + else + e.window.blit(" ", c.blit_bkg, c.blit_fgd) + end + end + + y = y + y_step + e.window.setCursorPos(x, y) + end + + -- back up one + y = y - y_step + + for _ = 1, pipe.w - 1 do + x = x + x_step + e.window.setCursorPos(x, y) + + if pipe.thin then e.window.blit("\x8c", c.blit_fgd, c.blit_bkg) - end - else - if i == pipe.w and y_step > 0 then - -- corner - e.window.blit(" ", c.blit_bkg, c.blit_fgd) else - e.window.blit("\x8f", c.blit_fgd, c.blit_bkg) - end - end - - x = x + x_step - e.window.setCursorPos(x, y) - end - - -- back up one - x = x - x_step - - for _ = 1, pipe.h - 1 do - y = y + y_step - e.window.setCursorPos(x, y) - - if pipe.thin then - e.window.blit("\x95", c.blit_bkg, c.blit_fgd) - else - e.window.blit(" ", c.blit_bkg, c.blit_fgd) - end - end - else - -- cross height then width - for i = 1, pipe.h do - if pipe.thin then - if i == pipe.h then - -- corner - if y_step < 0 then - e.window.blit("\x97", c.blit_bkg, c.blit_fgd) - else - e.window.blit("\x8d", c.blit_fgd, c.blit_bkg) - end - else - e.window.blit("\x95", c.blit_fgd, c.blit_bkg) - end - else - if i == pipe.h and y_step < 0 then - -- corner e.window.blit("\x83", c.blit_bkg, c.blit_fgd) - else - e.window.blit(" ", c.blit_bkg, c.blit_fgd) end end - - y = y + y_step - e.window.setCursorPos(x, y) end + end + else + -- build map if using thin pipes, easist way to check adjacent blocks (cannot 'cheat' like with standard width) + local map = {} - -- back up one - y = y - y_step + -- allocate map + for x = 1, args.width do + table.insert(map, {}) + for _ = 1, args.height do table.insert(map[x], false) end + end - for _ = 1, pipe.w - 1 do - x = x + x_step - e.window.setCursorPos(x, y) + -- build map + for p = 1, #args.pipes do + local pipe = args.pipes[p] ---@type pipe - if pipe.thin then - e.window.blit("\x8c", c.blit_fgd, c.blit_bkg) - else - e.window.blit("\x83", c.blit_bkg, c.blit_fgd) + local x = 1 + pipe.x1 + local y = 1 + pipe.y1 + + local x_step = util.trinary(pipe.x1 >= pipe.x2, -1, 1) + local y_step = util.trinary(pipe.y1 >= pipe.y2, -1, 1) + + local entry = { atr = pipe.align_tr, thin = pipe.thin, fg = colors.toBlit(pipe.color), bg = e.fg_bg.blit_bkg } + + if pipe.align_tr then + -- cross width then height + for _ = 1, pipe.w do + map[x][y] = entry + x = x + x_step + end + + x = x - x_step -- back up one + + for _ = 1, pipe.h do + map[x][y] = entry + y = y + y_step + end + else + -- cross height then width + for _ = 1, pipe.h do + map[x][y] = entry + y = y + y_step + end + + y = y - y_step -- back up one + + for _ = 1, pipe.w do + map[x][y] = entry + x = x + x_step end end end + -- render + for x = 1, args.width do + for y = 1, args.height do + local entry = map[x][y] ---@type _pipe_map_entry|false + local char + local invert = false + + if entry ~= false then + local function check(cx, cy) + return (map[cx] ~= nil) and (map[cx][cy] ~= nil) and (map[cx][cy] ~= false) and (map[cx][cy].fg == entry.fg) + end + + if entry.thin then + if check(x - 1, y) then -- if left + if check(x, y - 1) then -- if above + if check(x + 1, y) then -- if right + if check(x, y + 1) then -- if below + char = util.trinary(entry.atr, "\x91", "\x9d") + invert = entry.atr + else -- not below + char = util.trinary(entry.atr, "\x8e", "\x8d") + end + else -- not right + if check(x, y + 1) then -- if below + char = util.trinary(entry.atr, "\x91", "\x95") + invert = entry.atr + else -- not below + char = util.trinary(entry.atr, "\x8e", "\x85") + end + end + elseif check(x, y + 1) then-- not above, if below + if check(x + 1, y) then -- if right + char = util.trinary(entry.atr, "\x93", "\x9c") + invert = entry.atr + else -- not right + char = util.trinary(entry.atr, "\x93", "\x94") + invert = entry.atr + end + else -- not above, not below + char = "\x8c" + end + elseif check(x + 1, y) then -- not left, if right + if check(x, y - 1) then -- if above + if check(x, y + 1) then -- if below + char = util.trinary(entry.atr, "\x95", "\x9d") + invert = entry.atr + else -- not below + char = util.trinary(entry.atr, "\x8a", "\x8d") + end + else -- not above + if check(x, y + 1) then -- if below + char = util.trinary(entry.atr, "\x97", "\x9c") + invert = entry.atr + else -- not below + char = "\x8c" + end + end + else -- not left, not right + char = "\x95" + invert = entry.atr + end + else + if check(x, y - 1) then -- above + -- not below and (if left or right) + if (not check(x, y + 1)) and (check(x - 1, y) or check(x + 1, y)) then + char = util.trinary(entry.atr, "\x8f", " ") + invert = not entry.atr + else -- not below w/ sides only + char = " " + invert = true + end + elseif check(x, y + 1) then -- not above, if below + -- if left or right + if (check(x - 1, y) or check(x + 1, y)) then + char = "\x83" + invert = true + else -- not left or right + char = " " + invert = true + end + else -- not above, not below + char = util.trinary(entry.atr, "\x8f", "\x83") + invert = not entry.atr + end + end + + e.window.setCursorPos(x, y) + + if invert then + e.window.blit(char, entry.bg, entry.fg) + else + e.window.blit(char, entry.fg, entry.bg) + end + end + end + end end return e.complete() diff --git a/imgen.py b/imgen.py index 046fc36..8fc7989 100644 --- a/imgen.py +++ b/imgen.py @@ -48,6 +48,7 @@ def make_manifest(size): "versions" : { "installer" : get_version("./ccmsi.lua"), "bootloader" : get_version("./startup.lua"), + "common" : get_version("./scada-common/util.lua", True), "comms" : get_version("./scada-common/comms.lua", True), "graphics" : get_version("./graphics/core.lua", True), "lockbox" : get_version("./lockbox/init.lua", True), diff --git a/install_manifest.json b/install_manifest.json index 7b7cc96..51fe9f3 100644 --- a/install_manifest.json +++ b/install_manifest.json @@ -1 +1 @@ -{"versions": {"installer": "v1.7d", "bootloader": "0.2", "comms": "2.1.2", "graphics": "1.0.1", "lockbox": "1.0", "reactor-plc": "v1.5.5", "rtu": "v1.5.5", "supervisor": "v0.20.3", "coordinator": "v0.21.1", "pocket": "alpha-v0.5.2"}, "files": {"system": ["initenv.lua", "startup.lua"], "common": ["scada-common/ppm.lua", "scada-common/comms.lua", "scada-common/psil.lua", "scada-common/rsio.lua", "scada-common/constants.lua", "scada-common/mqueue.lua", "scada-common/tcd.lua", "scada-common/crash.lua", "scada-common/log.lua", "scada-common/types.lua", "scada-common/util.lua", "scada-common/network.lua"], "graphics": ["graphics/element.lua", "graphics/events.lua", "graphics/flasher.lua", "graphics/core.lua", "graphics/elements/listbox.lua", "graphics/elements/textbox.lua", "graphics/elements/displaybox.lua", "graphics/elements/pipenet.lua", "graphics/elements/rectangle.lua", "graphics/elements/div.lua", "graphics/elements/multipane.lua", "graphics/elements/tiling.lua", "graphics/elements/colormap.lua", "graphics/elements/indicators/alight.lua", "graphics/elements/indicators/icon.lua", "graphics/elements/indicators/power.lua", "graphics/elements/indicators/rad.lua", "graphics/elements/indicators/state.lua", "graphics/elements/indicators/light.lua", "graphics/elements/indicators/vbar.lua", "graphics/elements/indicators/led.lua", "graphics/elements/indicators/coremap.lua", "graphics/elements/indicators/data.lua", "graphics/elements/indicators/ledpair.lua", "graphics/elements/indicators/hbar.lua", "graphics/elements/indicators/trilight.lua", "graphics/elements/indicators/ledrgb.lua", "graphics/elements/controls/switch_button.lua", "graphics/elements/controls/spinbox_numeric.lua", "graphics/elements/controls/hazard_button.lua", "graphics/elements/controls/push_button.lua", "graphics/elements/controls/radio_button.lua", "graphics/elements/controls/multi_button.lua", "graphics/elements/controls/tabbar.lua", "graphics/elements/controls/checkbox.lua", "graphics/elements/controls/sidebar.lua", "graphics/elements/animations/waiting.lua"], "lockbox": ["lockbox/init.lua", "lockbox/LICENSE", "lockbox/kdf/pbkdf2.lua", "lockbox/util/bit.lua", "lockbox/util/array.lua", "lockbox/util/stream.lua", "lockbox/util/queue.lua", "lockbox/digest/sha2_224.lua", "lockbox/digest/sha1.lua", "lockbox/digest/sha2_256.lua", "lockbox/digest/md5.lua", "lockbox/mac/hmac.lua"], "reactor-plc": ["reactor-plc/renderer.lua", "reactor-plc/threads.lua", "reactor-plc/databus.lua", "reactor-plc/plc.lua", "reactor-plc/config.lua", "reactor-plc/startup.lua", "reactor-plc/panel/front_panel.lua", "reactor-plc/panel/style.lua"], "rtu": ["rtu/renderer.lua", "rtu/threads.lua", "rtu/rtu.lua", "rtu/databus.lua", "rtu/modbus.lua", "rtu/config.lua", "rtu/startup.lua", "rtu/panel/front_panel.lua", "rtu/panel/style.lua", "rtu/dev/sps_rtu.lua", "rtu/dev/envd_rtu.lua", "rtu/dev/boilerv_rtu.lua", "rtu/dev/redstone_rtu.lua", "rtu/dev/sna_rtu.lua", "rtu/dev/imatrix_rtu.lua", "rtu/dev/dynamicv_rtu.lua", "rtu/dev/turbinev_rtu.lua"], "supervisor": ["supervisor/renderer.lua", "supervisor/databus.lua", "supervisor/supervisor.lua", "supervisor/unit.lua", "supervisor/config.lua", "supervisor/startup.lua", "supervisor/unitlogic.lua", "supervisor/facility.lua", "supervisor/panel/pgi.lua", "supervisor/panel/front_panel.lua", "supervisor/panel/style.lua", "supervisor/panel/components/rtu_entry.lua", "supervisor/panel/components/pdg_entry.lua", "supervisor/session/coordinator.lua", "supervisor/session/svqtypes.lua", "supervisor/session/pocket.lua", "supervisor/session/svsessions.lua", "supervisor/session/rtu.lua", "supervisor/session/plc.lua", "supervisor/session/rsctl.lua", "supervisor/session/rtu/boilerv.lua", "supervisor/session/rtu/dynamicv.lua", "supervisor/session/rtu/txnctrl.lua", "supervisor/session/rtu/unit_session.lua", "supervisor/session/rtu/turbinev.lua", "supervisor/session/rtu/envd.lua", "supervisor/session/rtu/imatrix.lua", "supervisor/session/rtu/sps.lua", "supervisor/session/rtu/qtypes.lua", "supervisor/session/rtu/sna.lua", "supervisor/session/rtu/redstone.lua"], "coordinator": ["coordinator/coordinator.lua", "coordinator/renderer.lua", "coordinator/iocontrol.lua", "coordinator/sounder.lua", "coordinator/config.lua", "coordinator/startup.lua", "coordinator/process.lua", "coordinator/ui/dialog.lua", "coordinator/ui/pgi.lua", "coordinator/ui/style.lua", "coordinator/ui/layout/main_view.lua", "coordinator/ui/layout/unit_view.lua", "coordinator/ui/layout/front_panel.lua", "coordinator/ui/components/reactor.lua", "coordinator/ui/components/pkt_entry.lua", "coordinator/ui/components/process_ctl.lua", "coordinator/ui/components/unit_overview.lua", "coordinator/ui/components/boiler.lua", "coordinator/ui/components/unit_detail.lua", "coordinator/ui/components/imatrix.lua", "coordinator/ui/components/turbine.lua", "coordinator/session/pocket.lua", "coordinator/session/apisessions.lua"], "pocket": ["pocket/pocket.lua", "pocket/renderer.lua", "pocket/config.lua", "pocket/coreio.lua", "pocket/startup.lua", "pocket/ui/main.lua", "pocket/ui/style.lua", "pocket/ui/components/conn_waiting.lua", "pocket/ui/pages/turbine_page.lua", "pocket/ui/pages/reactor_page.lua", "pocket/ui/pages/home_page.lua", "pocket/ui/pages/unit_page.lua", "pocket/ui/pages/boiler_page.lua"]}, "depends": {"reactor-plc": ["system", "common", "graphics", "lockbox"], "rtu": ["system", "common", "graphics", "lockbox"], "supervisor": ["system", "common", "graphics", "lockbox"], "coordinator": ["system", "common", "graphics", "lockbox"], "pocket": ["system", "common", "graphics", "lockbox"]}, "sizes": {"manifest": 5789, "system": 1991, "common": 98474, "graphics": 147714, "lockbox": 34900, "reactor-plc": 95833, "rtu": 105774, "supervisor": 335453, "coordinator": 232175, "pocket": 37639}} \ No newline at end of file +{"versions": {"installer": "v1.8", "bootloader": "0.2", "common": "1.0.1", "comms": "2.2.1", "graphics": "1.1.0", "lockbox": "1.0", "reactor-plc": "v1.5.5", "rtu": "v1.6.0", "supervisor": "v0.22.1", "coordinator": "v0.22.0", "pocket": "v0.6.0-alpha"}, "files": {"system": ["initenv.lua", "startup.lua"], "common": ["scada-common/ppm.lua", "scada-common/comms.lua", "scada-common/psil.lua", "scada-common/rsio.lua", "scada-common/constants.lua", "scada-common/mqueue.lua", "scada-common/tcd.lua", "scada-common/crash.lua", "scada-common/audio.lua", "scada-common/log.lua", "scada-common/types.lua", "scada-common/util.lua", "scada-common/network.lua"], "graphics": ["graphics/element.lua", "graphics/events.lua", "graphics/flasher.lua", "graphics/core.lua", "graphics/elements/listbox.lua", "graphics/elements/textbox.lua", "graphics/elements/displaybox.lua", "graphics/elements/pipenet.lua", "graphics/elements/rectangle.lua", "graphics/elements/div.lua", "graphics/elements/multipane.lua", "graphics/elements/tiling.lua", "graphics/elements/colormap.lua", "graphics/elements/indicators/alight.lua", "graphics/elements/indicators/icon.lua", "graphics/elements/indicators/power.lua", "graphics/elements/indicators/rad.lua", "graphics/elements/indicators/state.lua", "graphics/elements/indicators/light.lua", "graphics/elements/indicators/vbar.lua", "graphics/elements/indicators/led.lua", "graphics/elements/indicators/coremap.lua", "graphics/elements/indicators/data.lua", "graphics/elements/indicators/ledpair.lua", "graphics/elements/indicators/hbar.lua", "graphics/elements/indicators/trilight.lua", "graphics/elements/indicators/ledrgb.lua", "graphics/elements/controls/switch_button.lua", "graphics/elements/controls/spinbox_numeric.lua", "graphics/elements/controls/hazard_button.lua", "graphics/elements/controls/push_button.lua", "graphics/elements/controls/app.lua", "graphics/elements/controls/radio_button.lua", "graphics/elements/controls/multi_button.lua", "graphics/elements/controls/tabbar.lua", "graphics/elements/controls/checkbox.lua", "graphics/elements/controls/sidebar.lua", "graphics/elements/animations/waiting.lua"], "lockbox": ["lockbox/init.lua", "lockbox/LICENSE", "lockbox/kdf/pbkdf2.lua", "lockbox/util/bit.lua", "lockbox/util/array.lua", "lockbox/util/stream.lua", "lockbox/util/queue.lua", "lockbox/digest/sha2_224.lua", "lockbox/digest/sha1.lua", "lockbox/digest/sha2_256.lua", "lockbox/digest/md5.lua", "lockbox/mac/hmac.lua"], "reactor-plc": ["reactor-plc/renderer.lua", "reactor-plc/threads.lua", "reactor-plc/databus.lua", "reactor-plc/plc.lua", "reactor-plc/config.lua", "reactor-plc/startup.lua", "reactor-plc/panel/front_panel.lua", "reactor-plc/panel/style.lua"], "rtu": ["rtu/renderer.lua", "rtu/threads.lua", "rtu/rtu.lua", "rtu/databus.lua", "rtu/modbus.lua", "rtu/config.lua", "rtu/startup.lua", "rtu/panel/front_panel.lua", "rtu/panel/style.lua", "rtu/dev/sps_rtu.lua", "rtu/dev/envd_rtu.lua", "rtu/dev/boilerv_rtu.lua", "rtu/dev/redstone_rtu.lua", "rtu/dev/sna_rtu.lua", "rtu/dev/imatrix_rtu.lua", "rtu/dev/dynamicv_rtu.lua", "rtu/dev/turbinev_rtu.lua"], "supervisor": ["supervisor/renderer.lua", "supervisor/databus.lua", "supervisor/supervisor.lua", "supervisor/unit.lua", "supervisor/config.lua", "supervisor/startup.lua", "supervisor/unitlogic.lua", "supervisor/facility.lua", "supervisor/panel/pgi.lua", "supervisor/panel/front_panel.lua", "supervisor/panel/style.lua", "supervisor/panel/components/rtu_entry.lua", "supervisor/panel/components/pdg_entry.lua", "supervisor/session/coordinator.lua", "supervisor/session/svqtypes.lua", "supervisor/session/pocket.lua", "supervisor/session/svsessions.lua", "supervisor/session/rtu.lua", "supervisor/session/plc.lua", "supervisor/session/rsctl.lua", "supervisor/session/rtu/boilerv.lua", "supervisor/session/rtu/dynamicv.lua", "supervisor/session/rtu/txnctrl.lua", "supervisor/session/rtu/unit_session.lua", "supervisor/session/rtu/turbinev.lua", "supervisor/session/rtu/envd.lua", "supervisor/session/rtu/imatrix.lua", "supervisor/session/rtu/sps.lua", "supervisor/session/rtu/qtypes.lua", "supervisor/session/rtu/sna.lua", "supervisor/session/rtu/redstone.lua"], "coordinator": ["coordinator/coordinator.lua", "coordinator/renderer.lua", "coordinator/iocontrol.lua", "coordinator/sounder.lua", "coordinator/config.lua", "coordinator/startup.lua", "coordinator/process.lua", "coordinator/ui/dialog.lua", "coordinator/ui/pgi.lua", "coordinator/ui/style.lua", "coordinator/ui/layout/main_view.lua", "coordinator/ui/layout/unit_view.lua", "coordinator/ui/layout/front_panel.lua", "coordinator/ui/components/reactor.lua", "coordinator/ui/components/pkt_entry.lua", "coordinator/ui/components/process_ctl.lua", "coordinator/ui/components/unit_overview.lua", "coordinator/ui/components/boiler.lua", "coordinator/ui/components/unit_detail.lua", "coordinator/ui/components/imatrix.lua", "coordinator/ui/components/turbine.lua", "coordinator/session/pocket.lua", "coordinator/session/apisessions.lua"], "pocket": ["pocket/pocket.lua", "pocket/renderer.lua", "pocket/iocontrol.lua", "pocket/config.lua", "pocket/startup.lua", "pocket/ui/main.lua", "pocket/ui/style.lua", "pocket/ui/components/conn_waiting.lua", "pocket/ui/pages/turbine_page.lua", "pocket/ui/pages/reactor_page.lua", "pocket/ui/pages/diag_page.lua", "pocket/ui/pages/home_page.lua", "pocket/ui/pages/unit_page.lua", "pocket/ui/pages/boiler_page.lua"]}, "depends": {"reactor-plc": ["system", "common", "graphics", "lockbox"], "rtu": ["system", "common", "graphics", "lockbox"], "supervisor": ["system", "common", "graphics", "lockbox"], "coordinator": ["system", "common", "graphics", "lockbox"], "pocket": ["system", "common", "graphics", "lockbox"]}, "sizes": {"manifest": 5908, "system": 1991, "common": 108345, "graphics": 152364, "lockbox": 34900, "reactor-plc": 95833, "rtu": 111126, "supervisor": 345892, "coordinator": 219352, "pocket": 53069}} \ No newline at end of file diff --git a/pocket/coreio.lua b/pocket/coreio.lua deleted file mode 100644 index 6f43dfd..0000000 --- a/pocket/coreio.lua +++ /dev/null @@ -1,35 +0,0 @@ --- --- Core I/O - Pocket Central I/O Management --- - -local psil = require("scada-common.psil") - -local coreio = {} - ----@class pocket_core_io -local io = { - ps = psil.create() -} - ----@enum POCKET_LINK_STATE -local LINK_STATE = { - UNLINKED = 0, - SV_LINK_ONLY = 1, - API_LINK_ONLY = 2, - LINKED = 3 -} - -coreio.LINK_STATE = LINK_STATE - --- get the core PSIL -function coreio.core_ps() - return io.ps -end - --- set network link state ----@param state POCKET_LINK_STATE -function coreio.report_link_state(state) - io.ps.publish("link_state", state) -end - -return coreio diff --git a/pocket/iocontrol.lua b/pocket/iocontrol.lua new file mode 100644 index 0000000..30645ae --- /dev/null +++ b/pocket/iocontrol.lua @@ -0,0 +1,106 @@ +-- +-- I/O Control for Pocket Integration with Supervisor & Coordinator +-- + +local psil = require("scada-common.psil") + +local types = require("scada-common.types") + +local ALARM = types.ALARM + +local iocontrol = {} + +---@class pocket_ioctl +local io = { + ps = psil.create() +} + +---@enum POCKET_LINK_STATE +local LINK_STATE = { + UNLINKED = 0, + SV_LINK_ONLY = 1, + API_LINK_ONLY = 2, + LINKED = 3 +} + +---@enum NAV_PAGE +local NAV_PAGE = { + HOME = 1, + UNITS = 2, + REACTORS = 3, + BOILERS = 4, + TURBINES = 5, + DIAG = 6, + D_ALARMS = 7 +} + +iocontrol.LINK_STATE = LINK_STATE +iocontrol.NAV_PAGE = NAV_PAGE + +-- initialize facility-independent components of pocket iocontrol +---@param comms pocket_comms +function iocontrol.init_core(comms) + ---@class pocket_ioctl_diag + io.diag = {} + + -- alarm testing + io.diag.tone_test = { + test_1 = function (state) comms.diag__set_alarm_tone(1, state) end, + test_2 = function (state) comms.diag__set_alarm_tone(2, state) end, + test_3 = function (state) comms.diag__set_alarm_tone(3, state) end, + test_4 = function (state) comms.diag__set_alarm_tone(4, state) end, + test_5 = function (state) comms.diag__set_alarm_tone(5, state) end, + test_6 = function (state) comms.diag__set_alarm_tone(6, state) end, + test_7 = function (state) comms.diag__set_alarm_tone(7, state) end, + test_8 = function (state) comms.diag__set_alarm_tone(8, state) end, + stop_tones = function () comms.diag__set_alarm_tone(0, false) end, + + test_breach = function (state) comms.diag__set_alarm(ALARM.ContainmentBreach, state) end, + test_rad = function (state) comms.diag__set_alarm(ALARM.ContainmentRadiation, state) end, + test_lost = function (state) comms.diag__set_alarm(ALARM.ReactorLost, state) end, + test_crit = function (state) comms.diag__set_alarm(ALARM.CriticalDamage, state) end, + test_dmg = function (state) comms.diag__set_alarm(ALARM.ReactorDamage, state) end, + test_overtemp = function (state) comms.diag__set_alarm(ALARM.ReactorOverTemp, state) end, + test_hightemp = function (state) comms.diag__set_alarm(ALARM.ReactorHighTemp, state) end, + test_wasteleak = function (state) comms.diag__set_alarm(ALARM.ReactorWasteLeak, state) end, + test_highwaste = function (state) comms.diag__set_alarm(ALARM.ReactorHighWaste, state) end, + test_rps = function (state) comms.diag__set_alarm(ALARM.RPSTransient, state) end, + test_rcs = function (state) comms.diag__set_alarm(ALARM.RCSTransient, state) end, + test_turbinet = function (state) comms.diag__set_alarm(ALARM.TurbineTrip, state) end, + stop_alarms = function () comms.diag__set_alarm(0, false) end, + + get_tone_states = function () comms.diag__get_alarm_tones() end, + + ready_warn = nil, ---@type graphics_element + tone_buttons = {}, + alarm_buttons = {}, + tone_indicators = {} -- indicators to update from supervisor tone states + } + + ---@class pocket_nav + io.nav = { + page = NAV_PAGE.HOME, ---@type NAV_PAGE + sub_pages = { NAV_PAGE.HOME, NAV_PAGE.UNITS, NAV_PAGE.REACTORS, NAV_PAGE.BOILERS, NAV_PAGE.TURBINES, NAV_PAGE.DIAG }, + tasks = {} + } + + -- add a task to be performed periodically while on a given page + ---@param page NAV_PAGE page to add task to + ---@param task function function to execute + function io.nav.register_task(page, task) + if io.nav.tasks[page] == nil then io.nav.tasks[page] = {} end + table.insert(io.nav.tasks[page], task) + end +end + +-- initialize facility-dependent components of pocket iocontrol +function iocontrol.init_fac() end + +-- set network link state +---@param state POCKET_LINK_STATE +function iocontrol.report_link_state(state) io.ps.publish("link_state", state) end + +-- get the IO controller database +function iocontrol.get_db() return io end + +return iocontrol diff --git a/pocket/pocket.lua b/pocket/pocket.lua index b0432cf..65b286a 100644 --- a/pocket/pocket.lua +++ b/pocket/pocket.lua @@ -1,16 +1,15 @@ -local comms = require("scada-common.comms") -local log = require("scada-common.log") -local util = require("scada-common.util") +local comms = require("scada-common.comms") +local log = require("scada-common.log") +local util = require("scada-common.util") -local coreio = require("pocket.coreio") +local iocontrol = require("pocket.iocontrol") local PROTOCOL = comms.PROTOCOL local DEVICE_TYPE = comms.DEVICE_TYPE local ESTABLISH_ACK = comms.ESTABLISH_ACK local SCADA_MGMT_TYPE = comms.SCADA_MGMT_TYPE --- local CAPI_TYPE = comms.CAPI_TYPE -local LINK_STATE = coreio.LINK_STATE +local LINK_STATE = iocontrol.LINK_STATE local pocket = {} @@ -79,20 +78,6 @@ function pocket.comms(version, nic, pkt_channel, svr_channel, crd_channel, range self.api.seq_num = self.api.seq_num + 1 end - -- send a packet to the coordinator API - -----@param msg_type CAPI_TYPE - -----@param msg table - -- local function _send_api(msg_type, msg) - -- local s_pkt = comms.scada_packet() - -- local pkt = comms.capi_packet() - - -- pkt.make(msg_type, msg) - -- s_pkt.make(self.api.addr, self.api.seq_num, PROTOCOL.COORD_API, pkt.raw_sendable()) - - -- nic.transmit(crd_channel, pkt_channel, s_pkt) - -- self.api.seq_num = self.api.seq_num + 1 - -- end - -- attempt supervisor connection establishment local function _send_sv_establish() _send_sv(SCADA_MGMT_TYPE.ESTABLISH, { comms.version, version, DEVICE_TYPE.PKT }) @@ -147,7 +132,7 @@ function pocket.comms(version, nic, pkt_channel, svr_channel, crd_channel, range -- attempt to re-link if any of the dependent links aren't active function public.link_update() if not self.sv.linked then - coreio.report_link_state(util.trinary(self.api.linked, LINK_STATE.API_LINK_ONLY, LINK_STATE.UNLINKED)) + iocontrol.report_link_state(util.trinary(self.api.linked, LINK_STATE.API_LINK_ONLY, LINK_STATE.UNLINKED)) if self.establish_delay_counter <= 0 then _send_sv_establish() @@ -156,7 +141,7 @@ function pocket.comms(version, nic, pkt_channel, svr_channel, crd_channel, range self.establish_delay_counter = self.establish_delay_counter - 1 end elseif not self.api.linked then - coreio.report_link_state(LINK_STATE.SV_LINK_ONLY) + iocontrol.report_link_state(LINK_STATE.SV_LINK_ONLY) if self.establish_delay_counter <= 0 then _send_api_establish() @@ -166,10 +151,29 @@ function pocket.comms(version, nic, pkt_channel, svr_channel, crd_channel, range end else -- linked, all good! - coreio.report_link_state(LINK_STATE.LINKED) + iocontrol.report_link_state(LINK_STATE.LINKED) end end + -- supervisor get active alarm tones + function public.diag__get_alarm_tones() + if self.sv.linked then _send_sv(SCADA_MGMT_TYPE.DIAG_TONE_GET, {}) end + end + + -- supervisor test alarm tones by tone + ---@param id TONE|0 tone ID, or 0 to stop all + ---@param state boolean tone state + function public.diag__set_alarm_tone(id, state) + if self.sv.linked then _send_sv(SCADA_MGMT_TYPE.DIAG_TONE_SET, { id, state }) end + end + + -- supervisor test alarm tones by alarm + ---@param id ALARM|0 alarm ID, 0 to stop all + ---@param state boolean alarm state + function public.diag__set_alarm(id, state) + if self.sv.linked then _send_sv(SCADA_MGMT_TYPE.DIAG_ALARM_SET, { id, state }) end + end + -- parse a packet ---@param side string ---@param sender integer @@ -205,6 +209,8 @@ function pocket.comms(version, nic, pkt_channel, svr_channel, crd_channel, range -- handle a packet ---@param packet mgmt_frame|capi_frame|nil function public.handle_packet(packet) + local diag = iocontrol.get_db().diag + if packet ~= nil then local l_chan = packet.scada_frame.local_channel() local r_chan = packet.scada_frame.remote_channel() @@ -231,47 +237,9 @@ function pocket.comms(version, nic, pkt_channel, svr_channel, crd_channel, range -- feed watchdog on valid sequence number api_watchdog.feed() - if protocol == PROTOCOL.COORD_API then - ---@cast packet capi_frame - elseif protocol == PROTOCOL.SCADA_MGMT then + if protocol == PROTOCOL.SCADA_MGMT then ---@cast packet mgmt_frame - if packet.type == SCADA_MGMT_TYPE.ESTABLISH then - -- connection with coordinator established - if packet.length == 1 then - local est_ack = packet.data[1] - - if est_ack == ESTABLISH_ACK.ALLOW then - log.info("coordinator connection established") - self.establish_delay_counter = 0 - self.api.linked = true - self.api.addr = src_addr - - if self.sv.linked then - coreio.report_link_state(LINK_STATE.LINKED) - else - coreio.report_link_state(LINK_STATE.API_LINK_ONLY) - end - elseif est_ack == ESTABLISH_ACK.DENY then - if self.api.last_est_ack ~= est_ack then - log.info("coordinator connection denied") - end - elseif est_ack == ESTABLISH_ACK.COLLISION then - if self.api.last_est_ack ~= est_ack then - log.info("coordinator connection denied due to collision") - end - elseif est_ack == ESTABLISH_ACK.BAD_VERSION then - if self.api.last_est_ack ~= est_ack then - log.info("coordinator comms version mismatch") - end - else - log.debug("coordinator SCADA_MGMT establish packet reply unsupported") - end - - self.api.last_est_ack = est_ack - else - log.debug("coordinator SCADA_MGMT establish packet length mismatch") - end - elseif self.api.linked then + if self.api.linked then if packet.type == SCADA_MGMT_TYPE.KEEP_ALIVE then -- keep alive request received, echo back if packet.length == 1 then @@ -298,6 +266,42 @@ function pocket.comms(version, nic, pkt_channel, svr_channel, crd_channel, range else log.debug("received unknown SCADA_MGMT packet type " .. packet.type .. " from coordinator") end + elseif packet.type == SCADA_MGMT_TYPE.ESTABLISH then + -- connection with coordinator established + if packet.length == 1 then + local est_ack = packet.data[1] + + if est_ack == ESTABLISH_ACK.ALLOW then + log.info("coordinator connection established") + self.establish_delay_counter = 0 + self.api.linked = true + self.api.addr = src_addr + + if self.sv.linked then + iocontrol.report_link_state(LINK_STATE.LINKED) + else + iocontrol.report_link_state(LINK_STATE.API_LINK_ONLY) + end + elseif est_ack == ESTABLISH_ACK.DENY then + if self.api.last_est_ack ~= est_ack then + log.info("coordinator connection denied") + end + elseif est_ack == ESTABLISH_ACK.COLLISION then + if self.api.last_est_ack ~= est_ack then + log.info("coordinator connection denied due to collision") + end + elseif est_ack == ESTABLISH_ACK.BAD_VERSION then + if self.api.last_est_ack ~= est_ack then + log.info("coordinator comms version mismatch") + end + else + log.debug("coordinator SCADA_MGMT establish packet reply unsupported") + end + + self.api.last_est_ack = est_ack + else + log.debug("coordinator SCADA_MGMT establish packet length mismatch") + end else log.debug("discarding coordinator non-link SCADA_MGMT packet before linked") end @@ -325,43 +329,7 @@ function pocket.comms(version, nic, pkt_channel, svr_channel, crd_channel, range -- handle packet if protocol == PROTOCOL.SCADA_MGMT then ---@cast packet mgmt_frame - if packet.type == SCADA_MGMT_TYPE.ESTABLISH then - -- connection with supervisor established - if packet.length == 1 then - local est_ack = packet.data[1] - - if est_ack == ESTABLISH_ACK.ALLOW then - log.info("supervisor connection established") - self.establish_delay_counter = 0 - self.sv.linked = true - self.sv.addr = src_addr - - if self.api.linked then - coreio.report_link_state(LINK_STATE.LINKED) - else - coreio.report_link_state(LINK_STATE.SV_LINK_ONLY) - end - elseif est_ack == ESTABLISH_ACK.DENY then - if self.sv.last_est_ack ~= est_ack then - log.info("supervisor connection denied") - end - elseif est_ack == ESTABLISH_ACK.COLLISION then - if self.sv.last_est_ack ~= est_ack then - log.info("supervisor connection denied due to collision") - end - elseif est_ack == ESTABLISH_ACK.BAD_VERSION then - if self.sv.last_est_ack ~= est_ack then - log.info("supervisor comms version mismatch") - end - else - log.debug("supervisor SCADA_MGMT establish packet reply unsupported") - end - - self.sv.last_est_ack = est_ack - else - log.debug("supervisor SCADA_MGMT establish packet length mismatch") - end - elseif self.sv.linked then + if self.sv.linked then if packet.type == SCADA_MGMT_TYPE.KEEP_ALIVE then -- keep alive request received, echo back if packet.length == 1 then @@ -385,9 +353,90 @@ function pocket.comms(version, nic, pkt_channel, svr_channel, crd_channel, range self.sv.r_seq_num = nil self.sv.addr = comms.BROADCAST log.info("supervisor server connection closed by remote host") + elseif packet.type == SCADA_MGMT_TYPE.DIAG_TONE_GET then + if packet.length == 8 then + for i = 1, #packet.data do + diag.tone_test.tone_indicators[i].update(packet.data[i] == true) + end + else + log.debug("supervisor SCADA diag alarm states packet length mismatch") + end + elseif packet.type == SCADA_MGMT_TYPE.DIAG_TONE_SET then + if packet.length == 1 and packet.data[1] == false then + diag.tone_test.ready_warn.set_value("testing denied") + log.debug("supervisor SCADA diag tone set failed") + elseif packet.length == 2 and type(packet.data[2]) == "table" then + local ready = packet.data[1] + local states = packet.data[2] + + diag.tone_test.ready_warn.set_value(util.trinary(ready, "", "system not ready")) + + for i = 1, #states do + if diag.tone_test.tone_buttons[i] ~= nil then + diag.tone_test.tone_buttons[i].set_value(states[i] == true) + diag.tone_test.tone_indicators[i].update(states[i] == true) + end + end + else + log.debug("supervisor SCADA diag tone set packet length/type mismatch") + end + elseif packet.type == SCADA_MGMT_TYPE.DIAG_ALARM_SET then + if packet.length == 1 and packet.data[1] == false then + diag.tone_test.ready_warn.set_value("testing denied") + log.debug("supervisor SCADA diag alarm set failed") + elseif packet.length == 2 and type(packet.data[2]) == "table" then + local ready = packet.data[1] + local states = packet.data[2] + + diag.tone_test.ready_warn.set_value(util.trinary(ready, "", "system not ready")) + + for i = 1, #states do + if diag.tone_test.alarm_buttons[i] ~= nil then + diag.tone_test.alarm_buttons[i].set_value(states[i] == true) + end + end + else + log.debug("supervisor SCADA diag alarm set packet length/type mismatch") + end else log.debug("received unknown SCADA_MGMT packet type " .. packet.type .. " from supervisor") end + elseif packet.type == SCADA_MGMT_TYPE.ESTABLISH then + -- connection with supervisor established + if packet.length == 1 then + local est_ack = packet.data[1] + + if est_ack == ESTABLISH_ACK.ALLOW then + log.info("supervisor connection established") + self.establish_delay_counter = 0 + self.sv.linked = true + self.sv.addr = src_addr + + if self.api.linked then + iocontrol.report_link_state(LINK_STATE.LINKED) + else + iocontrol.report_link_state(LINK_STATE.SV_LINK_ONLY) + end + elseif est_ack == ESTABLISH_ACK.DENY then + if self.sv.last_est_ack ~= est_ack then + log.info("supervisor connection denied") + end + elseif est_ack == ESTABLISH_ACK.COLLISION then + if self.sv.last_est_ack ~= est_ack then + log.info("supervisor connection denied due to collision") + end + elseif est_ack == ESTABLISH_ACK.BAD_VERSION then + if self.sv.last_est_ack ~= est_ack then + log.info("supervisor comms version mismatch") + end + else + log.debug("supervisor SCADA_MGMT establish packet reply unsupported") + end + + self.sv.last_est_ack = est_ack + else + log.debug("supervisor SCADA_MGMT establish packet length mismatch") + end else log.debug("discarding supervisor non-link SCADA_MGMT packet before linked") end diff --git a/pocket/startup.lua b/pocket/startup.lua index f8403c1..14ece9e 100644 --- a/pocket/startup.lua +++ b/pocket/startup.lua @@ -4,21 +4,21 @@ require("/initenv").init_env() -local crash = require("scada-common.crash") -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 crash = require("scada-common.crash") +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 core = require("graphics.core") -local config = require("pocket.config") -local coreio = require("pocket.coreio") -local pocket = require("pocket.pocket") -local renderer = require("pocket.renderer") +local config = require("pocket.config") +local iocontrol = require("pocket.iocontrol") +local pocket = require("pocket.pocket") +local renderer = require("pocket.renderer") -local POCKET_VERSION = "alpha-v0.5.2" +local POCKET_VERSION = "v0.6.0-alpha" local println = util.println local println_ts = util.println_ts @@ -73,7 +73,7 @@ local function main() network.init_mac(config.AUTH_KEY) end - coreio.report_link_state(coreio.LINK_STATE.UNLINKED) + iocontrol.report_link_state(iocontrol.LINK_STATE.UNLINKED) -- get the communications modem local modem = ppm.get_wireless_modem() @@ -104,6 +104,9 @@ local function main() local MAIN_CLOCK = 0.5 local loop_clock = util.new_clock(MAIN_CLOCK) + -- init I/O control + iocontrol.init_core(pocket_comms) + ---------------------------------------- -- start the UI ---------------------------------------- @@ -128,6 +131,9 @@ local function main() conn_wd.api.feed() log.debug("startup> conn watchdog started") + local io_db = iocontrol.get_db() + local nav = io_db.nav + -- main event loop while true do local event, param1, param2, param3, param4, param5 = util.pull_event() @@ -140,6 +146,13 @@ local function main() -- relink if necessary pocket_comms.link_update() + -- update any tasks for the active page + if (type(nav.tasks[nav.page]) == "table") then + for i = 1, #nav.tasks[nav.page] do + nav.tasks[nav.page][i]() + end + end + loop_clock.start() elseif conn_wd.sv.is_timer(param1) then -- supervisor watchdog timeout diff --git a/pocket/ui/main.lua b/pocket/ui/main.lua index 11331e4..212b5bc 100644 --- a/pocket/ui/main.lua +++ b/pocket/ui/main.lua @@ -2,17 +2,18 @@ -- Pocket GUI Root -- -local coreio = require("pocket.coreio") +local iocontrol = require("pocket.iocontrol") local style = require("pocket.ui.style") local conn_waiting = require("pocket.ui.components.conn_waiting") -local home_page = require("pocket.ui.pages.home_page") -local unit_page = require("pocket.ui.pages.unit_page") -local reactor_page = require("pocket.ui.pages.reactor_page") local boiler_page = require("pocket.ui.pages.boiler_page") +local diag_page = require("pocket.ui.pages.diag_page") +local home_page = require("pocket.ui.pages.home_page") +local reactor_page = require("pocket.ui.pages.reactor_page") local turbine_page = require("pocket.ui.pages.turbine_page") +local unit_page = require("pocket.ui.pages.unit_page") local core = require("graphics.core") @@ -22,6 +23,9 @@ local TextBox = require("graphics.elements.textbox") local Sidebar = require("graphics.elements.controls.sidebar") +local LINK_STATE = iocontrol.LINK_STATE +local NAV_PAGE = iocontrol.NAV_PAGE + local TEXT_ALIGN = core.TEXT_ALIGN local cpair = core.cpair @@ -29,6 +33,9 @@ local cpair = core.cpair -- create new main view ---@param main graphics_element main displaybox local function init(main) + local nav = iocontrol.get_db().nav + local ps = iocontrol.get_db().ps + -- window header message TextBox{parent=main,y=1,text="",alignment=TEXT_ALIGN.LEFT,height=1,fg_bg=style.header} @@ -45,10 +52,10 @@ local function init(main) local root_pane = MultiPane{parent=root_pane_div,x=1,y=1,panes=root_panes} - root_pane.register(coreio.core_ps(), "link_state", function (state) - if state == coreio.LINK_STATE.UNLINKED or state == coreio.LINK_STATE.API_LINK_ONLY then + root_pane.register(ps, "link_state", function (state) + if state == LINK_STATE.UNLINKED or state == LINK_STATE.API_LINK_ONLY then root_pane.set_value(1) - elseif state == coreio.LINK_STATE.SV_LINK_ONLY then + elseif state == LINK_STATE.SV_LINK_ONLY then root_pane.set_value(2) else root_pane.set_value(3) @@ -81,19 +88,36 @@ local function init(main) { char = "T", color = cpair(colors.black,colors.white) + }, + { + char = "D", + color = cpair(colors.black,colors.orange) } } - local pane_1 = home_page(page_div) - local pane_2 = unit_page(page_div) - local pane_3 = reactor_page(page_div) - local pane_4 = boiler_page(page_div) - local pane_5 = turbine_page(page_div) - local panes = { pane_1, pane_2, pane_3, pane_4, pane_5 } + local panes = { home_page(page_div), unit_page(page_div), reactor_page(page_div), boiler_page(page_div), turbine_page(page_div), diag_page(page_div) } local page_pane = MultiPane{parent=page_div,x=1,y=1,panes=panes} - Sidebar{parent=main_pane,x=1,y=1,tabs=sidebar_tabs,fg_bg=cpair(colors.white,colors.gray),callback=page_pane.set_value} + local function navigate_sidebar(page) + if page == 1 then + nav.page = nav.sub_pages[NAV_PAGE.HOME] + elseif page == 2 then + nav.page = nav.sub_pages[NAV_PAGE.UNITS] + elseif page == 3 then + nav.page = nav.sub_pages[NAV_PAGE.REACTORS] + elseif page == 4 then + nav.page = nav.sub_pages[NAV_PAGE.BOILERS] + elseif page == 5 then + nav.page = nav.sub_pages[NAV_PAGE.TURBINES] + elseif page == 6 then + nav.page = nav.sub_pages[NAV_PAGE.DIAG] + end + + page_pane.set_value(page) + end + + Sidebar{parent=main_pane,x=1,y=1,tabs=sidebar_tabs,fg_bg=cpair(colors.white,colors.gray),callback=navigate_sidebar} end return init diff --git a/pocket/ui/pages/diag_page.lua b/pocket/ui/pages/diag_page.lua new file mode 100644 index 0000000..38d9368 --- /dev/null +++ b/pocket/ui/pages/diag_page.lua @@ -0,0 +1,147 @@ +local iocontrol = require("pocket.iocontrol") + +local core = require("graphics.core") + +local Div = require("graphics.elements.div") +local MultiPane = require("graphics.elements.multipane") +local TextBox = require("graphics.elements.textbox") + +local IndicatorLight = require("graphics.elements.indicators.light") + +local App = require("graphics.elements.controls.app") +local Checkbox = require("graphics.elements.controls.checkbox") +local PushButton = require("graphics.elements.controls.push_button") +local SwitchButton = require("graphics.elements.controls.switch_button") + +local cpair = core.cpair + +local NAV_PAGE = iocontrol.NAV_PAGE + +local TEXT_ALIGN = core.TEXT_ALIGN + +-- new diagnostics page view +---@param root graphics_element parent +local function new_view(root) + local db = iocontrol.get_db() + + local main = Div{parent=root,x=1,y=1} + + local diag_home = Div{parent=main,x=1,y=1} + + TextBox{parent=diag_home,text="Diagnostic Apps",x=1,y=2,height=1,alignment=TEXT_ALIGN.CENTER} + + local alarm_test = Div{parent=main,x=1,y=1} + + local panes = { diag_home, alarm_test } + + local page_pane = MultiPane{parent=main,x=1,y=1,panes=panes} + + local function navigate_diag() + page_pane.set_value(1) + db.nav.page = NAV_PAGE.DIAG + db.nav.sub_pages[NAV_PAGE.DIAG] = NAV_PAGE.DIAG + end + + local function navigate_alarm() + page_pane.set_value(2) + db.nav.page = NAV_PAGE.D_ALARMS + db.nav.sub_pages[NAV_PAGE.DIAG] = NAV_PAGE.D_ALARMS + end + + ------------------------ + -- Alarm Testing Page -- + ------------------------ + + db.nav.register_task(NAV_PAGE.D_ALARMS, db.diag.tone_test.get_tone_states) + + local ttest = db.diag.tone_test + + local c_wht_gray = cpair(colors.white, colors.gray) + local c_red_gray = cpair(colors.red, colors.gray) + local c_yel_gray = cpair(colors.yellow, colors.gray) + local c_blue_gray = cpair(colors.blue, colors.gray) + + local audio = Div{parent=alarm_test,x=1,y=1} + + TextBox{parent=audio,y=1,text="Alarm Sounder Tests",height=1,alignment=TEXT_ALIGN.CENTER} + + ttest.ready_warn = TextBox{parent=audio,y=2,text="",height=1,alignment=TEXT_ALIGN.CENTER,fg_bg=cpair(colors.yellow,colors.black)} + + PushButton{parent=audio,x=13,y=18,text="\x11 BACK",min_width=8,fg_bg=cpair(colors.black,colors.lightGray),active_fg_bg=c_wht_gray,callback=navigate_diag} + + local tones = Div{parent=audio,x=2,y=3,height=10,width=8,fg_bg=cpair(colors.black,colors.yellow)} + + TextBox{parent=tones,text="Tones",height=1,alignment=TEXT_ALIGN.CENTER,fg_bg=audio.get_fg_bg()} + + local test_btns = {} + test_btns[1] = SwitchButton{parent=tones,text="TEST 1",min_width=8,active_fg_bg=c_wht_gray,callback=ttest.test_1} + test_btns[2] = SwitchButton{parent=tones,text="TEST 2",min_width=8,active_fg_bg=c_wht_gray,callback=ttest.test_2} + test_btns[3] = SwitchButton{parent=tones,text="TEST 3",min_width=8,active_fg_bg=c_wht_gray,callback=ttest.test_3} + test_btns[4] = SwitchButton{parent=tones,text="TEST 4",min_width=8,active_fg_bg=c_wht_gray,callback=ttest.test_4} + test_btns[5] = SwitchButton{parent=tones,text="TEST 5",min_width=8,active_fg_bg=c_wht_gray,callback=ttest.test_5} + test_btns[6] = SwitchButton{parent=tones,text="TEST 6",min_width=8,active_fg_bg=c_wht_gray,callback=ttest.test_6} + test_btns[7] = SwitchButton{parent=tones,text="TEST 7",min_width=8,active_fg_bg=c_wht_gray,callback=ttest.test_7} + test_btns[8] = SwitchButton{parent=tones,text="TEST 8",min_width=8,active_fg_bg=c_wht_gray,callback=ttest.test_8} + + ttest.tone_buttons = test_btns + + local function stop_all_tones() + for i = 1, #test_btns do test_btns[i].set_value(false) end + ttest.stop_tones() + end + + PushButton{parent=tones,text="STOP",min_width=8,active_fg_bg=c_wht_gray,fg_bg=cpair(colors.black,colors.red),callback=stop_all_tones} + + local alarms = Div{parent=audio,x=11,y=3,height=15,fg_bg=cpair(colors.lightGray,colors.black)} + + TextBox{parent=alarms,text="Alarms (\x13)",height=1,alignment=TEXT_ALIGN.CENTER,fg_bg=audio.get_fg_bg()} + + local alarm_btns = {} + alarm_btns[1] = Checkbox{parent=alarms,label="BREACH",min_width=15,box_fg_bg=c_red_gray,callback=ttest.test_breach} + alarm_btns[2] = Checkbox{parent=alarms,label="RADIATION",min_width=15,box_fg_bg=c_red_gray,callback=ttest.test_rad} + alarm_btns[3] = Checkbox{parent=alarms,label="RCT LOST",min_width=15,box_fg_bg=c_red_gray,callback=ttest.test_lost} + alarm_btns[4] = Checkbox{parent=alarms,label="CRIT DAMAGE",min_width=15,box_fg_bg=c_red_gray,callback=ttest.test_crit} + alarm_btns[5] = Checkbox{parent=alarms,label="DAMAGE",min_width=15,box_fg_bg=c_red_gray,callback=ttest.test_dmg} + alarm_btns[6] = Checkbox{parent=alarms,label="OVER TEMP",min_width=15,box_fg_bg=c_red_gray,callback=ttest.test_overtemp} + alarm_btns[7] = Checkbox{parent=alarms,label="HIGH TEMP",min_width=15,box_fg_bg=c_yel_gray,callback=ttest.test_hightemp} + alarm_btns[8] = Checkbox{parent=alarms,label="WASTE LEAK",min_width=15,box_fg_bg=c_red_gray,callback=ttest.test_wasteleak} + alarm_btns[9] = Checkbox{parent=alarms,label="WASTE HIGH",min_width=15,box_fg_bg=c_yel_gray,callback=ttest.test_highwaste} + alarm_btns[10] = Checkbox{parent=alarms,label="RPS TRANS",min_width=15,box_fg_bg=c_yel_gray,callback=ttest.test_rps} + alarm_btns[11] = Checkbox{parent=alarms,label="RCS TRANS",min_width=15,box_fg_bg=c_yel_gray,callback=ttest.test_rcs} + alarm_btns[12] = Checkbox{parent=alarms,label="TURBINE TRP",min_width=15,box_fg_bg=c_red_gray,callback=ttest.test_turbinet} + + ttest.alarm_buttons = alarm_btns + + local function stop_all_alarms() + for i = 1, #alarm_btns do alarm_btns[i].set_value(false) end + ttest.stop_alarms() + end + + PushButton{parent=alarms,x=3,y=15,text="STOP \x13",min_width=8,fg_bg=cpair(colors.black,colors.red),active_fg_bg=c_wht_gray,callback=stop_all_alarms} + + local states = Div{parent=audio,x=2,y=14,height=5,width=8} + + TextBox{parent=states,text="States",height=1,alignment=TEXT_ALIGN.CENTER} + local t_1 = IndicatorLight{parent=states,label="1",colors=c_blue_gray} + local t_2 = IndicatorLight{parent=states,label="2",colors=c_blue_gray} + local t_3 = IndicatorLight{parent=states,label="3",colors=c_blue_gray} + local t_4 = IndicatorLight{parent=states,label="4",colors=c_blue_gray} + local t_5 = IndicatorLight{parent=states,x=6,y=2,label="5",colors=c_blue_gray} + local t_6 = IndicatorLight{parent=states,x=6,label="6",colors=c_blue_gray} + local t_7 = IndicatorLight{parent=states,x=6,label="7",colors=c_blue_gray} + local t_8 = IndicatorLight{parent=states,x=6,label="8",colors=c_blue_gray} + + ttest.tone_indicators = { t_1, t_2, t_3, t_4, t_5, t_6, t_7, t_8 } + + -------------- + -- App List -- + -------------- + + App{parent=diag_home,x=3,y=4,text="\x0f",title="Alarm",callback=navigate_alarm,app_fg_bg=cpair(colors.black,colors.red),active_fg_bg=cpair(colors.white,colors.gray)} + App{parent=diag_home,x=10,y=4,text="\x1e",title="LoopT",callback=function()end,app_fg_bg=cpair(colors.black,colors.cyan)} + App{parent=diag_home,x=17,y=4,text="@",title="Comps",callback=function()end,app_fg_bg=cpair(colors.black,colors.orange)} + + return main +end + +return new_view diff --git a/pocket/ui/pages/home_page.lua b/pocket/ui/pages/home_page.lua index a31cae8..d192796 100644 --- a/pocket/ui/pages/home_page.lua +++ b/pocket/ui/pages/home_page.lua @@ -1,20 +1,21 @@ --- local style = require("pocket.ui.style") +local core = require("graphics.core") -local core = require("graphics.core") +local Div = require("graphics.elements.div") -local Div = require("graphics.elements.div") -local TextBox = require("graphics.elements.textbox") +local App = require("graphics.elements.controls.app") --- local cpair = core.cpair - -local TEXT_ALIGN = core.TEXT_ALIGN +local cpair = core.cpair -- new home page view ---@param root graphics_element parent local function new_view(root) local main = Div{parent=root,x=1,y=1} - TextBox{parent=main,text="HOME",x=1,y=1,height=1,alignment=TEXT_ALIGN.CENTER} + App{parent=main,x=3,y=2,text="\x17",title="PRC",callback=function()end,app_fg_bg=cpair(colors.black,colors.purple)} + App{parent=main,x=10,y=2,text="\x15",title="CTL",callback=function()end,app_fg_bg=cpair(colors.black,colors.green)} + App{parent=main,x=17,y=2,text="\x08",title="DEV",callback=function()end,app_fg_bg=cpair(colors.black,colors.lightGray)} + App{parent=main,x=3,y=7,text="\x7f",title="Waste",callback=function()end,app_fg_bg=cpair(colors.black,colors.brown)} + App{parent=main,x=10,y=7,text="\xb6",title="Guide",callback=function()end,app_fg_bg=cpair(colors.black,colors.cyan)} return main end diff --git a/reactor-plc/startup.lua b/reactor-plc/startup.lua index 0c8022f..66435d9 100644 --- a/reactor-plc/startup.lua +++ b/reactor-plc/startup.lua @@ -19,7 +19,7 @@ local plc = require("reactor-plc.plc") local renderer = require("reactor-plc.renderer") local threads = require("reactor-plc.threads") -local R_PLC_VERSION = "v1.5.5" +local R_PLC_VERSION = "v1.5.6" local println = util.println local println_ts = util.println_ts diff --git a/reactor-plc/threads.lua b/reactor-plc/threads.lua index 10634ef..12ae75a 100644 --- a/reactor-plc/threads.lua +++ b/reactor-plc/threads.lua @@ -165,7 +165,7 @@ function threads.thread__main(smem, init) local type, device = ppm.handle_unmount(param1) if type ~= nil and device ~= nil then - if type == "fissionReactorLogicAdapter" then + if device == plc_dev.reactor then println_ts("reactor disconnected!") log.error("reactor logic adapter disconnected") @@ -205,7 +205,7 @@ function threads.thread__main(smem, init) local type, device = ppm.mount(param1) if type ~= nil and device ~= nil then - if type == "fissionReactorLogicAdapter" then + if plc_state.no_reactor and (type == "fissionReactorLogicAdapter") then -- reconnected reactor plc_dev.reactor = device plc_state.no_reactor = false diff --git a/rtu/config.lua b/rtu/config.lua index a0d3e68..98ca5df 100644 --- a/rtu/config.lua +++ b/rtu/config.lua @@ -15,6 +15,10 @@ config.COMMS_TIMEOUT = 5 -- all devices on the same network must use the same key -- config.AUTH_KEY = "SCADAfacility123" +-- alarm sounder volume (0.0 to 3.0, 1.0 being standard max volume, this is the option given to to speaker.play()) +-- note: alarm sine waves are at half saturation, so that multiple will be required to reach full scale +config.SOUNDER_VOLUME = 1.0 + -- log path config.LOG_PATH = "/log.txt" -- log mode diff --git a/rtu/databus.lua b/rtu/databus.lua index 3014367..4fe183a 100644 --- a/rtu/databus.lua +++ b/rtu/databus.lua @@ -37,6 +37,12 @@ function databus.tx_hw_modem(has_modem) databus.ps.publish("has_modem", has_modem) end +-- transmit the number of speakers connected +---@param count integer +function databus.tx_hw_spkr_count(count) + databus.ps.publish("speaker_count", count) +end + -- transmit unit hardware type across the bus ---@param uid integer unit ID ---@param type RTU_UNIT_TYPE diff --git a/rtu/panel/front_panel.lua b/rtu/panel/front_panel.lua index dc0dd44..ad6f7a0 100644 --- a/rtu/panel/front_panel.lua +++ b/rtu/panel/front_panel.lua @@ -2,36 +2,27 @@ -- RTU Front Panel GUI -- -local types = require("scada-common.types") -local util = require("scada-common.util") +local types = require("scada-common.types") +local util = require("scada-common.util") -local databus = require("rtu.databus") +local databus = require("rtu.databus") -local style = require("rtu.panel.style") +local style = require("rtu.panel.style") -local core = require("graphics.core") +local core = require("graphics.core") -local Div = require("graphics.elements.div") -local TextBox = require("graphics.elements.textbox") +local Div = require("graphics.elements.div") +local TextBox = require("graphics.elements.textbox") -local LED = require("graphics.elements.indicators.led") -local RGBLED = require("graphics.elements.indicators.ledrgb") +local DataIndicator = require("graphics.elements.indicators.data") +local LED = require("graphics.elements.indicators.led") +local RGBLED = require("graphics.elements.indicators.ledrgb") local TEXT_ALIGN = core.TEXT_ALIGN local cpair = core.cpair -local UNIT_TYPE_LABELS = { - "UNKNOWN", - "REDSTONE", - "BOILER", - "TURBINE", - "DYNAMIC TANK", - "IND MATRIX", - "SPS", - "SNA", - "ENV DETECTOR" -} +local UNIT_TYPE_LABELS = { "UNKNOWN", "REDSTONE", "BOILER", "TURBINE", "DYNAMIC TANK", "IND MATRIX", "SPS", "SNA", "ENV DETECTOR" } -- create new front panel view @@ -72,6 +63,10 @@ local function init(panel, units) local comp_id = util.sprintf("(%d)", os.getComputerID()) TextBox{parent=system,x=9,y=4,width=6,height=1,text=comp_id,fg_bg=cpair(colors.lightGray,colors.ivory)} + TextBox{parent=system,x=1,y=14,text="SPEAKERS",height=1,width=8,fg_bg=style.label} + local speaker_count = DataIndicator{parent=system,x=10,y=14,label="",format="%3d",value=0,width=3,fg_bg=cpair(colors.gray,colors.white)} + speaker_count.register(databus.ps, "speaker_count", speaker_count.update) + -- -- about label -- diff --git a/rtu/rtu.lua b/rtu/rtu.lua index 3832986..4c92afc 100644 --- a/rtu/rtu.lua +++ b/rtu/rtu.lua @@ -1,9 +1,11 @@ +local audio = require("scada-common.audio") local comms = require("scada-common.comms") local ppm = require("scada-common.ppm") local log = require("scada-common.log") local types = require("scada-common.types") local util = require("scada-common.util") +local config = require("rtu.config") local databus = require("rtu.databus") local modbus = require("rtu.modbus") @@ -155,6 +157,48 @@ function rtu.init_unit() return protected end +-- create an alarm speaker sounder +---@param speaker table device peripheral +function rtu.init_sounder(speaker) + ---@class rtu_speaker_sounder + local spkr_ctl = { + speaker = speaker, + name = ppm.get_iface(speaker), + playing = false, + stream = audio.new_stream(), + play = function () end, + stop = function () end, + continue = function () end + } + + -- continue audio stream if playing + function spkr_ctl.continue() + if spkr_ctl.playing then + if spkr_ctl.speaker ~= nil and spkr_ctl.stream.has_next_block() then + local success = spkr_ctl.speaker.playAudio(spkr_ctl.stream.get_next_block(), config.SOUNDER_VOLUME) + if not success then log.error(util.c("rtu_sounder(", spkr_ctl.name, "): error playing audio")) end + end + end + end + + -- start audio stream playback + function spkr_ctl.play() + if not spkr_ctl.playing then + spkr_ctl.playing = true + return spkr_ctl.continue() + end + end + + -- stop audio stream playback + function spkr_ctl.stop() + spkr_ctl.playing = false + spkr_ctl.speaker.stop() + spkr_ctl.stream.stop() + end + + return spkr_ctl +end + -- RTU Communications ---@nodiscard ---@param version string RTU version @@ -312,7 +356,8 @@ function rtu.comms(version, nic, rtu_channel, svr_channel, range, conn_watchdog) ---@param packet modbus_frame|mgmt_frame ---@param units table RTU units ---@param rtu_state rtu_state - function public.handle_packet(packet, units, rtu_state) + ---@param sounders table speaker alarm sounders + function public.handle_packet(packet, units, rtu_state, sounders) -- print a log message to the terminal as long as the UI isn't running local function println_ts(message) if not rtu_state.fp_ok then util.println_ts(message) end end @@ -387,7 +432,49 @@ function rtu.comms(version, nic, rtu_channel, svr_channel, range, conn_watchdog) elseif protocol == PROTOCOL.SCADA_MGMT then ---@cast packet mgmt_frame -- SCADA management packet - if packet.type == SCADA_MGMT_TYPE.ESTABLISH then + if rtu_state.linked then + if packet.type == SCADA_MGMT_TYPE.KEEP_ALIVE then + -- keep alive request received, echo back + if packet.length == 1 and type(packet.data[1]) == "number" then + local timestamp = packet.data[1] + local trip_time = util.time() - timestamp + + if trip_time > 750 then + log.warning("RTU KEEP_ALIVE trip time > 750ms (" .. trip_time .. "ms)") + end + + -- log.debug("RTU RTT = " .. trip_time .. "ms") + + _send_keep_alive_ack(timestamp) + else + log.debug("SCADA_MGMT keep alive packet length/type mismatch") + end + elseif packet.type == SCADA_MGMT_TYPE.CLOSE then + -- close connection + conn_watchdog.cancel() + public.unlink(rtu_state) + println_ts("server connection closed by remote host") + log.warning("server connection closed by remote host") + elseif packet.type == SCADA_MGMT_TYPE.RTU_ADVERT then + -- request for capabilities again + public.send_advertisement(units) + elseif packet.type == SCADA_MGMT_TYPE.RTU_TONE_ALARM then + -- alarm tone update from supervisor + if (packet.length == 1) and type(packet.data[1] == "table") and (#packet.data[1] == 8) then + local states = packet.data[1] + + for i = 1, #sounders do + local s = sounders[i] ---@type rtu_speaker_sounder + + -- set tone states + for id = 1, #states do s.stream.set_active(id, states[id] == true) end + end + end + else + -- not supported + log.debug("received unsupported SCADA_MGMT message type " .. packet.type) + end + elseif packet.type == SCADA_MGMT_TYPE.ESTABLISH then if packet.length == 1 then local est_ack = packet.data[1] @@ -421,36 +508,6 @@ function rtu.comms(version, nic, rtu_channel, svr_channel, range, conn_watchdog) else log.debug("SCADA_MGMT establish packet length mismatch") end - elseif rtu_state.linked then - if packet.type == SCADA_MGMT_TYPE.KEEP_ALIVE then - -- keep alive request received, echo back - if packet.length == 1 and type(packet.data[1]) == "number" then - local timestamp = packet.data[1] - local trip_time = util.time() - timestamp - - if trip_time > 750 then - log.warning("RTU KEEP_ALIVE trip time > 750ms (" .. trip_time .. "ms)") - end - - -- log.debug("RTU RTT = " .. trip_time .. "ms") - - _send_keep_alive_ack(timestamp) - else - log.debug("SCADA_MGMT keep alive packet length/type mismatch") - end - elseif packet.type == SCADA_MGMT_TYPE.CLOSE then - -- close connection - conn_watchdog.cancel() - public.unlink(rtu_state) - println_ts("server connection closed by remote host") - log.warning("server connection closed by remote host") - elseif packet.type == SCADA_MGMT_TYPE.RTU_ADVERT then - -- request for capabilities again - public.send_advertisement(units) - else - -- not supported - log.debug("received unsupported SCADA_MGMT message type " .. packet.type) - end else log.debug("discarding non-link SCADA_MGMT packet before linked") end diff --git a/rtu/startup.lua b/rtu/startup.lua index dd1c27f..acec142 100644 --- a/rtu/startup.lua +++ b/rtu/startup.lua @@ -4,6 +4,7 @@ require("/initenv").init_env() +local audio = require("scada-common.audio") local comms = require("scada-common.comms") local crash = require("scada-common.crash") local log = require("scada-common.log") @@ -30,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.5.5" +local RTU_VERSION = "v1.6.0" local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE local RTU_UNIT_HW_STATE = databus.RTU_UNIT_HW_STATE @@ -96,6 +97,9 @@ local function main() return end + -- generate alarm tones + audio.generate_tones() + ---@class rtu_shared_memory local __shared_memory = { -- RTU system state flags @@ -106,6 +110,11 @@ local function main() shutdown = false }, + -- RTU gateway devices (not RTU units) + rtu_dev = { + sounders = {} + }, + -- system objects rtu_sys = { nic = network.nic(modem), @@ -481,6 +490,18 @@ local function main() log.info("startup> running in headless mode without front panel") end + -- find and setup all speakers + local speakers = ppm.get_all_devices("speaker") + for _, s in pairs(speakers) do + local sounder = rtu.init_sounder(s) + + table.insert(__shared_memory.rtu_dev.sounders, sounder) + + log.debug(util.c("startup> added speaker, attached as ", sounder.name)) + end + + databus.tx_hw_spkr_count(#__shared_memory.rtu_dev.sounders) + -- start connection watchdog smem_sys.conn_watchdog = util.new_watchdog(config.COMMS_TIMEOUT) log.debug("startup> conn watchdog started") diff --git a/rtu/threads.lua b/rtu/threads.lua index 904f37b..ee8e270 100644 --- a/rtu/threads.lua +++ b/rtu/threads.lua @@ -8,6 +8,7 @@ local util = require("scada-common.util") local databus = require("rtu.databus") local modbus = require("rtu.modbus") local renderer = require("rtu.renderer") +local rtu = require("rtu.rtu") local boilerv_rtu = require("rtu.dev.boilerv_rtu") local dynamicv_rtu = require("rtu.dev.dynamicv_rtu") @@ -24,7 +25,7 @@ local threads = {} local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE local UNIT_HW_STATE = databus.RTU_UNIT_HW_STATE -local MAIN_CLOCK = 2 -- (2Hz, 40 ticks) +local MAIN_CLOCK = 0.5 -- (2Hz, 10 ticks) local COMMS_SLEEP = 100 -- (100ms, 2 ticks) -- main thread @@ -47,6 +48,7 @@ function threads.thread__main(smem) -- load in from shared memory local rtu_state = smem.rtu_state + local sounders = smem.rtu_dev.sounders local nic = smem.rtu_sys.nic local rtu_comms = smem.rtu_sys.rtu_comms local conn_watchdog = smem.rtu_sys.conn_watchdog @@ -66,6 +68,15 @@ function threads.thread__main(smem) -- blink heartbeat indicator databus.heartbeat() + -- update speaker states + for _, sounder in pairs(sounders) do + -- re-compute output if needed, then play audio if available + if sounder.stream.is_recompute_needed() then + sounder.stream.compute_buffer() + if sounder.stream.has_next_block() then sounder.play() else sounder.stop() end + end + end + -- start next clock timer loop_clock.start() @@ -110,6 +121,18 @@ function threads.thread__main(smem) else log.warning("non-comms modem disconnected") end + elseif type == "speaker" then + for i = 1, #sounders do + if sounders[i].speaker == device then + table.remove(sounders, i) + + log.warning(util.c("speaker ", param1, " disconnected")) + println_ts("speaker disconnected") + + databus.tx_hw_spkr_count(#sounders) + break + end + end else for i = 1, #units do -- find disconnected device @@ -147,6 +170,13 @@ function threads.thread__main(smem) else log.info("wired modem reconnected") end + elseif type == "speaker" then + table.insert(sounders, rtu.init_sounder(device)) + + println_ts("speaker connected") + log.info(util.c("connected speaker ", param1)) + + databus.tx_hw_spkr_count(#sounders) else -- relink lost peripheral to correct unit entry for i = 1, #units do @@ -252,6 +282,15 @@ function threads.thread__main(smem) elseif event == "mouse_click" or event == "mouse_up" or event == "mouse_drag" or event == "mouse_scroll" then -- handle a mouse event renderer.handle_mouse(core.events.new_mouse_event(event, param1, param2, param3)) + elseif event == "speaker_audio_empty" then + -- handle empty speaker audio buffer + for i = 1, #sounders do + local sounder = sounders[i] ---@type rtu_speaker_sounder + if sounder.name == param1 then + sounder.continue() + break + end + end end -- check for termination request @@ -299,6 +338,7 @@ function threads.thread__comms(smem) -- load in from shared memory local rtu_state = smem.rtu_state + local sounders = smem.rtu_dev.sounders local rtu_comms = smem.rtu_sys.rtu_comms local units = smem.rtu_sys.units @@ -321,8 +361,8 @@ function threads.thread__comms(smem) -- received data elseif msg.qtype == mqueue.TYPE.PACKET then -- received a packet - -- handle the packet (rtu_state passed to allow setting link flag) - rtu_comms.handle_packet(msg.message, units, rtu_state) + -- handle the packet (rtu_state passed to allow setting link flag, sounders passed to manage alarm audio) + rtu_comms.handle_packet(msg.message, units, rtu_state, sounders) end end diff --git a/scada-common/audio.lua b/scada-common/audio.lua new file mode 100644 index 0000000..32dfb57 --- /dev/null +++ b/scada-common/audio.lua @@ -0,0 +1,313 @@ +-- +-- Audio & Tone Control for Alarms +-- + +-- sounds modeled after https://www.e2s.com/references-and-guidelines/listen-and-download-alarm-tones + +-- note: max samples = 0x20000 (128 * 1024 samples) + +local _2_PI = 2 * math.pi -- 2 whole pies, hope you're hungry +local _DRATE = 48000 -- 48kHz audio +local _MAX_VAL = 127 / 2 -- max signed integer in this 8-bit audio +local _05s_SAMPLES = 24000 -- half a second worth of samples + +---@class audio +local audio = {} + +---@enum TONE +local TONE = { + T_340Hz_Int_2Hz = 1, + T_544Hz_440Hz_Alt = 2, + T_660Hz_Int_125ms = 3, + T_745Hz_Int_1Hz = 4, + T_800Hz_Int = 5, + T_800Hz_1000Hz_Alt = 6, + T_1000Hz_Int = 7, + T_1800Hz_Int_4Hz = 8 +} + +audio.TONE = TONE + +local tone_data = { + { {}, {}, {}, {} }, -- 340Hz @ 2Hz Intermittent + { {}, {}, {}, {} }, -- 544Hz 100mS / 440Hz 400mS Alternating + { {}, {}, {}, {} }, -- 660Hz @ 125ms On 125ms Off + { {}, {}, {}, {} }, -- 745Hz @ 1Hz Intermittent + { {}, {}, {}, {} }, -- 800Hz @ 0.25s On 1.75s Off + { {}, {}, {}, {} }, -- 800/1000Hz @ 0.25s Alternating + { {}, {}, {}, {} }, -- 1KHz 1s on, 1s off Intermittent + { {}, {}, {}, {} } -- 1.8KHz @ 4Hz Intermittent +} + +-- calculate how many samples are in the given number of milliseconds +---@nodiscard +---@param ms integer milliseconds +---@return integer samples +local function ms_to_samples(ms) return math.floor(ms * 48) end + +--#region Tone Generation (the Maths) + +-- 340Hz @ 2Hz Intermittent +local function gen_tone_1() + local t, dt = 0, _2_PI * 340 / _DRATE + + for i = 1, _05s_SAMPLES do + local val = math.floor(math.sin(t) * _MAX_VAL) + tone_data[1][1][i] = val + tone_data[1][3][i] = val + tone_data[1][2][i] = 0 + tone_data[1][4][i] = 0 + t = (t + dt) % _2_PI + end +end + +-- 544Hz 100mS / 440Hz 400mS Alternating +local function gen_tone_2() + local t1, dt1 = 0, _2_PI * 544 / _DRATE + local t2, dt2 = 0, _2_PI * 440 / _DRATE + local alternate_at = ms_to_samples(100) + + for i = 1, _05s_SAMPLES do + local value + + if i <= alternate_at then + value = math.floor(math.sin(t1) * _MAX_VAL) + t1 = (t1 + dt1) % _2_PI + else + value = math.floor(math.sin(t2) * _MAX_VAL) + t2 = (t2 + dt2) % _2_PI + end + + tone_data[2][1][i] = value + tone_data[2][2][i] = value + tone_data[2][3][i] = value + tone_data[2][4][i] = value + end +end + +-- 660Hz @ 125ms On 125ms Off +local function gen_tone_3() + local elapsed_samples = 0 + local alternate_after = ms_to_samples(125) + local alternate_at = alternate_after + local mode = true + + local t, dt = 0, _2_PI * 660 / _DRATE + + for set = 1, 4 do + for i = 1, _05s_SAMPLES do + if mode then + local val = math.floor(math.sin(t) * _MAX_VAL) + tone_data[3][set][i] = val + t = (t + dt) % _2_PI + else + t = 0 + tone_data[3][set][i] = 0 + end + + if elapsed_samples == alternate_at then + mode = not mode + alternate_at = elapsed_samples + alternate_after + end + + elapsed_samples = elapsed_samples + 1 + end + end +end + +-- 745Hz @ 1Hz Intermittent +local function gen_tone_4() + local t, dt = 0, _2_PI * 745 / _DRATE + + for i = 1, _05s_SAMPLES do + local val = math.floor(math.sin(t) * _MAX_VAL) + tone_data[4][1][i] = val + tone_data[4][3][i] = val + tone_data[4][2][i] = 0 + tone_data[4][4][i] = 0 + t = (t + dt) % _2_PI + end +end + +-- 800Hz @ 0.25s On 1.75s Off +local function gen_tone_5() + local t, dt = 0, _2_PI * 800 / _DRATE + local stop_at = ms_to_samples(250) + + for i = 1, _05s_SAMPLES do + local val = math.floor(math.sin(t) * _MAX_VAL) + + if i > stop_at then + tone_data[5][1][i] = val + else + tone_data[5][1][i] = 0 + end + + tone_data[5][2][i] = 0 + tone_data[5][3][i] = 0 + tone_data[5][4][i] = 0 + + t = (t + dt) % _2_PI + end +end + +-- 1000/800Hz @ 0.25s Alternating +local function gen_tone_6() + local t1, dt1 = 0, _2_PI * 1000 / _DRATE + local t2, dt2 = 0, _2_PI * 800 / _DRATE + + local alternate_at = ms_to_samples(250) + + for i = 1, _05s_SAMPLES do + local val + if i <= alternate_at then + val = math.floor(math.sin(t1) * _MAX_VAL) + t1 = (t1 + dt1) % _2_PI + else + val = math.floor(math.sin(t2) * _MAX_VAL) + t2 = (t2 + dt2) % _2_PI + end + + tone_data[6][1][i] = val + tone_data[6][2][i] = val + tone_data[6][3][i] = val + tone_data[6][4][i] = val + end +end + +-- 1KHz 1s on, 1s off Intermittent +local function gen_tone_7() + local t, dt = 0, _2_PI * 1000 / _DRATE + + for i = 1, _05s_SAMPLES do + local val = math.floor(math.sin(t) * _MAX_VAL) + tone_data[7][1][i] = val + tone_data[7][2][i] = val + tone_data[7][3][i] = 0 + tone_data[7][4][i] = 0 + t = (t + dt) % _2_PI + end +end + +-- 1800Hz @ 4Hz Intermittent +local function gen_tone_8() + local t, dt = 0, _2_PI * 1800 / _DRATE + + local off_at = ms_to_samples(250) + + for i = 1, _05s_SAMPLES do + local val = 0 + + if i <= off_at then + val = math.floor(math.sin(t) * _MAX_VAL) + t = (t + dt) % _2_PI + end + + tone_data[8][1][i] = val + tone_data[8][2][i] = val + tone_data[8][3][i] = val + tone_data[8][4][i] = val + end +end + +--#endregion + +-- generate all 8 tone sequences +function audio.generate_tones() + gen_tone_1(); gen_tone_2(); gen_tone_3(); gen_tone_4(); gen_tone_5(); gen_tone_6(); gen_tone_7(); gen_tone_8() +end + +-- hard audio limiter +---@nodiscard +---@param output number output level +---@return number limited -128.0 to 127.0 +local function limit(output) + return math.max(-128, math.min(127, output)) +end + +-- clear output buffer +---@param buffer table quad buffer +local function clear(buffer) + for i = 1, 4 do + for s = 1, _05s_SAMPLES do buffer[i][s] = 0 end + end +end + +-- create a new audio tone stream controller +function audio.new_stream() + local self = { + any_active = false, + need_recompute = false, + next_block = 1, + -- split audio up into 0.5s samples, so specific components can be ended quicker + quad_buffer = { {}, {}, {}, {} }, + -- all tone enable states + tone_active = { false, false, false, false, false, false, false, false } + } + + clear(self.quad_buffer) + + ---@class tone_stream + local public = {} + + -- add a tone to the output buffer + ---@param index TONE tone ID + ---@param active boolean active state + function public.set_active(index, active) + if self.tone_active[index] ~= nil then + if self.tone_active[index] ~= active then self.need_recompute = true end + self.tone_active[index] = active + end + end + + -- check if a tone is active + ---@param index TONE tone index + function public.is_active(index) + if self.tone_active[index] then return self.tone_active[index] end + return false + end + + -- set all tones inactive, reset next block, and clear output buffer + function public.stop() + for i = 1, #self.tone_active do self.tone_active[i] = false end + self.next_block = 1 + clear(self.quad_buffer) + end + + -- check if the output buffer needs to be recomputed due to changes + function public.is_recompute_needed() return self.need_recompute end + + -- re-compute the output buffer + function public.compute_buffer() + clear(self.quad_buffer) + + self.need_recompute = false + self.any_active = false + + for id = 1, #tone_data do + if self.tone_active[id] then + self.any_active = true + for i = 1, 4 do + local buffer = self.quad_buffer[i] + local values = tone_data[id][i] + for s = 1, _05s_SAMPLES do self.quad_buffer[i][s] = limit(buffer[s] + values[s]) end + end + end + end + end + + -- check if the next audio block has data + function public.has_next_block() return #self.quad_buffer[self.next_block] > 0 end + + -- get the next audio block + function public.get_next_block() + local block = self.quad_buffer[self.next_block] + self.next_block = self.next_block + 1 + if self.next_block > 4 then self.next_block = 1 end + return block + end + + return public +end + +return audio diff --git a/scada-common/comms.lua b/scada-common/comms.lua index 6a8324d..af049d7 100644 --- a/scada-common/comms.lua +++ b/scada-common/comms.lua @@ -14,50 +14,54 @@ local max_distance = nil ---@type number|nil maximum acceptable t ---@class comms local comms = {} -comms.version = "2.1.2" +comms.version = "2.2.1" ---@enum PROTOCOL local PROTOCOL = { - MODBUS_TCP = 0, -- our "MODBUS TCP"-esque protocol - RPLC = 1, -- reactor PLC protocol - SCADA_MGMT = 2, -- SCADA supervisor management, device advertisements, etc - SCADA_CRDN = 3, -- data/control packets for coordinators to/from supervisory controllers - COORD_API = 4 -- data/control packets for pocket computers to/from coordinators + MODBUS_TCP = 0, -- our "MODBUS TCP"-esque protocol + RPLC = 1, -- reactor PLC protocol + SCADA_MGMT = 2, -- SCADA supervisor management, device advertisements, etc + SCADA_CRDN = 3, -- data/control packets for coordinators to/from supervisory controllers + COORD_API = 4 -- data/control packets for pocket computers to/from coordinators } ---@enum RPLC_TYPE local RPLC_TYPE = { - STATUS = 0, -- reactor/system status - MEK_STRUCT = 1, -- mekanism build structure - MEK_BURN_RATE = 2, -- set burn rate - RPS_ENABLE = 3, -- enable reactor - RPS_SCRAM = 4, -- SCRAM reactor (manual request) - RPS_ASCRAM = 5, -- SCRAM reactor (automatic request) - RPS_STATUS = 6, -- RPS status - RPS_ALARM = 7, -- RPS alarm broadcast - RPS_RESET = 8, -- clear RPS trip (if in bad state, will trip immediately) - RPS_AUTO_RESET = 9, -- clear RPS trip if it is just a timeout or auto scram - AUTO_BURN_RATE = 10 -- set an automatic burn rate, PLC will respond with status, enable toggle speed limited + STATUS = 0, -- reactor/system status + MEK_STRUCT = 1, -- mekanism build structure + MEK_BURN_RATE = 2, -- set burn rate + RPS_ENABLE = 3, -- enable reactor + RPS_SCRAM = 4, -- SCRAM reactor (manual request) + RPS_ASCRAM = 5, -- SCRAM reactor (automatic request) + RPS_STATUS = 6, -- RPS status + RPS_ALARM = 7, -- RPS alarm broadcast + RPS_RESET = 8, -- clear RPS trip (if in bad state, will trip immediately) + RPS_AUTO_RESET = 9, -- clear RPS trip if it is just a timeout or auto scram + AUTO_BURN_RATE = 10 -- set an automatic burn rate, PLC will respond with status, enable toggle speed limited } ---@enum SCADA_MGMT_TYPE local SCADA_MGMT_TYPE = { - ESTABLISH = 0, -- establish new connection - KEEP_ALIVE = 1, -- keep alive packet w/ RTT - CLOSE = 2, -- close a connection - RTU_ADVERT = 3, -- RTU capability advertisement - RTU_DEV_REMOUNT = 4 -- RTU multiblock possbily changed (formed, unformed) due to PPM remount + ESTABLISH = 0, -- establish new connection + KEEP_ALIVE = 1, -- keep alive packet w/ RTT + CLOSE = 2, -- close a connection + RTU_ADVERT = 3, -- RTU capability advertisement + RTU_DEV_REMOUNT = 4, -- RTU multiblock possbily changed (formed, unformed) due to PPM remount + RTU_TONE_ALARM = 5, -- instruct RTUs to play specified alarm tones + DIAG_TONE_GET = 6, -- diagnostic: get alarm tones + DIAG_TONE_SET = 7, -- diagnostic: set alarm tones + DIAG_ALARM_SET = 8 -- diagnostic: set alarm to simulate audio for } ---@enum SCADA_CRDN_TYPE local SCADA_CRDN_TYPE = { - INITIAL_BUILDS = 0, -- initial, complete builds packet to the coordinator - FAC_BUILDS = 1, -- facility RTU builds - FAC_STATUS = 2, -- state of facility and facility devices - FAC_CMD = 3, -- faility command - UNIT_BUILDS = 4, -- build of each reactor unit (reactor + RTUs) - UNIT_STATUSES = 5, -- state of each of the reactor units - UNIT_CMD = 6 -- command a reactor unit + INITIAL_BUILDS = 0, -- initial, complete builds packet to the coordinator + FAC_BUILDS = 1, -- facility RTU builds + FAC_STATUS = 2, -- state of facility and facility devices + FAC_CMD = 3, -- faility command + UNIT_BUILDS = 4, -- build of each reactor unit (reactor + RTUs) + UNIT_STATUSES = 5, -- state of each of the reactor units + UNIT_CMD = 6 -- command a reactor unit } ---@enum CAPI_TYPE @@ -66,50 +70,50 @@ local CAPI_TYPE = { ---@enum ESTABLISH_ACK local ESTABLISH_ACK = { - ALLOW = 0, -- link approved - DENY = 1, -- link denied - COLLISION = 2, -- link denied due to existing active link - BAD_VERSION = 3 -- link denied due to comms version mismatch + ALLOW = 0, -- link approved + DENY = 1, -- link denied + COLLISION = 2, -- link denied due to existing active link + BAD_VERSION = 3 -- link denied due to comms version mismatch } ---@enum DEVICE_TYPE local DEVICE_TYPE = { - PLC = 0, -- PLC device type for establish - RTU = 1, -- RTU device type for establish - SV = 2, -- supervisor device type for establish - CRDN = 3, -- coordinator device type for establish - PKT = 4 -- pocket device type for establish + PLC = 0, -- PLC device type for establish + RTU = 1, -- RTU device type for establish + SV = 2, -- supervisor device type for establish + CRDN = 3, -- coordinator device type for establish + PKT = 4 -- pocket device type for establish } ---@enum PLC_AUTO_ACK local PLC_AUTO_ACK = { - FAIL = 0, -- failed to set burn rate/burn rate invalid - DIRECT_SET_OK = 1, -- successfully set burn rate - RAMP_SET_OK = 2, -- successfully started burn rate ramping - ZERO_DIS_OK = 3 -- successfully disabled reactor with < 0.01 burn rate + FAIL = 0, -- failed to set burn rate/burn rate invalid + DIRECT_SET_OK = 1, -- successfully set burn rate + RAMP_SET_OK = 2, -- successfully started burn rate ramping + ZERO_DIS_OK = 3 -- successfully disabled reactor with < 0.01 burn rate } ---@enum FAC_COMMAND local FAC_COMMAND = { - SCRAM_ALL = 0, -- SCRAM all reactors - STOP = 1, -- stop automatic process control - START = 2, -- start automatic process control - ACK_ALL_ALARMS = 3, -- acknowledge all alarms on all units - SET_WASTE_MODE = 4, -- set automatic waste processing mode - SET_PU_FB = 5 -- set plutonium fallback mode + SCRAM_ALL = 0, -- SCRAM all reactors + STOP = 1, -- stop automatic process control + START = 2, -- start automatic process control + ACK_ALL_ALARMS = 3, -- acknowledge all alarms on all units + SET_WASTE_MODE = 4, -- set automatic waste processing mode + SET_PU_FB = 5 -- set plutonium fallback mode } ---@enum UNIT_COMMAND local UNIT_COMMAND = { - SCRAM = 0, -- SCRAM the reactor - START = 1, -- start the reactor - RESET_RPS = 2, -- reset the RPS - SET_BURN = 3, -- set the burn rate - SET_WASTE = 4, -- set the waste processing mode - ACK_ALL_ALARMS = 5, -- ack all active alarms - ACK_ALARM = 6, -- ack a particular alarm - RESET_ALARM = 7, -- reset a particular alarm - SET_GROUP = 8 -- assign this unit to a group + SCRAM = 0, -- SCRAM the reactor + START = 1, -- start the reactor + RESET_RPS = 2, -- reset the RPS + SET_BURN = 3, -- set the burn rate + SET_WASTE = 4, -- set the waste processing mode + ACK_ALL_ALARMS = 5, -- ack all active alarms + ACK_ALARM = 6, -- ack a particular alarm + RESET_ALARM = 7, -- reset a particular alarm + SET_GROUP = 8 -- assign this unit to a group } comms.PROTOCOL = PROTOCOL @@ -146,6 +150,7 @@ function comms.scada_packet() local self = { modem_msg_in = nil, ---@type modem_message|nil valid = false, + authenticated = false, raw = {}, src_addr = comms.BROADCAST, dest_addr = comms.BROADCAST, @@ -234,6 +239,9 @@ function comms.scada_packet() return self.valid end + -- report that this packet has been authenticated (was received with a valid HMAC) + function public.stamp_authenticated() self.authenticated = true end + -- public accessors -- ---@nodiscard @@ -248,6 +256,8 @@ function comms.scada_packet() ---@nodiscard function public.is_valid() return self.valid end + ---@nodiscard + function public.is_authenticated() return self.authenticated end ---@nodiscard function public.src_addr() return self.src_addr end @@ -313,7 +323,7 @@ function comms.authd_packet() self.valid = false self.raw = self.modem_msg_in.msg - if (type(max_distance) == "number") and (distance > max_distance) then + if (type(max_distance) == "number") and (type(distance) == "number") and (distance > max_distance) then -- outside of maximum allowable transmission distance -- log.debug("comms.authd_packet.receive(): discarding packet with distance " .. distance .. " outside of trusted range") else @@ -588,7 +598,11 @@ function comms.mgmt_packet() self.type == SCADA_MGMT_TYPE.CLOSE or self.type == SCADA_MGMT_TYPE.REMOTE_LINKED or self.type == SCADA_MGMT_TYPE.RTU_ADVERT or - self.type == SCADA_MGMT_TYPE.RTU_DEV_REMOUNT + self.type == SCADA_MGMT_TYPE.RTU_DEV_REMOUNT or + self.type == SCADA_MGMT_TYPE.RTU_TONE_ALARM or + self.type == SCADA_MGMT_TYPE.DIAG_TONE_GET or + self.type == SCADA_MGMT_TYPE.DIAG_TONE_SET or + self.type == SCADA_MGMT_TYPE.DIAG_ALARM_SET end -- make a SCADA management packet diff --git a/scada-common/constants.lua b/scada-common/constants.lua index 7c661ee..eaf6dd3 100644 --- a/scada-common/constants.lua +++ b/scada-common/constants.lua @@ -2,10 +2,12 @@ -- System and Safety Constants -- +---@class scada_constants local constants = {} --#region Reactor Protection System (on the PLC) Limits +---@class _rps_constants local rps = {} rps.MAX_DAMAGE_PERCENT = 90 -- damage >= 90% @@ -21,6 +23,7 @@ constants.RPS_LIMITS = rps --#region Annunciator Limits +---@class _annunciator_constants local annunc = {} annunc.RCSFlowLow_H2O = -3.2 -- flow < -3.2 mB/s @@ -44,6 +47,7 @@ constants.ANNUNCIATOR_LIMITS = annunc --#region Supervisor Alarm Limits +---@class _alarm_constants local alarms = {} -- unit alarms diff --git a/scada-common/crash.lua b/scada-common/crash.lua index 45a3874..1942a8d 100644 --- a/scada-common/crash.lua +++ b/scada-common/crash.lua @@ -6,8 +6,10 @@ local comms = require("scada-common.comms") local log = require("scada-common.log") local util = require("scada-common.util") -local core = require("graphics.core") +local has_graphics, core = pcall(require, "graphics.core") +local has_lockbox, lockbox = pcall(require, "lockbox") +---@class crash_handler local crash = {} local app = "unknown" @@ -34,7 +36,8 @@ function crash.handler(error) log.info(util.c("APPLICATION: ", app)) log.info(util.c("FIRMWARE VERSION: ", ver)) log.info(util.c("COMMS VERSION: ", comms.version)) - log.info(util.c("GRAPHICS VERSION: ", core.version)) + if has_graphics then log.info(util.c("GRAPHICS VERSION: ", core.version)) end + if has_lockbox then log.info(util.c("LOCKBOX VERSION: ", lockbox.version)) end log.info("----------------------------------") log.info(debug.traceback("--- begin debug trace ---", 1)) log.info("--- end debug trace ---") diff --git a/scada-common/log.lua b/scada-common/log.lua index 51b5373..2c266bb 100644 --- a/scada-common/log.lua +++ b/scada-common/log.lua @@ -4,14 +4,11 @@ local util = require("scada-common.util") ----@class log +---@class logger local log = {} ---@alias MODE integer -local MODE = { - APPEND = 0, - NEW = 1 -} +local MODE = { APPEND = 0, NEW = 1 } log.MODE = MODE diff --git a/scada-common/mqueue.lua b/scada-common/mqueue.lua index b48e4ad..fc60a1e 100644 --- a/scada-common/mqueue.lua +++ b/scada-common/mqueue.lua @@ -4,6 +4,14 @@ local mqueue = {} +---@class queue_item +---@field qtype MQ_TYPE +---@field message any + +---@class queue_data +---@field key any +---@field val any + ---@enum MQ_TYPE local TYPE = { COMMAND = 0, @@ -13,22 +21,14 @@ local TYPE = { mqueue.TYPE = TYPE +local insert = table.insert +local remove = table.remove + -- create a new message queue ---@nodiscard function mqueue.new() local queue = {} - local insert = table.insert - local remove = table.remove - - ---@class queue_item - ---@field qtype MQ_TYPE - ---@field message any - - ---@class queue_data - ---@field key any - ---@field val any - ---@class mqueue local public = {} @@ -48,28 +48,20 @@ function mqueue.new() -- push a new item onto the queue ---@param qtype MQ_TYPE ---@param message any - local function _push(qtype, message) - insert(queue, { qtype = qtype, message = message }) - end + local function _push(qtype, message) insert(queue, { qtype = qtype, message = message }) end -- push a command onto the queue ---@param message any - function public.push_command(message) - _push(TYPE.COMMAND, message) - end + function public.push_command(message) _push(TYPE.COMMAND, message) end -- push data onto the queue ---@param key any ---@param value any - function public.push_data(key, value) - _push(TYPE.DATA, { key = key, val = value }) - end + function public.push_data(key, value) _push(TYPE.DATA, { key = key, val = value }) end -- push a packet onto the queue ---@param packet packet|frame - function public.push_packet(packet) - _push(TYPE.PACKET, packet) - end + function public.push_packet(packet) _push(TYPE.PACKET, packet) end -- get an item off the queue ---@nodiscard @@ -77,9 +69,7 @@ function mqueue.new() function public.pop() if #queue > 0 then return remove(queue, 1) - else - return nil - end + else return nil end end return public diff --git a/scada-common/network.lua b/scada-common/network.lua index 491faef..d9fa83f 100644 --- a/scada-common/network.lua +++ b/scada-common/network.lua @@ -2,19 +2,21 @@ -- Network Communications -- +local comms = require("scada-common.comms") +local log = require("scada-common.log") +local util = require("scada-common.util") + local md5 = require("lockbox.digest.md5") local sha256 = require("lockbox.digest.sha2_256") local pbkdf2 = require("lockbox.kdf.pbkdf2") local hmac = require("lockbox.mac.hmac") local stream = require("lockbox.util.stream") local array = require("lockbox.util.array") -local comms = require("scada-common.comms") - -local log = require("scada-common.log") -local util = require("scada-common.util") +---@class scada_net_interface local network = {} +-- cryptography engine local c_eng = { key = nil, hmac = nil @@ -212,6 +214,7 @@ function network.nic(modem) if packet_hmac == computed_hmac then -- log.debug("crypto.modem.receive: HMAC verified in " .. (util.time_ms() - start) .. "ms") s_packet.receive(side, sender, reply_to, textutils.unserialize(msg), distance) + s_packet.stamp_authenticated() else -- log.debug("crypto.modem.receive: HMAC failed verification in " .. (util.time_ms() - start) .. "ms") end diff --git a/scada-common/ppm.lua b/scada-common/ppm.lua index 90f0b17..6af4e2f 100644 --- a/scada-common/ppm.lua +++ b/scada-common/ppm.lua @@ -8,15 +8,13 @@ local util = require("scada-common.util") ---@class ppm local ppm = {} -local ACCESS_FAULT = nil ---@type nil - -local UNDEFINED_FIELD = "undefined field" - +local ACCESS_FAULT = nil ---@type nil +local UNDEFINED_FIELD = "undefined field" local VIRTUAL_DEVICE_TYPE = "ppm_vdev" -ppm.ACCESS_FAULT = ACCESS_FAULT -ppm.UNDEFINED_FIELD = UNDEFINED_FIELD -ppm.VIRTUAL_DEVICE_TYPE = VIRTUAL_DEVICE_TYPE +ppm.ACCESS_FAULT = ACCESS_FAULT +ppm.UNDEFINED_FIELD = UNDEFINED_FIELD +ppm.VIRTUAL_DEVICE_TYPE = VIRTUAL_DEVICE_TYPE ---------------------------- -- PRIVATE DATA/FUNCTIONS -- @@ -34,9 +32,9 @@ local ppm_sys = { mute = false } --- wrap peripheral calls with lua protected call as we don't want a disconnect to crash a program
--- also provides peripheral-specific fault checks (auto-clear fault defaults to true)
--- assumes iface is a valid peripheral +-- Wrap peripheral calls with lua protected call as we don't want a disconnect to crash a program. +-- Additionally provides peripheral-specific fault checks (auto-clear fault defaults to true).
+-- Note: assumes iface is a valid peripheral. ---@param iface string CC peripheral interface local function peri_init(iface) local self = { @@ -307,16 +305,12 @@ end -- list all available peripherals ---@nodiscard ---@return table names -function ppm.list_avail() - return peripheral.getNames() -end +function ppm.list_avail() return peripheral.getNames() end -- list mounted peripherals ---@nodiscard ---@return table mounts -function ppm.list_mounts() - return ppm_sys.mounts -end +function ppm.list_mounts() return ppm_sys.mounts end -- get a mounted peripheral side/interface by device table ---@nodiscard @@ -390,9 +384,7 @@ end -- get the fission reactor (if multiple, returns the first) ---@nodiscard ---@return table|nil reactor function table -function ppm.get_fission_reactor() - return ppm.get_device("fissionReactorLogicAdapter") -end +function ppm.get_fission_reactor() return ppm.get_device("fissionReactorLogicAdapter") end -- get the wireless modem (if multiple, returns the first)
-- if this is in a CraftOS emulated environment, wired modems will be used instead @@ -419,9 +411,7 @@ function ppm.get_monitor_list() local list = {} for iface, device in pairs(ppm_sys.mounts) do - if device.type == "monitor" then - list[iface] = device - end + if device.type == "monitor" then list[iface] = device end end return list diff --git a/scada-common/psil.lua b/scada-common/psil.lua index 13dcaa5..09686bf 100644 --- a/scada-common/psil.lua +++ b/scada-common/psil.lua @@ -9,14 +9,12 @@ local psil = {} -- instantiate a new PSI layer ---@nodiscard function psil.create() - local self = { - ic = {} - } + local ic = {} -- allocate a new interconnect field ---@key string data key local function alloc(key) - self.ic[key] = { subscribers = {}, value = nil } + ic[key] = { subscribers = {}, value = nil } end ---@class psil @@ -28,22 +26,22 @@ function psil.create() ---@param func function function to call on change function public.subscribe(key, func) -- allocate new key if not found or notify if value is found - if self.ic[key] == nil then + if ic[key] == nil then alloc(key) - elseif self.ic[key].value ~= nil then - func(self.ic[key].value) + elseif ic[key].value ~= nil then + func(ic[key].value) end -- subscribe to key - table.insert(self.ic[key].subscribers, { notify = func }) + table.insert(ic[key].subscribers, { notify = func }) end -- unsubscribe a function from a given key ---@param key string data key ---@param func function function to unsubscribe function public.unsubscribe(key, func) - if self.ic[key] ~= nil then - util.filter_table(self.ic[key].subscribers, function (s) return s.notify ~= func end) + if ic[key] ~= nil then + util.filter_table(ic[key].subscribers, function (s) return s.notify ~= func end) end end @@ -51,32 +49,32 @@ function psil.create() ---@param key string data key ---@param value any data value function public.publish(key, value) - if self.ic[key] == nil then alloc(key) end + if ic[key] == nil then alloc(key) end - if self.ic[key].value ~= value then - for i = 1, #self.ic[key].subscribers do - self.ic[key].subscribers[i].notify(value) + if ic[key].value ~= value then + for i = 1, #ic[key].subscribers do + ic[key].subscribers[i].notify(value) end end - self.ic[key].value = value + ic[key].value = value end -- publish a toggled boolean value to a given key, passing it to all subscribers if it has changed
-- this is intended to be used to toggle boolean indicators such as heartbeats without extra state variables ---@param key string data key function public.toggle(key) - if self.ic[key] == nil then alloc(key) end + if ic[key] == nil then alloc(key) end - self.ic[key].value = self.ic[key].value == false + ic[key].value = ic[key].value == false - for i = 1, #self.ic[key].subscribers do - self.ic[key].subscribers[i].notify(self.ic[key].value) + for i = 1, #ic[key].subscribers do + ic[key].subscribers[i].notify(ic[key].value) end end -- clear the contents of the interconnect - function public.purge() self.ic = nil end + function public.purge() ic = {} end return public end diff --git a/scada-common/rsio.lua b/scada-common/rsio.lua index 29acfd2..13a6d6a 100644 --- a/scada-common/rsio.lua +++ b/scada-common/rsio.lua @@ -123,7 +123,7 @@ function rsio.to_string(port) if util.is_int(port) and port > 0 and port <= #names then return names[port] else - return "" + return "UNKNOWN" end end diff --git a/scada-common/tcd.lua b/scada-common/tcd.lua index f5ff0e5..a3c920f 100644 --- a/scada-common/tcd.lua +++ b/scada-common/tcd.lua @@ -68,7 +68,7 @@ function tcd.handle(event) end end --- identify any overdo callbacks
+-- identify any overdue callbacks
-- prints to log debug output function tcd.diagnostics() for timer, entry in pairs(registry) do diff --git a/scada-common/util.lua b/scada-common/util.lua index 99f62a6..bf44884 100644 --- a/scada-common/util.lua +++ b/scada-common/util.lua @@ -7,6 +7,9 @@ local cc_strings = require("cc.strings") ---@class util local util = {} +-- scada-common version +util.version = "1.0.1" + -- ENVIRONMENT CONSTANTS -- util.TICK_TIME_S = 0.05 @@ -80,9 +83,9 @@ end ---@return string function util.strrep(str, n) local repeated = "" - for _ = 1, n do - repeated = repeated .. str - end + + for _ = 1, n do repeated = repeated .. str end + return repeated end @@ -123,7 +126,9 @@ function util.strwrap(str, limit) return cc_strings.wrap(str, limit) end ---@diagnostic disable-next-line: unused-vararg function util.concat(...) local str = "" + for _, v in ipairs(arg) do str = str .. util.strval(v) end + return str end @@ -322,7 +327,8 @@ end --- EVENT_CONSUMER: this function consumes events function util.nop() util.psleep(0.05) end --- attempt to maintain a minimum loop timing (duration of execution) +-- attempt to maintain a minimum loop timing (duration of execution)
+-- note: will not yield for time periods less than 50ms ---@nodiscard ---@param target_timing integer minimum amount of milliseconds to wait for ---@param last_update integer millisecond time of last update @@ -399,7 +405,7 @@ local function GFE(fe) return fe / 1000000000.0 end local function TFE(fe) return fe / 1000000000000.0 end local function PFE(fe) return fe / 1000000000000000.0 end local function EFE(fe) return fe / 1000000000000000000.0 end -- if you accomplish this please touch grass -local function ZFE(fe) return fe / 1000000000000000000000.0 end -- please stop +local function ZFE(fe) return fe / 1000000000000000000000.0 end -- how & why did you do this? -- format a power value into XXX.XX UNIT format (FE, kFE, MFE, GFE, TFE, PFE, EFE, ZFE) ---@nodiscard diff --git a/supervisor/config.lua b/supervisor/config.lua index 2373f0d..5ea565b 100644 --- a/supervisor/config.lua +++ b/supervisor/config.lua @@ -10,27 +10,39 @@ config.RTU_CHANNEL = 16242 config.CRD_CHANNEL = 16243 -- pocket comms channel config.PKT_CHANNEL = 16244 --- max trusted modem message distance (0 to disable check) +-- 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 +-- time in seconds (>= 2) before assuming a remote +-- device is no longer active config.PLC_TIMEOUT = 5 config.RTU_TIMEOUT = 5 config.CRD_TIMEOUT = 5 config.PKT_TIMEOUT = 5 --- facility authentication key (do NOT use one of your passwords) +-- 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 +-- all devices on this network must use this key -- config.AUTH_KEY = "SCADAfacility123" -- expected number of reactors config.NUM_REACTORS = 4 --- expected number of boilers/turbines for each reactor +-- expected number of devices for each unit config.REACTOR_COOLING = { - { BOILERS = 1, TURBINES = 1 }, -- reactor unit 1 - { BOILERS = 1, TURBINES = 1 }, -- reactor unit 2 - { BOILERS = 1, TURBINES = 1 }, -- reactor unit 3 - { BOILERS = 1, TURBINES = 1 } -- reactor unit 4 +-- reactor unit 1 +{ BOILERS = 1, TURBINES = 1, TANK = false }, +-- reactor unit 2 +{ BOILERS = 1, TURBINES = 1, TANK = false }, +-- reactor unit 3 +{ BOILERS = 1, TURBINES = 1, TANK = false }, +-- reactor unit 4 +{ BOILERS = 1, TURBINES = 1, TANK = false } } +-- advanced facility dynamic tank configuration +-- (see wiki for details) +-- by default, dynamic tanks are for each unit +config.FAC_TANK_MODE = 0 +config.FAC_TANK_DEFS = { 0, 0, 0, 0 } -- log path config.LOG_PATH = "/log.txt" diff --git a/supervisor/facility.lua b/supervisor/facility.lua index 77aa676..8c91157 100644 --- a/supervisor/facility.lua +++ b/supervisor/facility.lua @@ -1,22 +1,32 @@ -local const = require("scada-common.constants") -local log = require("scada-common.log") -local rsio = require("scada-common.rsio") -local types = require("scada-common.types") -local util = require("scada-common.util") +local audio = require("scada-common.audio") +local const = require("scada-common.constants") +local log = require("scada-common.log") +local rsio = require("scada-common.rsio") +local types = require("scada-common.types") +local util = require("scada-common.util") -local unit = require("supervisor.unit") +local unit = require("supervisor.unit") -local rsctl = require("supervisor.session.rsctl") +local qtypes = require("supervisor.session.rtu.qtypes") -local PROCESS = types.PROCESS -local PROCESS_NAMES = types.PROCESS_NAMES -local PRIO = types.ALARM_PRIORITY -local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE -local WASTE = types.WASTE_PRODUCT -local WASTE_MODE = types.WASTE_MODE +local rsctl = require("supervisor.session.rsctl") + +local TONE = audio.TONE + +local ALARM = types.ALARM +local PRIO = types.ALARM_PRIORITY +local ALARM_STATE = types.ALARM_STATE +local CONTAINER_MODE = types.CONTAINER_MODE +local PROCESS = types.PROCESS +local PROCESS_NAMES = types.PROCESS_NAMES +local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE +local WASTE_MODE = types.WASTE_MODE +local WASTE = types.WASTE_PRODUCT local IO = rsio.IO +local DTV_RTU_S_DATA = qtypes.DTV_RTU_S_DATA + -- 7.14 kJ per blade for 1 mB of fissile fuel
-- 2856 FE per blade per 1 mB, 285.6 FE per blade per 0.1 mB (minimum) local POWER_PER_BLADE = util.joules_to_fe(7140) @@ -54,12 +64,13 @@ local facility = {} -- create a new facility management object ---@nodiscard ---@param num_reactors integer number of reactor units ----@param cooling_conf table cooling configurations of reactor units +---@param cooling_conf sv_cooling_conf cooling configurations of reactor units function facility.new(num_reactors, cooling_conf) local self = { units = {}, status_text = { "START UP", "initializing..." }, all_sys_ok = false, + allow_testing = false, -- rtus rtu_conn_count = 0, rtu_list = {}, @@ -109,6 +120,12 @@ function facility.new(num_reactors, cooling_conf) waste_product = WASTE.PLUTONIUM, current_waste_product = WASTE.PLUTONIUM, pu_fallback = false, + -- alarm tones + tone_states = {}, + test_tone_set = false, + test_tone_reset = false, + test_tone_states = {}, + test_alarm_states = {}, -- statistics im_stat_init = false, avg_charge = util.mov_avg(3, 0.0), @@ -118,7 +135,7 @@ function facility.new(num_reactors, cooling_conf) -- create units for i = 1, num_reactors do - table.insert(self.units, unit.new(i, cooling_conf[i].BOILERS, cooling_conf[i].TURBINES)) + table.insert(self.units, unit.new(i, cooling_conf.r_cool[i].BOILERS, cooling_conf.r_cool[i].TURBINES)) table.insert(self.group_map, 0) end @@ -128,6 +145,13 @@ function facility.new(num_reactors, cooling_conf) -- init redstone RTU I/O controller self.io_ctl = rsctl.new(self.redstone) + -- fill blank alarm/tone states + for _ = 1, 12 do table.insert(self.test_alarm_states, false) end + for _ = 1, 8 do + table.insert(self.tone_states, false) + table.insert(self.test_tone_states, false) + end + -- check if all auto-controlled units completed ramping ---@nodiscard local function _all_units_ramped() @@ -262,15 +286,20 @@ function facility.new(num_reactors, cooling_conf) -- supervisor sessions reporting the list of active RTU sessions ---@param rtu_sessions table session list of all connected RTUs - function public.report_rtus(rtu_sessions) - self.rtu_conn_count = #rtu_sessions - end + function public.report_rtus(rtu_sessions) self.rtu_conn_count = #rtu_sessions end -- update (iterate) the facility management function public.update() -- unlink RTU unit sessions if they are closed for _, v in pairs(self.rtu_list) do util.filter_table(v, function (u) return u.is_connected() end) end + -- check if test routines are allowed right now + self.allow_testing = true + for i = 1, #self.units do + local u = self.units[i] ---@type reactor_unit + self.allow_testing = self.allow_testing and u.is_safe_idle() + end + -- current state for process control local charge_update = 0 local rate_update = 0 @@ -732,24 +761,138 @@ function facility.new(num_reactors, cooling_conf) self.io_ctl.digital_write(IO.F_ALARM, has_alarm) end - ----------------------------- - -- Update Waste Processing -- - ----------------------------- + ---------------- + -- Unit Tasks -- + ---------------- local insufficent_po_rate = false + local need_emcool = false + for i = 1, #self.units do local u = self.units[i] ---@type reactor_unit + + -- update auto waste processing if u.get_control_inf().waste_mode == WASTE_MODE.AUTO then if (u.get_sna_rate() * 10.0) < u.get_burn_rate() then insufficent_po_rate = true - break + end + end + + -- check if unit activated emergency coolant & uses facility tanks + if (cooling_conf.fac_tank_mode > 0) and u.is_emer_cool_tripped() and (cooling_conf.fac_tank_defs[i] == 2) then + need_emcool = true + end + end + + -- update waste product + if self.waste_product == WASTE.PLUTONIUM or (self.pu_fallback and insufficent_po_rate) then + self.current_waste_product = WASTE.PLUTONIUM + else self.current_waste_product = self.waste_product end + + -- make sure dynamic tanks are allowing outflow if required + -- set all, rather than trying to determine which is for which (simpler & safer) + -- there should be no need for any to be in fill only mode + if need_emcool then + for i = 1, #self.tanks do + local session = self.tanks[i] ---@type unit_session + local tank = session.get_db() ---@type dynamicv_session_db + + if tank.state.container_mode == CONTAINER_MODE.FILL then + session.get_cmd_queue().push_data(DTV_RTU_S_DATA.SET_CONT_MODE, CONTAINER_MODE.BOTH) end end end - if self.waste_product == WASTE.PLUTONIUM or (self.pu_fallback and insufficent_po_rate) then - self.current_waste_product = WASTE.PLUTONIUM - else self.current_waste_product = self.waste_product end + ------------------------ + -- Update Alarm Tones -- + ------------------------ + + local allow_test = self.allow_testing and self.test_tone_set + + local alarms = { false, false, false, false, false, false, false, false, false, false, false, false } + + -- reset tone states before re-evaluting + for i = 1, #self.tone_states do self.tone_states[i] = false end + + if allow_test then + alarms = self.test_alarm_states + else + -- check all alarms for all units + for i = 1, #self.units do + local u = self.units[i] ---@type reactor_unit + for id, alarm in pairs(u.get_alarms()) do + alarms[id] = alarms[id] or (alarm == ALARM_STATE.TRIPPED) + end + end + + if not self.test_tone_reset then + -- clear testing alarms if we aren't using them + for i = 1, #self.test_alarm_states do self.test_alarm_states[i] = false end + end + end + + -- Evaluate Alarms -- + + -- containment breach is worst case CRITICAL alarm, this takes priority + if alarms[ALARM.ContainmentBreach] then + self.tone_states[TONE.T_1800Hz_Int_4Hz] = true + else + -- critical damage is highest priority CRITICAL level alarm + if alarms[ALARM.CriticalDamage] then + self.tone_states[TONE.T_660Hz_Int_125ms] = true + else + -- EMERGENCY level alarms + URGENT over temp + if alarms[ALARM.ReactorDamage] or alarms[ALARM.ReactorOverTemp] or alarms[ALARM.ReactorWasteLeak] then + self.tone_states[TONE.T_544Hz_440Hz_Alt] = true + -- URGENT level turbine trip + elseif alarms[ALARM.TurbineTrip] then + self.tone_states[TONE.T_745Hz_Int_1Hz] = true + -- URGENT level reactor lost + elseif alarms[ALARM.ReactorLost] then + self.tone_states[TONE.T_340Hz_Int_2Hz] = true + -- TIMELY level alarms + elseif alarms[ALARM.ReactorHighTemp] or alarms[ALARM.ReactorHighWaste] or alarms[ALARM.RCSTransient] then + self.tone_states[TONE.T_800Hz_Int] = true + end + end + + -- check RPS transient URGENT level alarm + if alarms[ALARM.RPSTransient] then + self.tone_states[TONE.T_1000Hz_Int] = true + -- disable really painful audio combination + self.tone_states[TONE.T_340Hz_Int_2Hz] = false + end + end + + -- radiation is a big concern, always play this CRITICAL level alarm if active + if alarms[ALARM.ContainmentRadiation] then + self.tone_states[TONE.T_800Hz_1000Hz_Alt] = true + -- we are going to disable the RPS trip alarm audio due to conflict, and if it was enabled + -- then we can re-enable the reactor lost alarm audio since it doesn't painfully combine with this one + if self.tone_states[TONE.T_1000Hz_Int] and alarms[ALARM.ReactorLost] then self.tone_states[TONE.T_340Hz_Int_2Hz] = true end + -- it sounds *really* bad if this is in conjunction with these other tones, so disable them + self.tone_states[TONE.T_745Hz_Int_1Hz] = false + self.tone_states[TONE.T_800Hz_Int] = false + self.tone_states[TONE.T_1000Hz_Int] = false + end + + -- add to tone states if testing is active + if allow_test then + for i = 1, #self.tone_states do + self.tone_states[i] = self.tone_states[i] or self.test_tone_states[i] + end + + self.test_tone_reset = false + else + if not self.test_tone_reset then + -- clear testing tones if we aren't using them + for i = 1, #self.test_tone_states do self.test_tone_states[i] = false end + end + + -- flag that tones were reset + self.test_tone_set = false + self.test_tone_reset = true + end end -- call the update function of all units in the facility
@@ -891,8 +1034,52 @@ function facility.new(num_reactors, cooling_conf) return self.pu_fallback end + -- DIAGNOSTIC TESTING -- + + -- attempt to set a test tone state + ---@param id TONE|0 tone ID or 0 to disable all + ---@param state boolean state + ---@return boolean allow_testing, table test_tone_states + function public.diag_set_test_tone(id, state) + if self.allow_testing then + self.test_tone_set = true + self.test_tone_reset = false + + if id == 0 then + for i = 1, #self.test_tone_states do self.test_tone_states[i] = false end + else + self.test_tone_states[id] = state + end + end + + return self.allow_testing, self.test_tone_states + end + + -- attempt to set a test alarm state + ---@param id ALARM|0 alarm ID or 0 to disable all + ---@param state boolean state + ---@return boolean allow_testing, table test_alarm_states + function public.diag_set_test_alarm(id, state) + if self.allow_testing then + self.test_tone_set = true + self.test_tone_reset = false + + if id == 0 then + for i = 1, #self.test_alarm_states do self.test_alarm_states[i] = false end + else + self.test_alarm_states[id] = state + end + end + + return self.allow_testing, self.test_alarm_states + end + -- READ STATES/PROPERTIES -- + -- get current alarm tone on/off states + ---@nodiscard + function public.get_alarm_tones() return self.tone_states end + -- get build properties of all facility devices ---@nodiscard ---@param type RTU_UNIT_TYPE? type or nil to include only a particular unit type, or to include all if nil diff --git a/supervisor/session/coordinator.lua b/supervisor/session/coordinator.lua index 98b7431..aa64e17 100644 --- a/supervisor/session/coordinator.lua +++ b/supervisor/session/coordinator.lua @@ -17,6 +17,9 @@ local FAC_COMMAND = comms.FAC_COMMAND local SV_Q_DATA = svqtypes.SV_Q_DATA +-- grace period in seconds for coordinator to finish UI draw to prevent timeout +local WATCHDOG_GRACE = 20.0 + -- retry time constants in ms -- local INITIAL_WAIT = 1500 local RETRY_PERIOD = 1000 @@ -61,6 +64,7 @@ function coordinator.new_session(id, s_addr, in_queue, out_queue, timeout, facil r_seq_num = nil, connected = true, conn_watchdog = util.new_watchdog(timeout), + establish_time = util.time_s(), last_rtt = 0, -- periodic messages periodics = { @@ -150,7 +154,8 @@ function coordinator.new_session(id, s_addr, in_queue, out_queue, timeout, facil local function _send_fac_status() local status = { facility.get_control_status(), - facility.get_rtu_statuses() + facility.get_rtu_statuses(), + facility.get_alarm_tones() } _send(SCADA_CRDN_TYPE.FAC_STATUS, status) @@ -168,7 +173,8 @@ function coordinator.new_session(id, s_addr, in_queue, out_queue, timeout, facil unit.get_rtu_statuses(), unit.get_annunciator(), unit.get_alarms(), - unit.get_state() + unit.get_state(), + unit.get_valves() } end @@ -354,7 +360,15 @@ function coordinator.new_session(id, s_addr, in_queue, out_queue, timeout, facil -- check if a timer matches this session's watchdog ---@nodiscard function public.check_wd(timer) - return self.conn_watchdog.is_timer(timer) and self.connected + local is_wd = self.conn_watchdog.is_timer(timer) and self.connected + + -- if we are waiting for initial coordinator UI draw, don't close yet + if is_wd and (util.time_s() - self.establish_time) <= WATCHDOG_GRACE then + self.conn_watchdog.feed() + is_wd = false + end + + return is_wd end -- close the connection diff --git a/supervisor/session/pocket.lua b/supervisor/session/pocket.lua index 9de55ab..30ca7eb 100644 --- a/supervisor/session/pocket.lua +++ b/supervisor/session/pocket.lua @@ -33,8 +33,9 @@ local PERIODICS = { ---@param in_queue mqueue in message queue ---@param out_queue mqueue out message queue ---@param timeout number communications timeout +---@param facility facility facility data table ---@param fp_ok boolean if the front panel UI is running -function pocket.new_session(id, s_addr, in_queue, out_queue, timeout, fp_ok) +function pocket.new_session(id, s_addr, in_queue, out_queue, timeout, facility, fp_ok) -- print a log message to the terminal as long as the UI isn't running local function println(message) if not fp_ok then util.println_ts(message) end end @@ -129,6 +130,55 @@ function pocket.new_session(id, s_addr, in_queue, out_queue, timeout, fp_ok) elseif pkt.type == SCADA_MGMT_TYPE.CLOSE then -- close the session _close() + elseif pkt.type == SCADA_MGMT_TYPE.DIAG_TONE_GET then + -- get the state of alarm tones + _send_mgmt(SCADA_MGMT_TYPE.DIAG_TONE_GET, facility.get_alarm_tones()) + elseif pkt.type == SCADA_MGMT_TYPE.DIAG_TONE_SET then + local valid = false + + -- attempt to set a tone state + if pkt.scada_frame.is_authenticated() then + if pkt.length == 2 then + if type(pkt.data[1]) == "number" and type(pkt.data[2]) == "boolean" then + valid = true + + -- try to set tone states, then send back if testing is allowed + local allow_testing, test_tone_states = facility.diag_set_test_tone(pkt.data[1], pkt.data[2]) + _send_mgmt(SCADA_MGMT_TYPE.DIAG_TONE_SET, { allow_testing, test_tone_states }) + else + log.debug(log_header .. "SCADA diag tone set packet data type mismatch") + end + else + log.debug(log_header .. "SCADA diag tone set packet length mismatch") + end + else + log.debug(log_header .. "DIAG_TONE_SET is blocked without HMAC for security") + end + + if not valid then _send_mgmt(SCADA_MGMT_TYPE.DIAG_TONE_SET, { false }) end + elseif pkt.type == SCADA_MGMT_TYPE.DIAG_ALARM_SET then + local valid = false + + -- attempt to set an alarm state + if pkt.scada_frame.is_authenticated() then + if pkt.length == 2 then + if type(pkt.data[1]) == "number" and type(pkt.data[2]) == "boolean" then + valid = true + + -- try to set alarm states, then send back if testing is allowed + local allow_testing, test_alarm_states = facility.diag_set_test_alarm(pkt.data[1], pkt.data[2]) + _send_mgmt(SCADA_MGMT_TYPE.DIAG_ALARM_SET, { allow_testing, test_alarm_states }) + else + log.debug(log_header .. "SCADA diag alarm set packet data type mismatch") + end + else + log.debug(log_header .. "SCADA diag alarm set packet length mismatch") + end + else + log.debug(log_header .. "DIAG_ALARM_SET is blocked without HMAC for security") + end + + if not valid then _send_mgmt(SCADA_MGMT_TYPE.DIAG_ALARM_SET, { false }) end else log.debug(log_header .. "handler received unsupported SCADA_MGMT packet type " .. pkt.type) end diff --git a/supervisor/session/rsctl.lua b/supervisor/session/rsctl.lua index fb17efe..1bdef5a 100644 --- a/supervisor/session/rsctl.lua +++ b/supervisor/session/rsctl.lua @@ -11,6 +11,18 @@ function rsctl.new(redstone_rtus) ---@class rs_controller local public = {} + -- check if a redstone port has available connections + ---@param port IO_PORT + ---@return boolean + function public.is_connected(port) + for i = 1, #redstone_rtus do + local db = redstone_rtus[i].get_db() ---@type redstone_session_db + if db.io[port] ~= nil then return true end + end + + return false + end + -- write to a digital redstone port (applies to all RTUs) ---@param port IO_PORT ---@param value boolean diff --git a/supervisor/session/rtu.lua b/supervisor/session/rtu.lua index c33f11a..1e42f49 100644 --- a/supervisor/session/rtu.lua +++ b/supervisor/session/rtu.lua @@ -26,7 +26,8 @@ local SCADA_MGMT_TYPE = comms.SCADA_MGMT_TYPE local RTU_UNIT_TYPE = types.RTU_UNIT_TYPE local PERIODICS = { - KEEP_ALIVE = 2000 + KEEP_ALIVE = 2000, + ALARM_TONES = 500 } -- create a new RTU session @@ -58,7 +59,8 @@ function rtu.new_session(id, s_addr, in_queue, out_queue, timeout, advertisement -- periodic messages periodics = { last_update = 0, - keep_alive = 0 + keep_alive = 0, + alarm_tones = 0 }, units = {} } @@ -389,6 +391,14 @@ function rtu.new_session(id, s_addr, in_queue, out_queue, timeout, advertisement periodics.keep_alive = 0 end + -- alarm tones + + periodics.alarm_tones = periodics.alarm_tones + elapsed + if periodics.alarm_tones >= PERIODICS.ALARM_TONES then + _send_mgmt(SCADA_MGMT_TYPE.RTU_TONE_ALARM, { facility.get_alarm_tones() }) + periodics.alarm_tones = 0 + end + self.periodics.last_update = util.time() -------------------------------------------- diff --git a/supervisor/session/svsessions.lua b/supervisor/session/svsessions.lua index 075f090..05b543b 100644 --- a/supervisor/session/svsessions.lua +++ b/supervisor/session/svsessions.lua @@ -104,8 +104,8 @@ local function _sv_handle_outq(session) -- max 100ms spent processing queue if util.time() - handle_start > 100 then - log.warning("[SVS] supervisor out queue handler exceeded 100ms queue process limit") - log.warning(util.c("[SVS] offending session: ", session)) + log.debug("[SVS] supervisor out queue handler exceeded 100ms queue process limit") + log.debug(util.c("[SVS] offending session: ", session)) break end end @@ -198,7 +198,7 @@ end ---@param nic nic network interface device ---@param fp_ok boolean front panel active ---@param num_reactors integer number of reactors ----@param cooling_conf table cooling configuration definition +---@param cooling_conf sv_cooling_conf cooling configuration definition function svsessions.init(nic, fp_ok, num_reactors, cooling_conf) self.nic = nic self.fp_ok = fp_ok @@ -430,7 +430,8 @@ function svsessions.establish_pdg_session(source_addr, version) local id = self.next_ids.pdg - pdg_s.instance = pocket.new_session(id, source_addr, pdg_s.in_queue, pdg_s.out_queue, config.PKT_TIMEOUT, self.fp_ok) + pdg_s.instance = pocket.new_session(id, source_addr, pdg_s.in_queue, pdg_s.out_queue, config.PKT_TIMEOUT, self.facility, + self.fp_ok) table.insert(self.sessions.pdg, pdg_s) local mt = { diff --git a/supervisor/startup.lua b/supervisor/startup.lua index 59f5c4f..c1a7036 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 = "v0.20.4" +local SUPERVISOR_VERSION = "v1.0.0" local println = util.println local println_ts = util.println_ts @@ -48,11 +48,16 @@ cfv.assert_type_num(config.PKT_TIMEOUT) cfv.assert_min(config.PKT_TIMEOUT, 2) cfv.assert_type_int(config.NUM_REACTORS) cfv.assert_type_table(config.REACTOR_COOLING) +cfv.assert_type_int(config.FAC_TANK_MODE) +cfv.assert_type_table(config.FAC_TANK_DEFS) cfv.assert_type_str(config.LOG_PATH) cfv.assert_type_int(config.LOG_MODE) assert(cfv.valid(), "bad config file: missing/invalid fields") +assert((config.FAC_TANK_MODE == 0) or (config.NUM_REACTORS == #config.FAC_TANK_DEFS), + "bad config file: FAC_TANK_DEFS length not equal to NUM_REACTORS") + cfv.assert_eq(#config.REACTOR_COOLING, config.NUM_REACTORS) assert(cfv.valid(), "config: number of cooling configs different than number of units") @@ -61,6 +66,7 @@ for i = 1, config.NUM_REACTORS do assert(cfv.valid(), "config: missing cooling entry for reactor " .. i) cfv.assert_type_int(config.REACTOR_COOLING[i].BOILERS) cfv.assert_type_int(config.REACTOR_COOLING[i].TURBINES) + cfv.assert_type_bool(config.REACTOR_COOLING[i].TANK) assert(cfv.valid(), "config: missing boilers/turbines for reactor " .. i) cfv.assert_min(config.REACTOR_COOLING[i].BOILERS, 0) cfv.assert_min(config.REACTOR_COOLING[i].TURBINES, 1) diff --git a/supervisor/supervisor.lua b/supervisor/supervisor.lua index fb33b06..37707a3 100644 --- a/supervisor/supervisor.lua +++ b/supervisor/supervisor.lua @@ -32,7 +32,8 @@ function supervisor.comms(_version, nic, fp_ok) -- configuration data local num_reactors = config.NUM_REACTORS - local cooling_conf = config.REACTOR_COOLING + ---@class sv_cooling_conf + local cooling_conf = { r_cool = config.REACTOR_COOLING, fac_tank_mode = config.FAC_TANK_MODE, fac_tank_defs = config.FAC_TANK_DEFS } local self = { last_est_acks = {} @@ -295,16 +296,10 @@ function supervisor.comms(_version, nic, fp_ok) local s_id = svsessions.establish_crd_session(src_addr, firmware_v) if s_id ~= false then - local cfg = { num_reactors } - for i = 1, #cooling_conf do - table.insert(cfg, cooling_conf[i].BOILERS) - table.insert(cfg, cooling_conf[i].TURBINES) - end - println(util.c("CRD (", firmware_v, ") [@", src_addr, "] \xbb connected")) log.info(util.c("CRD_ESTABLISH: coordinator (", firmware_v, ") [@", src_addr, "] connected with session ID ", s_id)) - _send_establish(packet.scada_frame, ESTABLISH_ACK.ALLOW, cfg) + _send_establish(packet.scada_frame, ESTABLISH_ACK.ALLOW, { num_reactors, cooling_conf }) else if last_ack ~= ESTABLISH_ACK.COLLISION then log.info("CRD_ESTABLISH: denied new coordinator [@" .. src_addr .. "] due to already being connected to another coordinator") diff --git a/supervisor/unit.lua b/supervisor/unit.lua index c233631..262de6b 100644 --- a/supervisor/unit.lua +++ b/supervisor/unit.lua @@ -333,14 +333,28 @@ function unit.new(reactor_id, num_boilers, num_turbines) --#region redstone I/O - local __rs_w = self.io_ctl.digital_write + -- create a generic valve interface + ---@nodiscard + ---@param port IO_PORT + local function _make_valve_iface(port) + ---@class unit_valve_iface + local iface = { + open = function () self.io_ctl.digital_write(port, true) end, + close = function () self.io_ctl.digital_write(port, false) end, + -- check valve state + ---@nodiscard + ---@return 0|1|2 0 for not connected, 1 for inactive, 2 for active + check = function () return util.trinary(self.io_ctl.is_connected(port), util.trinary(self.io_ctl.digital_read(port), 2, 1), 0) end + } + return iface + end -- valves - local waste_pu = { open = function () __rs_w(IO.WASTE_PU, true) end, close = function () __rs_w(IO.WASTE_PU, false) end } - local waste_sna = { open = function () __rs_w(IO.WASTE_PO, true) end, close = function () __rs_w(IO.WASTE_PO, false) end } - local waste_po = { open = function () __rs_w(IO.WASTE_POPL, true) end, close = function () __rs_w(IO.WASTE_POPL, false) end } - local waste_sps = { open = function () __rs_w(IO.WASTE_AM, true) end, close = function () __rs_w(IO.WASTE_AM, false) end } - local emer_cool = { open = function () __rs_w(IO.U_EMER_COOL, true) end, close = function () __rs_w(IO.U_EMER_COOL, false) end } + local waste_pu = _make_valve_iface(IO.WASTE_PU) + local waste_sna = _make_valve_iface(IO.WASTE_PO) + local waste_po = _make_valve_iface(IO.WASTE_POPL) + local waste_sps = _make_valve_iface(IO.WASTE_AM) + local emer_cool = _make_valve_iface(IO.U_EMER_COOL) ---@class unit_valves self.valves = { @@ -719,6 +733,27 @@ function unit.new(reactor_id, num_boilers, num_turbines) return false end + -- check if the reactor is connected, is stopped, the RPS is not tripped, and no alarms are active + ---@nodiscard + function public.is_safe_idle() + -- can't be disconnected + if self.plc_i == nil then return false end + + -- reactor must be stopped and RPS can't be tripped + if self.plc_i.get_status().status or self.plc_i.get_db().rps_tripped then return false end + + -- alarms must be inactive and not tripping + for _, alarm in pairs(self.alarms) do + if not (alarm.state == AISTATE.INACTIVE or alarm.state == AISTATE.RING_BACK) then return false end + end + + return true + end + + -- check if emergency coolant activation has been tripped + ---@nodiscard + function public.is_emer_cool_tripped() return self.emcool_opened end + -- get build properties of machines -- -- filter options @@ -813,7 +848,12 @@ function unit.new(reactor_id, num_boilers, num_turbines) end -- basic SNA statistical information - status.sna = { #self.snas, public.get_sna_rate() } + local total_peak = 0 + for i = 1, #self.snas do + local db = self.snas[i].get_db() ---@type sna_session_db + total_peak = total_peak + db.state.peak_production + end + status.sna = { #self.snas, public.get_sna_rate(), total_peak } -- radiation monitors (environment detectors) status.rad_mon = {} @@ -864,6 +904,19 @@ function unit.new(reactor_id, num_boilers, num_turbines) } end + -- get valve states + ---@nodiscard + function public.get_valves() + local v = self.valves + return { + v.waste_pu.check(), + v.waste_sna.check(), + v.waste_po.check(), + v.waste_sps.check(), + v.emer_cool.check() + } + end + -- get the reactor ID ---@nodiscard function public.get_id() return self.r_id end diff --git a/supervisor/unitlogic.lua b/supervisor/unitlogic.lua index 6003ac8..080f529 100644 --- a/supervisor/unitlogic.lua +++ b/supervisor/unitlogic.lua @@ -10,11 +10,13 @@ local qtypes = require("supervisor.session.rtu.qtypes") local RPS_TRIP_CAUSE = types.RPS_TRIP_CAUSE local TRI_FAIL = types.TRI_FAIL +local CONTAINER_MODE = types.CONTAINER_MODE local DUMPING_MODE = types.DUMPING_MODE local PRIO = types.ALARM_PRIORITY local ALARM_STATE = types.ALARM_STATE local TBV_RTU_S_DATA = qtypes.TBV_RTU_S_DATA +local DTV_RTU_S_DATA = qtypes.DTV_RTU_S_DATA local IO = rsio.IO @@ -826,6 +828,16 @@ function logic.handle_redstone(self) end end + -- make sure dynamic tanks are allowing outflow + for i = 1, #self.tanks do + local session = self.tanks[i] ---@type unit_session + local tank = session.get_db() ---@type dynamicv_session_db + + if tank.state.container_mode == CONTAINER_MODE.FILL then + session.get_cmd_queue().push_data(DTV_RTU_S_DATA.SET_CONT_MODE, CONTAINER_MODE.BOTH) + end + end + if self.db.annunciator.EmergencyCoolant > 1 and not self.emcool_opened then log.info(util.c("UNIT ", self.r_id, " emergency coolant valve opened")) log.info(util.c("UNIT ", self.r_id, " turbines set to dump excess steam"))